508 lines
19 KiB
Python
508 lines
19 KiB
Python
#!/usr/bin/python
|
|
# -*- coding: utf-8 -*-
|
|
#
|
|
# Copyright 2014 The Plaso Project Authors.
|
|
# Please see the AUTHORS file for details on individual authors.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Parser for Mac OS X Keychain files."""
|
|
|
|
# INFO: Only supports internet and application passwords,
|
|
# because it is the only data that contains timestamp events.
|
|
# Keychain can also store "secret notes". These notes are stored
|
|
# in the same type than the application format, then, they are already
|
|
# supported. The stored wifi are also application passwords.
|
|
|
|
# TODO: the AccessControl for each entry has not been implemented. Until now,
|
|
# I know that the AccessControl from Internet and App password are stored
|
|
# using other tables (Symmetric, certificates, etc). Access Control
|
|
# indicates which specific tool, or all, is able to use this entry.
|
|
|
|
|
|
import binascii
|
|
import construct
|
|
import logging
|
|
import os
|
|
|
|
from plaso.lib import errors
|
|
from plaso.lib import event
|
|
from plaso.lib import eventdata
|
|
from plaso.lib import timelib
|
|
from plaso.parsers import interface
|
|
from plaso.parsers import manager
|
|
|
|
|
|
__author__ = 'Joaquin Moreno Garijo (Joaquin.MorenoGarijo.2013@live.rhul.ac.uk)'
|
|
|
|
|
|
class KeychainInternetRecordEvent(event.EventObject):
|
|
"""Convenience class for an keychain internet record event."""
|
|
|
|
DATA_TYPE = 'mac:keychain:internet'
|
|
|
|
def __init__(
|
|
self, timestamp, timestamp_desc, entry_name, account_name,
|
|
text_description, comments, where, protocol, type_protocol, ssgp_hash):
|
|
"""Initializes the event object.
|
|
|
|
Args:
|
|
timestamp: Description of the timestamp value.
|
|
timestamp_desc: Timelib type of the timestamp.
|
|
entry_name: Name of the entry.
|
|
account_name: Name of the account.
|
|
text_description: Short description about the entry.
|
|
comments: String that contains the comments added by the user.
|
|
where: The domain name or IP where the password is used.
|
|
protocol: The internet protocol used (eg. https).
|
|
type_protocol: The sub-protocol used (eg. form).
|
|
ssgp_hash: String with hexadecimal values from the password / cert hash.
|
|
"""
|
|
super(KeychainInternetRecordEvent, self).__init__()
|
|
self.timestamp = timestamp
|
|
self.timestamp_desc = timestamp_desc
|
|
self.entry_name = entry_name
|
|
self.account_name = account_name
|
|
self.text_description = text_description
|
|
self.where = where
|
|
self.protocol = protocol
|
|
self.type_protocol = type_protocol
|
|
self.comments = comments
|
|
self.ssgp_hash = ssgp_hash
|
|
|
|
|
|
class KeychainApplicationRecordEvent(event.EventObject):
|
|
"""Convenience class for an keychain application password record event."""
|
|
DATA_TYPE = 'mac:keychain:application'
|
|
|
|
def __init__(
|
|
self, timestamp, timestamp_desc, entry_name,
|
|
account_name, text_description, comments, ssgp_hash):
|
|
"""Initializes the event object.
|
|
|
|
Args:
|
|
timestamp: Description of the timestamp value.
|
|
timestamp_desc: Timelib type of the timestamp.
|
|
entry_name: Name of the entry.
|
|
account_name: Name of the account.
|
|
text_description: Short description about the entry.
|
|
comments: String that contains the comments added by the user.
|
|
ssgp_hash: String with hexadecimal values from the password / cert hash.
|
|
"""
|
|
super(KeychainApplicationRecordEvent, self).__init__()
|
|
self.timestamp = timestamp
|
|
self.timestamp_desc = timestamp_desc
|
|
self.entry_name = entry_name
|
|
self.account_name = account_name
|
|
self.text_description = text_description
|
|
self.comments = comments
|
|
self.ssgp_hash = ssgp_hash
|
|
|
|
|
|
class KeychainParser(interface.BaseParser):
|
|
"""Parser for Keychain files."""
|
|
|
|
NAME = 'mac_keychain'
|
|
DESCRIPTION = u'Parser for Mac OS X Keychain files.'
|
|
|
|
KEYCHAIN_MAGIC_HEADER = 'kych'
|
|
KEYCHAIN_MAJOR_VERSION = 1
|
|
KEYCHAIN_MINOR_VERSION = 0
|
|
|
|
RECORD_TYPE_APPLICATION = 0x80000000
|
|
RECORD_TYPE_INTERNET = 0x80000001
|
|
|
|
# DB HEADER.
|
|
KEYCHAIN_DB_HEADER = construct.Struct(
|
|
'db_header',
|
|
construct.String('magic', 4),
|
|
construct.UBInt16('major_version'),
|
|
construct.UBInt16('minor_version'),
|
|
construct.UBInt32('header_size'),
|
|
construct.UBInt32('schema_offset'),
|
|
construct.Padding(4))
|
|
|
|
# DB SCHEMA.
|
|
KEYCHAIN_DB_SCHEMA = construct.Struct(
|
|
'db_schema',
|
|
construct.UBInt32('size'),
|
|
construct.UBInt32('number_of_tables'))
|
|
# For each number_of_tables, the schema has a TABLE_OFFSET with the
|
|
# offset starting in the DB_SCHEMA.
|
|
TABLE_OFFSET = construct.UBInt32('table_offset')
|
|
|
|
TABLE_HEADER = construct.Struct(
|
|
'table_header',
|
|
construct.UBInt32('table_size'),
|
|
construct.UBInt32('record_type'),
|
|
construct.UBInt32('number_of_records'),
|
|
construct.UBInt32('first_record'),
|
|
construct.UBInt32('index_offset'),
|
|
construct.Padding(4),
|
|
construct.UBInt32('recordnumbercount'))
|
|
|
|
RECORD_HEADER = construct.Struct(
|
|
'record_header',
|
|
construct.UBInt32('entry_length'),
|
|
construct.Padding(12),
|
|
construct.UBInt32('ssgp_length'),
|
|
construct.Padding(4),
|
|
construct.UBInt32('creation_time'),
|
|
construct.UBInt32('last_mod_time'),
|
|
construct.UBInt32('text_description'),
|
|
construct.Padding(4),
|
|
construct.UBInt32('comments'),
|
|
construct.Padding(8),
|
|
construct.UBInt32('entry_name'),
|
|
construct.Padding(20),
|
|
construct.UBInt32('account_name'),
|
|
construct.Padding(4))
|
|
RECORD_HEADER_APP = construct.Struct(
|
|
'record_entry_app',
|
|
RECORD_HEADER,
|
|
construct.Padding(4))
|
|
RECORD_HEADER_INET = construct.Struct(
|
|
'record_entry_inet',
|
|
RECORD_HEADER,
|
|
construct.UBInt32('where'),
|
|
construct.UBInt32('protocol'),
|
|
construct.UBInt32('type'),
|
|
construct.Padding(4),
|
|
construct.UBInt32('url'))
|
|
|
|
TEXT = construct.PascalString(
|
|
'text', length_field=construct.UBInt32('length'))
|
|
TIME = construct.Struct(
|
|
'timestamp',
|
|
construct.String('year', 4),
|
|
construct.String('month', 2),
|
|
construct.String('day', 2),
|
|
construct.String('hour', 2),
|
|
construct.String('minute', 2),
|
|
construct.String('second', 2),
|
|
construct.Padding(2))
|
|
TYPE_TEXT = construct.String('type', 4)
|
|
|
|
# TODO: add more protocols.
|
|
_PROTOCOL_TRANSLATION_DICT = {
|
|
u'htps': u'https',
|
|
u'smtp': u'smtp',
|
|
u'imap': u'imap',
|
|
u'http': u'http'}
|
|
|
|
def _GetTimestampFromEntry(self, parser_context, file_entry, structure):
|
|
"""Parse a time entry structure into a microseconds since Epoch in UTC.
|
|
|
|
Args:
|
|
parser_context: A parser context object (instance of ParserContext).
|
|
file_entry: A file entry object (instance of dfvfs.FileEntry).
|
|
structure: TIME entry structure:
|
|
year: String with the number of the year.
|
|
month: String with the number of the month.
|
|
day: String with the number of the day.
|
|
hour: String with the number of the month.
|
|
minute: String with the number of the minute.
|
|
second: String with the number of the second.
|
|
|
|
Returns:
|
|
Microseconds since Epoch in UTC.
|
|
"""
|
|
try:
|
|
return timelib.Timestamp.FromTimeParts(
|
|
int(structure.year, 10), int(structure.month, 10),
|
|
int(structure.day, 10), int(structure.hour, 10),
|
|
int(structure.minute, 10), int(structure.second, 10))
|
|
except ValueError:
|
|
logging.warning(
|
|
u'[{0:s}] Invalid keychain time {1!s} in file: {2:s}'.format(
|
|
self.NAME, parser_context.GetDisplayName(file_entry), structure))
|
|
return 0
|
|
|
|
def _ReadEntryApplication(
|
|
self, parser_context, file_object, file_entry=None, parser_chain=None):
|
|
"""Extracts the information from an application password entry.
|
|
|
|
Args:
|
|
parser_context: A parser context object (instance of ParserContext).
|
|
file_object: A file-like object that points to an Keychain file.
|
|
file_entry: Optional file entry object (instance of dfvfs.FileEntry).
|
|
The default is None.
|
|
parser_chain: Optional string containing the parsing chain up to this
|
|
point. The default is None.
|
|
"""
|
|
offset = file_object.tell()
|
|
try:
|
|
record = self.RECORD_HEADER_APP.parse_stream(file_object)
|
|
except (IOError, construct.FieldError):
|
|
logging.warning((
|
|
u'[{0:s}] Unsupported record header at 0x{1:08x} in file: '
|
|
u'{2:s}').format(
|
|
self.NAME, offset, parser_context.GetDisplayName(file_entry)))
|
|
return
|
|
|
|
(ssgp_hash, creation_time, last_mod_time, text_description,
|
|
comments, entry_name, account_name) = self._ReadEntryHeader(
|
|
parser_context, file_entry, file_object, record.record_header, offset)
|
|
|
|
# Move to the end of the record, and then, prepared for the next record.
|
|
file_object.seek(
|
|
record.record_header.entry_length + offset - file_object.tell(),
|
|
os.SEEK_CUR)
|
|
event_object = KeychainApplicationRecordEvent(
|
|
creation_time, eventdata.EventTimestamp.CREATION_TIME,
|
|
entry_name, account_name, text_description, comments, ssgp_hash)
|
|
parser_context.ProduceEvent(
|
|
event_object, parser_chain=parser_chain, file_entry=file_entry)
|
|
|
|
if creation_time != last_mod_time:
|
|
event_object = KeychainApplicationRecordEvent(
|
|
last_mod_time, eventdata.EventTimestamp.MODIFICATION_TIME,
|
|
entry_name, account_name, text_description, comments, ssgp_hash)
|
|
parser_context.ProduceEvent(
|
|
event_object, parser_chain=parser_chain, file_entry=file_entry)
|
|
|
|
def _ReadEntryHeader(
|
|
self, parser_context, file_entry, file_object, record, offset):
|
|
"""Read the common record attributes.
|
|
|
|
Args:
|
|
parser_context: A parser context object (instance of ParserContext).
|
|
file_entry: A file entry object (instance of dfvfs.FileEntry).
|
|
file_object: A file-like object that points to an Keychain file.
|
|
record: Structure with the header of the record.
|
|
offset: First byte of the record.
|
|
|
|
Returns:
|
|
A list of:
|
|
ssgp_hash: Hash of the encrypted data (passwd, cert, note).
|
|
creation_time: When the entry was created.
|
|
last_mod_time: Last time the entry was updated.
|
|
text_description: A brief description of the entry.
|
|
entry_name: Name of the entry
|
|
account_name: Name of the account.
|
|
"""
|
|
# Info: The hash header always start with the string ssgp follow by
|
|
# the hash. Furthermore The fields are always a multiple of four.
|
|
# Then if it is not multiple the value is padded by 0x00.
|
|
ssgp_hash = binascii.hexlify(file_object.read(record.ssgp_length)[4:])
|
|
|
|
file_object.seek(
|
|
record.creation_time - file_object.tell() + offset - 1, os.SEEK_CUR)
|
|
creation_time = self._GetTimestampFromEntry(
|
|
parser_context, file_entry, self.TIME.parse_stream(file_object))
|
|
|
|
file_object.seek(
|
|
record.last_mod_time - file_object.tell() + offset - 1, os.SEEK_CUR)
|
|
last_mod_time = self._GetTimestampFromEntry(
|
|
parser_context, file_entry, self.TIME.parse_stream(file_object))
|
|
|
|
# The comment field does not always contain data.
|
|
if record.text_description:
|
|
file_object.seek(
|
|
record.text_description - file_object.tell() + offset -1,
|
|
os.SEEK_CUR)
|
|
try:
|
|
text_description = self.TEXT.parse_stream(file_object)
|
|
except construct.FieldError:
|
|
text_description = u'N/A (error)'
|
|
else:
|
|
text_description = u'N/A'
|
|
|
|
# The comment field does not always contain data.
|
|
if record.comments:
|
|
file_object.seek(
|
|
record.text_description - file_object.tell() + offset -1,
|
|
os.SEEK_CUR)
|
|
try:
|
|
comments = self.TEXT.parse_stream(file_object)
|
|
except construct.FieldError:
|
|
comments = u'N/A (error)'
|
|
else:
|
|
comments = u'N/A'
|
|
|
|
file_object.seek(
|
|
record.entry_name - file_object.tell() + offset - 1, os.SEEK_CUR)
|
|
try:
|
|
entry_name = self.TEXT.parse_stream(file_object)
|
|
except construct.FieldError:
|
|
entry_name = u'N/A (error)'
|
|
|
|
file_object.seek(
|
|
record.account_name - file_object.tell() + offset - 1, os.SEEK_CUR)
|
|
try:
|
|
account_name = self.TEXT.parse_stream(file_object)
|
|
except construct.FieldError:
|
|
account_name = u'N/A (error)'
|
|
|
|
return (
|
|
ssgp_hash, creation_time, last_mod_time,
|
|
text_description, comments, entry_name, account_name)
|
|
|
|
def _ReadEntryInternet(
|
|
self, parser_context, file_object, file_entry=None, parser_chain=None):
|
|
"""Extracts the information from an Internet password entry.
|
|
|
|
Args:
|
|
parser_context: A parser context object (instance of ParserContext).
|
|
file_object: A file-like object that points to an Keychain file.
|
|
file_entry: Optional file entry object (instance of dfvfs.FileEntry).
|
|
The default is None.
|
|
parser_chain: Optional string containing the parsing chain up to this
|
|
point. The default is None.
|
|
"""
|
|
offset = file_object.tell()
|
|
try:
|
|
record = self.RECORD_HEADER_INET.parse_stream(file_object)
|
|
except (IOError, construct.FieldError):
|
|
logging.warning((
|
|
u'[{0:s}] Unsupported record header at 0x{1:08x} in file: '
|
|
u'{2:s}').format(
|
|
self.NAME, offset, parser_context.GetDisplayName(file_entry)))
|
|
return
|
|
|
|
(ssgp_hash, creation_time, last_mod_time, text_description,
|
|
comments, entry_name, account_name) = self._ReadEntryHeader(
|
|
parser_context, file_entry, file_object, record.record_header, offset)
|
|
if not record.where:
|
|
where = u'N/A'
|
|
protocol = u'N/A'
|
|
type_protocol = u'N/A'
|
|
else:
|
|
file_object.seek(
|
|
record.where - file_object.tell() + offset - 1, os.SEEK_CUR)
|
|
where = self.TEXT.parse_stream(file_object)
|
|
file_object.seek(
|
|
record.protocol - file_object.tell() + offset - 1, os.SEEK_CUR)
|
|
protocol = self.TYPE_TEXT.parse_stream(file_object)
|
|
file_object.seek(
|
|
record.type - file_object.tell() + offset - 1, os.SEEK_CUR)
|
|
type_protocol = self.TEXT.parse_stream(file_object)
|
|
type_protocol = self._PROTOCOL_TRANSLATION_DICT.get(
|
|
type_protocol, type_protocol)
|
|
if record.url:
|
|
file_object.seek(
|
|
record.url - file_object.tell() + offset - 1, os.SEEK_CUR)
|
|
url = self.TEXT.parse_stream(file_object)
|
|
where = u'{0:s}{1:s}'.format(where, url)
|
|
|
|
# Move to the end of the record, and then, prepared for the next record.
|
|
file_object.seek(
|
|
record.record_header.entry_length + offset - file_object.tell(),
|
|
os.SEEK_CUR)
|
|
|
|
event_object = KeychainInternetRecordEvent(
|
|
creation_time, eventdata.EventTimestamp.CREATION_TIME,
|
|
entry_name, account_name, text_description,
|
|
comments, where, protocol, type_protocol, ssgp_hash)
|
|
parser_context.ProduceEvent(
|
|
event_object, parser_chain=parser_chain, file_entry=file_entry)
|
|
|
|
if creation_time != last_mod_time:
|
|
event_object = KeychainInternetRecordEvent(
|
|
last_mod_time, eventdata.EventTimestamp.MODIFICATION_TIME,
|
|
entry_name, account_name, text_description,
|
|
comments, where, protocol, type_protocol, ssgp_hash)
|
|
parser_context.ProduceEvent(
|
|
event_object, parser_chain=parser_chain, file_entry=file_entry)
|
|
|
|
def _VerifyStructure(self, file_object):
|
|
"""Verify that we are dealing with an Keychain entry.
|
|
|
|
Args:
|
|
file_object: A file-like object that points to an Keychain file.
|
|
|
|
Returns:
|
|
A list of table positions if it is a keychain, None otherwise.
|
|
"""
|
|
# INFO: The HEADER KEYCHAIN:
|
|
# [DBHEADER] + [DBSCHEMA] + [OFFSET TABLE A] + ... + [OFFSET TABLE Z]
|
|
# Where the table offset is relative to the first byte of the DB Schema,
|
|
# then we must add to this offset the size of the [DBHEADER].
|
|
try:
|
|
db_header = self.KEYCHAIN_DB_HEADER.parse_stream(file_object)
|
|
except (IOError, construct.FieldError):
|
|
return
|
|
if (db_header.minor_version != self.KEYCHAIN_MINOR_VERSION or
|
|
db_header.major_version != self.KEYCHAIN_MAJOR_VERSION or
|
|
db_header.magic != self.KEYCHAIN_MAGIC_HEADER):
|
|
return
|
|
|
|
# Read the database schema and extract the offset for all the tables.
|
|
# They are ordered by file position from the top to the bottom of the file.
|
|
try:
|
|
db_schema = self.KEYCHAIN_DB_SCHEMA.parse_stream(file_object)
|
|
except (IOError, construct.FieldError):
|
|
return
|
|
table_offsets = []
|
|
for _ in range(db_schema.number_of_tables):
|
|
try:
|
|
table_offset = self.TABLE_OFFSET.parse_stream(file_object)
|
|
except (IOError, construct.FieldError):
|
|
return
|
|
table_offsets.append(table_offset + self.KEYCHAIN_DB_HEADER.sizeof())
|
|
return table_offsets
|
|
|
|
def Parse(self, parser_context, file_entry, parser_chain=None):
|
|
"""Extract data from a Keychain file.
|
|
|
|
Args:
|
|
parser_context: A parser context object (instance of ParserContext).
|
|
file_entry: A file entry object (instance of dfvfs.FileEntry).
|
|
parser_chain: Optional string containing the parsing chain up to this
|
|
point. The default is None.
|
|
"""
|
|
file_object = file_entry.GetFileObject()
|
|
table_offsets = self._VerifyStructure(file_object)
|
|
if not table_offsets:
|
|
file_object.close()
|
|
raise errors.UnableToParseFile(u'The file is not a Keychain file.')
|
|
|
|
# Add ourselves to the parser chain, which will be used in all subsequent
|
|
# event creation in this parser.
|
|
parser_chain = self._BuildParserChain(parser_chain)
|
|
|
|
for table_offset in table_offsets:
|
|
# Skipping X bytes, unknown data at this point.
|
|
file_object.seek(table_offset - file_object.tell(), os.SEEK_CUR)
|
|
try:
|
|
table = self.TABLE_HEADER.parse_stream(file_object)
|
|
except construct.FieldError as exception:
|
|
logging.warning((
|
|
u'[{0:s}] Unable to parse table header in file: {1:s} '
|
|
u'with error: {2:s}.').format(
|
|
self.NAME, parser_context.GetDisplayName(file_entry),
|
|
exception))
|
|
continue
|
|
|
|
# Table_offset: absolute byte in the file where the table starts.
|
|
# table.first_record: first record in the table, relative to the
|
|
# first byte of the table.
|
|
file_object.seek(
|
|
table_offset + table.first_record - file_object.tell(), os.SEEK_CUR)
|
|
|
|
if table.record_type == self.RECORD_TYPE_INTERNET:
|
|
for _ in range(table.number_of_records):
|
|
self._ReadEntryInternet(
|
|
parser_context, file_object, file_entry=file_entry,
|
|
parser_chain=parser_chain)
|
|
|
|
elif table.record_type == self.RECORD_TYPE_APPLICATION:
|
|
for _ in range(table.number_of_records):
|
|
self._ReadEntryApplication(
|
|
parser_context, file_object, file_entry=file_entry,
|
|
parser_chain=parser_chain)
|
|
|
|
file_object.close()
|
|
|
|
|
|
manager.ParsersManager.RegisterParser(KeychainParser)
|