From d5bb9ae8d3c0859cda157365bc4001688b483513 Mon Sep 17 00:00:00 2001 From: Alexis Brignoni Date: Tue, 22 Sep 2026 16:02:21 -0400 Subject: [PATCH 1/4] Add KakaoTalk support for iOS Five artifacts from the KakaoTalk iOS client's own stores: - Messages (Message.sqlite): sender, chat, type and times in the clear; the message body and attachment are encrypted at rest and reported as stored, with the published scheme cited. - Chats (Talk.sqlite ZCHAT): rooms, member and unread counts, folder. - Users (ZUSER): friends and chat members, names and profile fields. - Address Book Matches (ZCONTACT): synced device contacts. - Chat Media: files under the per chat media folders, attributed to a chat by the folder id the store records, rendered where the bytes are a type the report can show. Message.sqlite and Talk.sqlite are generic names, so each store is confirmed to carry the client's own layout before a row is reported. sentAt and readAt are Cocoa seconds; updateAt in the same table is Unix seconds, converted separately. Co-Authored-By: Claude Opus 4.8 --- scripts/artifacts/kakaoTalk.py | 454 +++++++++++++++++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 scripts/artifacts/kakaoTalk.py diff --git a/scripts/artifacts/kakaoTalk.py b/scripts/artifacts/kakaoTalk.py new file mode 100644 index 000000000..311d73178 --- /dev/null +++ b/scripts/artifacts/kakaoTalk.py @@ -0,0 +1,454 @@ +__artifacts_v2__ = { + "kakaoTalkMessages": { + "name": "KakaoTalk - Messages", + "description": "Message records from the KakaoTalk iOS client, with the body as stored", + "author": "@AlexisBrignoni, Claude", + "creation_date": "2026-09-22", + "last_update_date": "2026-09-22", + "requirements": "none", + "category": "KakaoTalk", + "notes": "Read from the Message table of Message.sqlite in the app's PrivateDocuments " + "folder. Most of each row's content sits in the write ahead log rather than in the " + "database file, so the -wal and -shm sidecars are in the declared paths and must be " + "staged with it. **The message body is encrypted at rest and is reported as " + "stored.** On the two devices tested every non empty value of the message column, " + "and of the attachment column, was base64 text that does not decode to readable " + "content, and no key that opens it was recovered, so the Message and Attachment " + "columns carry the stored ciphertext rather than text. Published research describes the " + "scheme as AES-CBC under a key derived from the account's user id by two HMAC " + "passes combined with XOR, decrypting each record on its own, and states that " + "the key and initialisation vector are byte arrays hardcoded in the client, " + "which it does not publish; no decryption is attempted here. Reference: D. Kim, " + "B. Kim, Y. Yang, H. Jang, 'Decryption and Artifact Analysis of KakaoTalk Data " + "in the iOS Environment', Journal of Digital Contents Society, Vol. 26, No. 5, " + "pp. 1363-1373, May 2025, http://dx.doi.org/10.9728/dcs.2025.26.5.1363. " + "Everything else in the row " + "is in the clear: the chat it belongs to, the sender, the type, and the times. Two " + "epochs appear in this one table and each is converted on its own: sentAt and " + "readAt are Cocoa seconds from 2001, and updateAt in the same row is Unix seconds. " + "That was measured rather than assumed, by converting the newest row of each device " + "both ways and keeping the reading that falls inside the acquisition period. Sender " + "Name is resolved from the ZUSER table of Talk.sqlite in the same app container by " + "the numeric user id the message records, which is a link the store keeps rather " + "than a correlation; a sender with no matching user row is reported with the id and " + "a blank name. Type is reported as stored: no mapping for the integer was " + "recoverable, because the client is closed source. " + "Field mapping was done against two private samples; no sample data is recorded for " + "them. A Message.sqlite that does not carry this client's own table and column " + "layout is skipped and logged, so another app's file of the same name cannot be " + "reported as KakaoTalk.", + "paths": ('*/Library/PrivateDocuments/Message.sqlite*', + '*/Library/PrivateDocuments/Talk.sqlite*'), + "output_types": "standard", + "artifact_icon": "message-circle" + }, + "kakaoTalkChats": { + "name": "KakaoTalk - Chats", + "description": "Chat rooms recorded by the KakaoTalk iOS client", + "author": "@AlexisBrignoni, Claude", + "creation_date": "2026-09-22", + "last_update_date": "2026-09-22", + "requirements": "none", + "category": "KakaoTalk", + "notes": "Read from the ZCHAT table of Talk.sqlite, a Core Data store, with the folder name " + "taken from ZCHATFOLDER where the chat's id appears in that folder's id list. " + "Updated is ZUPDATEDAT, Cocoa seconds from 2001. Room Name is the ZROOMNAME column " + "and was blank on most rows of the devices tested: a one to one chat carries no " + "room name, and the display name an examiner sees in the app is built from the " + "members instead, so a blank here is not a missing value. Chat Type and View Type " + "are reported as stored. Member Count is the app's own active member count, and Unread " + "Count was zero on every row of the devices tested, kept because a chat with " + "unread messages would show it. The " + "last message is reported by its id and type only, because the last message text " + "the row also carries is encrypted, in the same way the message bodies are. Field " + "mapping was done against two private samples; no sample data is recorded for them.", + "paths": ('*/Library/PrivateDocuments/Talk.sqlite*',), + "output_types": "standard", + "artifact_icon": "messages" + }, + "kakaoTalkUsers": { + "name": "KakaoTalk - Users", + "description": "User records the KakaoTalk iOS client holds for friends and chat members", + "author": "@AlexisBrignoni, Claude", + "creation_date": "2026-09-22", + "last_update_date": "2026-09-22", + "requirements": "none", + "category": "KakaoTalk", + "notes": "Read from the ZUSER table of Talk.sqlite. A row is a user the client knows about, " + "which includes friends, members of chats the account is in, and official accounts, " + "so a row is not by itself evidence that the account communicated with that user. " + "Friend Type, Block Type and User Type are reported as stored: no mapping for the " + "integers was recoverable from the closed source client. Name, Nickname and Custom " + "Name are separate columns the store keeps separately, and they differed on rows of " + "the devices tested, so all three are reported rather than one being chosen. Phone " + "Number is encrypted at rest and is reported as stored: it was base64 text on 6 " + "of 7 and 6 of 8 user rows of the two devices tested, matching the published " + "description of that column. Profile " + "photo URLs are reported as stored and are remote addresses the report does not " + "fetch. Status Message and Email were blank, and Hidden and Favorite were zero, " + "on every row of the two devices tested; each is kept because a populated store " + "would carry these and their absence here is itself the finding. Field mapping " + "was done against two private samples; no sample data is " + "recorded for them.", + "paths": ('*/Library/PrivateDocuments/Talk.sqlite*',), + "output_types": "standard", + "artifact_icon": "users" + }, + "kakaoTalkContacts": { + "name": "KakaoTalk - Address Book Matches", + "description": "Device address book entries the KakaoTalk iOS client matched to its users", + "author": "@AlexisBrignoni, Claude", + "creation_date": "2026-09-22", + "last_update_date": "2026-09-22", + "requirements": "none", + "category": "KakaoTalk", + "notes": "Read from the ZCONTACT table of Talk.sqlite, which holds entries the client took " + "from the device address book when contact syncing was on. A row records a name and " + "number held on the device, not a KakaoTalk account, and the linked user id is " + "present only where the client matched that entry to one. The name is in the clear and " + "the numbers are not: both number columns were base64 text on every row of the two " + "devices tested and are reported as stored, encrypted in the same way as the message " + "bodies. A third column, ZORIGINALPHONENUMBER, held a value identical to the " + "normalised one on every row of both devices, so it is not reported separately; the " + "raw column, where the schema has it, differed from the normalised one on all 15 " + "rows of the device that carries it and is reported. The raw number and contact id " + "columns are absent from the older of the two schemas seen, so each is resolved per " + "store and left blank where the column does not exist, rather than costing the " + "artifact its rows. Field mapping was done against two private samples; " + "no sample data is recorded for them.", + "paths": ('*/Library/PrivateDocuments/Talk.sqlite*',), + "output_types": "standard", + "artifact_icon": "address-book" + }, + "kakaoTalkMedia": { + "name": "KakaoTalk - Chat Media", + "description": "Media files the KakaoTalk iOS client stored under its per chat folders", + "author": "@AlexisBrignoni, Claude", + "creation_date": "2026-09-22", + "last_update_date": "2026-09-22", + "requirements": "none", + "category": "KakaoTalk", + "notes": "One row per file under the client's chat, chatVideo and chatAudio folders, with " + "the file rendered where its bytes are a type the report can show. The folder a " + "file sits in is named with a chat id, which is the link the store records, so each " + "file is attributed to a chat rather than correlated to one: on the device with " + "media, seven distinct chat folder ids appeared, five of them present in the chat " + "table and two not, and those two are reported with the id and no room name. **The link stops " + "at the chat.** The message row's own attachment column is encrypted, so nothing in " + "the extraction ties a file to an individual message, and no guess from size or " + "time is made. That is why the media is not rendered on the message rows and this " + "artifact exists separately. Files whose name begins with a thumbnail marker are " + "the client's own reduced copies and are labelled as such rather than dropped. Chat Room " + "Name was blank on every media row of the devices tested: the media in these folders " + "belonged to chats the store left without a room name, and the one chat that did carry " + "a room name had no media here. The " + "content type is taken from the bytes, not from the name, because these files " + "carry no extension. Field mapping was done against two private samples; no sample " + "data is recorded for them.", + "paths": ('*/Library/PrivateDocuments/chat/*/*', + '*/Library/PrivateDocuments/chatVideo/*/*', + '*/Library/PrivateDocuments/chatAudio/*/*', + '*/Library/PrivateDocuments/Talk.sqlite*'), + "output_types": "standard", + "artifact_icon": "photo" + }, +} + +import os +import re +import sqlite3 + +from scripts.ilapfuncs import (artifact_processor, check_in_media, get_sqlite_db_records, + convert_unix_ts_to_utc, convert_cocoa_core_data_ts_to_utc, logfunc) + +# Message.sqlite and Talk.sqlite are generic names. A store is this client's only when it +# carries the client's own layout, so each is confirmed before a row is reported. +MESSAGE_MARKERS = {'sentAt', 'chatId', 'serverLogId', 'clientMsgId'} +TALK_TABLES = {'ZCHAT', 'ZUSER'} + +MEDIA_DIR = re.compile(r'/PrivateDocuments/(?Pchat|chatVideo|chatAudio)/(?P\d+)/') +THUMB = re.compile(r'(^|/)(_th_|.*-thum$)') + + +def _container_of(path): + found = re.match(r'(?P.*/Data/Application/[^/]+)/', path.replace('\\', '/')) + if found: + return found.group('root') + return os.path.dirname(path.replace('\\', '/')) + + +def _columns(db_path, table): + try: + with sqlite3.connect(f'file:{db_path}?mode=ro', uri=True) as con: + return {row[1] for row in con.execute(f'PRAGMA table_info("{table}")')} + except sqlite3.Error: + return set() + + +def _tables(db_path): + try: + with sqlite3.connect(f'file:{db_path}?mode=ro', uri=True) as con: + return {row[0] for row in con.execute( + "SELECT name FROM sqlite_master WHERE type='table'")} + except sqlite3.Error: + return set() + + +def _stores(context): + """Group this client's confirmed stores by the container they sit in.""" + grouped = {} + for file_found in context.get_files_found(): + file_found = str(file_found) + name = os.path.basename(file_found.replace('\\', '/')) + entry = grouped.setdefault(_container_of(file_found), {}) + if name == 'Message.sqlite': + if MESSAGE_MARKERS <= _columns(file_found, 'Message'): + entry['messages'] = file_found + else: + logfunc(f'KakaoTalk: {name} does not carry the client\'s layout, skipped') + elif name == 'Talk.sqlite': + if TALK_TABLES <= _tables(file_found): + entry['talk'] = file_found + else: + logfunc(f'KakaoTalk: {name} does not carry the client\'s layout, skipped') + elif MEDIA_DIR.search(file_found): + entry.setdefault('media', []).append(file_found) + return grouped + + +def _users_by_id(talk_path): + if not talk_path: + return {} + users = {} + for row in get_sqlite_db_records(talk_path, 'SELECT ZID, ZNAME, ZNICKNAME FROM ZUSER'): + if row[0] is not None: + users[str(row[0])] = row[1] or row[2] or '' + return users + + +def _chats_by_id(talk_path): + if not talk_path: + return {} + chats = {} + for row in get_sqlite_db_records(talk_path, 'SELECT ZID, ZROOMNAME, ZTYPE FROM ZCHAT'): + if row[0] is not None: + chats[str(row[0])] = (row[1] or '', row[2] if row[2] is not None else '') + return chats + + +@artifact_processor +def kakaoTalkMessages(context): + data_headers = ( + ('Sent', 'datetime'), + ('Read', 'datetime'), + ('Updated', 'datetime'), + 'Chat ID', + 'Sender User ID', + 'Sender Name', + 'Type', + 'Message (encrypted, as stored)', + 'Attachment (encrypted, as stored)', + 'Server Log ID', + 'Source File', + ) + data_list = [] + source_paths = [] + + for _, entry in sorted(_stores(context).items()): + message_path = entry.get('messages') + if not message_path: + continue + users = _users_by_id(entry.get('talk')) + query = ('SELECT sentAt, readAt, updateAt, chatId, userId, type, message, attachment, ' + 'serverLogId FROM Message') + rows = 0 + for row in get_sqlite_db_records(message_path, query): + sender = str(row[4]) if row[4] is not None else '' + data_list.append(( + convert_cocoa_core_data_ts_to_utc(row[0]) if row[0] else '', + convert_cocoa_core_data_ts_to_utc(row[1]) if row[1] else '', + convert_unix_ts_to_utc(row[2]) if row[2] else '', + row[3] if row[3] is not None else '', + sender, + users.get(sender, ''), + row[5] if row[5] is not None else '', + row[6] or '', + row[7] or '', + row[8] if row[8] is not None else '', + context.get_relative_path(message_path), + )) + rows += 1 + if rows: + source_paths.append(message_path) + + return data_headers, data_list, '\n'.join(source_paths) + + +@artifact_processor +def kakaoTalkChats(context): + data_headers = ( + ('Updated', 'datetime'), + 'Chat ID', + 'Room Name', + 'Chat Type (as stored)', + 'View Type (as stored)', + 'Member Count', + 'Unread Count', + 'Last Message ID', + 'Last Message Type (as stored)', + 'Folder', + 'Source File', + ) + data_list = [] + source_paths = [] + + for _, entry in sorted(_stores(context).items()): + talk_path = entry.get('talk') + if not talk_path: + continue + folders = {} + for row in get_sqlite_db_records(talk_path, 'SELECT ZNAME, ZCHATIDS FROM ZCHATFOLDER'): + for chat_id in re.findall(r'\d+', str(row[1] or '')): + folders[chat_id] = row[0] or '' + query = ('SELECT ZUPDATEDAT, ZID, ZROOMNAME, ZTYPE, ZVIEWTYPE, ZACTIVEMEMBERCOUNT, ' + 'ZUNREADCOUNT, ZLASTMESSAGEID, ZLASTMESSAGETYPE FROM ZCHAT') + rows = 0 + for row in get_sqlite_db_records(talk_path, query): + data_list.append(( + convert_cocoa_core_data_ts_to_utc(row[0]) if row[0] else '', + row[1] if row[1] is not None else '', + row[2] or '', + row[3] if row[3] is not None else '', + row[4] if row[4] is not None else '', + row[5] if row[5] is not None else '', + row[6] if row[6] is not None else '', + row[7] if row[7] is not None else '', + row[8] if row[8] is not None else '', + folders.get(str(row[1]), ''), + context.get_relative_path(talk_path), + )) + rows += 1 + if rows: + source_paths.append(talk_path) + + return data_headers, data_list, '\n'.join(source_paths) + + +@artifact_processor +def kakaoTalkUsers(context): + data_headers = ( + 'User ID', + 'Account ID', + 'Name', + 'Nickname', + 'Custom Name', + 'Status Message', + 'Email', + 'Phone Number (encrypted, as stored)', + 'Friend Type (as stored)', + 'Block Type (as stored)', + 'User Type (as stored)', + 'Hidden', + 'Favorite', + 'Profile Photo URL', + 'Source File', + ) + data_list = [] + source_paths = [] + + for _, entry in sorted(_stores(context).items()): + talk_path = entry.get('talk') + if not talk_path: + continue + query = ('SELECT ZID, ZACCOUNTID, ZNAME, ZNICKNAME, ZCUSTOMNAME, ZSTATUSMESSAGE, ZEMAIL, ' + 'ZPHONENUMBER, ZFRIENDTYPE, ZBLOCKTYPE, ZUSERTYPE, ZHIDDEN, ZFAVORITE, ZPHOTOURL ' + 'FROM ZUSER') + rows = 0 + for row in get_sqlite_db_records(talk_path, query): + data_list.append(tuple( + (value if value is not None else '') for value in row + ) + (context.get_relative_path(talk_path),)) + rows += 1 + if rows: + source_paths.append(talk_path) + + return data_headers, data_list, '\n'.join(source_paths) + + +@artifact_processor +def kakaoTalkContacts(context): + data_headers = ( + 'Name', + 'Phone Number (encrypted, as stored)', + 'Raw Phone Number (encrypted, as stored)', + 'Contact ID', + 'Linked User ID', + 'Source File', + ) + data_list = [] + source_paths = [] + + for _, entry in sorted(_stores(context).items()): + talk_path = entry.get('talk') + if not talk_path: + continue + columns = _columns(talk_path, 'ZCONTACT') + raw = 'ZRAWPHONENUMBER' if 'ZRAWPHONENUMBER' in columns else "''" + contact_id = 'ZCONTACTID' if 'ZCONTACTID' in columns else "''" + query = (f'SELECT ZNAME, ZPHONENUMBER, {raw}, {contact_id}, ZUSER ' + 'FROM ZCONTACT') + rows = 0 + for row in get_sqlite_db_records(talk_path, query): + data_list.append(tuple( + (value if value is not None else '') for value in row + ) + (context.get_relative_path(talk_path),)) + rows += 1 + if rows: + source_paths.append(talk_path) + + return data_headers, data_list, '\n'.join(source_paths) + + +@artifact_processor +def kakaoTalkMedia(context): + data_headers = ( + 'Chat ID', + 'Chat Room Name', + 'Folder', + 'File Name', + 'Is Thumbnail', + 'Size (bytes)', + ('Media', 'media'), + 'Source File', + ) + data_list = [] + source_paths = [] + + for _, entry in sorted(_stores(context).items()): + media_files = entry.get('media') or [] + if not media_files: + continue + chats = _chats_by_id(entry.get('talk')) + for file_found in sorted(media_files): + found = MEDIA_DIR.search(file_found.replace('\\', '/')) + if not found: + continue + chat_id = found.group('chatid') + name = os.path.basename(file_found.replace('\\', '/')) + try: + size = os.path.getsize(file_found) + except OSError: + size = '' + data_list.append(( + chat_id, + chats.get(chat_id, ('', ''))[0], + found.group('kind'), + name, + 'Yes' if THUMB.search(name) else 'No', + size, + check_in_media(file_found, name) or '', + context.get_relative_path(file_found), + )) + source_paths.append(file_found) + + return data_headers, data_list, '\n'.join(source_paths) From c03e9d1a2c601fcacd884fa724362d0e7cfc08d9 Mon Sep 17 00:00:00 2001 From: Alexis Brignoni Date: Tue, 22 Sep 2026 16:54:47 -0400 Subject: [PATCH 2/4] KakaoTalk iOS: decrypt the message, attachment and phone fields The client encrypts these per record with AES-CBC, under a key derived from the owning user id by PBKDF2-HMAC-SHA1 with two iterations and a key and IV hardcoded in the client. The derivation and the two constants were recovered from the client binary and validated against the stored ciphertext: two iterations decrypt every value, one and three decrypt none, and the wrong user id decrypts none. Message body, attachment metadata (keyed on Message.userId) and the ZUSER phone number (keyed on the row's ZID) are now decrypted; a value that does not decrypt is left as stored. The ZCONTACT address-book numbers use a different, unrecovered key and stay as stored. Co-Authored-By: Claude Opus 4.8 --- scripts/artifacts/kakaoTalk.py | 102 ++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 26 deletions(-) diff --git a/scripts/artifacts/kakaoTalk.py b/scripts/artifacts/kakaoTalk.py index 311d73178..1f42a2435 100644 --- a/scripts/artifacts/kakaoTalk.py +++ b/scripts/artifacts/kakaoTalk.py @@ -1,7 +1,7 @@ __artifacts_v2__ = { "kakaoTalkMessages": { "name": "KakaoTalk - Messages", - "description": "Message records from the KakaoTalk iOS client, with the body as stored", + "description": "Message records from the KakaoTalk iOS client, with the body decrypted", "author": "@AlexisBrignoni, Claude", "creation_date": "2026-09-22", "last_update_date": "2026-09-22", @@ -10,19 +10,21 @@ "notes": "Read from the Message table of Message.sqlite in the app's PrivateDocuments " "folder. Most of each row's content sits in the write ahead log rather than in the " "database file, so the -wal and -shm sidecars are in the declared paths and must be " - "staged with it. **The message body is encrypted at rest and is reported as " - "stored.** On the two devices tested every non empty value of the message column, " - "and of the attachment column, was base64 text that does not decode to readable " - "content, and no key that opens it was recovered, so the Message and Attachment " - "columns carry the stored ciphertext rather than text. Published research describes the " - "scheme as AES-CBC under a key derived from the account's user id by two HMAC " - "passes combined with XOR, decrypting each record on its own, and states that " - "the key and initialisation vector are byte arrays hardcoded in the client, " - "which it does not publish; no decryption is attempted here. Reference: D. Kim, " - "B. Kim, Y. Yang, H. Jang, 'Decryption and Artifact Analysis of KakaoTalk Data " - "in the iOS Environment', Journal of Digital Contents Society, Vol. 26, No. 5, " - "pp. 1363-1373, May 2025, http://dx.doi.org/10.9728/dcs.2025.26.5.1363. " - "Everything else in the row " + "staged with it. The message body and the attachment metadata are encrypted at rest: " + "on the two devices tested every non empty value of the message column, and of the " + "attachment column, was base64 ciphertext. The Message and Attachment columns are decrypted here: the client encrypts " + "each of these fields on its own with AES-CBC, under a key derived from the user " + "id that owns the row by PBKDF2-HMAC-SHA1 with two iterations, and a key and " + "initialisation vector hardcoded in the client. That derivation and the two " + "constants were recovered from the client binary and validated against the " + "stored ciphertext: two iterations decrypted every value on the devices tested " + "(all message and all attachment rows), while one and three iterations decrypted " + "none, and the wrong user id decrypted none. A value that does not decrypt is " + "left as the stored base64 rather than dropped. The published description of the " + "scheme is D. Kim, B. Kim, Y. Yang, H. Jang, 'Decryption and Artifact Analysis " + "of KakaoTalk Data in the iOS Environment', Journal of Digital Contents Society, " + "Vol. 26, No. 5, pp. 1363-1373, May 2025, " + "http://dx.doi.org/10.9728/dcs.2025.26.5.1363. Everything else in the row " "is in the clear: the chat it belongs to, the sender, the type, and the times. Two " "epochs appear in this one table and each is converted on its own: sentAt and " "readAt are Cocoa seconds from 2001, and updateAt in the same row is Unix seconds. " @@ -68,7 +70,7 @@ }, "kakaoTalkUsers": { "name": "KakaoTalk - Users", - "description": "User records the KakaoTalk iOS client holds for friends and chat members", + "description": "User records the KakaoTalk iOS client holds, with phone numbers decrypted", "author": "@AlexisBrignoni, Claude", "creation_date": "2026-09-22", "last_update_date": "2026-09-22", @@ -81,9 +83,10 @@ "integers was recoverable from the closed source client. Name, Nickname and Custom " "Name are separate columns the store keeps separately, and they differed on rows of " "the devices tested, so all three are reported rather than one being chosen. Phone " - "Number is encrypted at rest and is reported as stored: it was base64 text on 6 " - "of 7 and 6 of 8 user rows of the two devices tested, matching the published " - "description of that column. Profile " + "Number is encrypted at rest and is decrypted here with the same per record scheme as " + "the message bodies, keyed on the row's own user id; it decrypted to a phone number " + "on all 6 of the populated user rows of each device. A value that does not decrypt is " + "left as stored. Profile " "photo URLs are reported as stored and are remote addresses the report does not " "fetch. Status Message and Email were blank, and Hidden and Favorite were zero, " "on every row of the two devices tested; each is kept because a populated store " @@ -154,10 +157,14 @@ }, } +import base64 +import hashlib import os import re import sqlite3 +from Crypto.Cipher import AES + from scripts.ilapfuncs import (artifact_processor, check_in_media, get_sqlite_db_records, convert_unix_ts_to_utc, convert_cocoa_core_data_ts_to_utc, logfunc) @@ -169,6 +176,45 @@ MEDIA_DIR = re.compile(r'/PrivateDocuments/(?Pchat|chatVideo|chatAudio)/(?P\d+)/') THUMB = re.compile(r'(^|/)(_th_|.*-thum$)') +# KakaoTalk iOS field decryption. The client encrypts the message body, the attachment +# metadata and some phone numbers per record, and derives the AES key from the owning user +# id. The key derivation and the two hardcoded constants below were recovered from the +# client binary (TalkSharedBase, +[KAODatabaseCrypto makeKeyWithIdentifier:]) and validated +# against real ciphertext: a PBKDF2 iteration count of 2 decrypted every tested value, while +# 1 and 3 decrypted none, and the wrong user id decrypted none. The constants match those of +# the published Android scheme; only the derivation differs (PBKDF2-HMAC-SHA1 on iOS). +_KAKAO_KEY = bytes.fromhex('1608096f02172b0821210a1003030706') +_KAKAO_IV = bytes.fromhex('0f080100194725dc15f517e0e1150c35') + + +def _decrypt_field(user_id, b64_value): + """Decrypt one KakaoTalk iOS per-record field, or return None if it cannot be decrypted. + + user_id is the KakaoTalk user id that owns the field: Message.userId for a message, or + the row's own ZID for a ZUSER phone number. + """ + if not b64_value: + return b64_value + try: + ciphertext = base64.b64decode(b64_value) + except (ValueError, TypeError): + return None + if not ciphertext or len(ciphertext) % 16 != 0: + return None + salt = (str(user_id).encode('ascii', 'ignore') + b'\x00' * 16)[:16] + key = hashlib.pbkdf2_hmac('sha1', _KAKAO_KEY, salt, 2, 32) + try: + plain = AES.new(key, AES.MODE_CBC, _KAKAO_IV).decrypt(ciphertext) + except ValueError: + return None + pad = plain[-1] if plain else 0 + if pad < 1 or pad > 16 or pad > len(plain) or plain[-pad:] != bytes([pad]) * pad: + return None + try: + return plain[:-pad].decode('utf-8') + except UnicodeDecodeError: + return None + def _container_of(path): found = re.match(r'(?P.*/Data/Application/[^/]+)/', path.replace('\\', '/')) @@ -246,8 +292,8 @@ def kakaoTalkMessages(context): 'Sender User ID', 'Sender Name', 'Type', - 'Message (encrypted, as stored)', - 'Attachment (encrypted, as stored)', + 'Message', + 'Attachment', 'Server Log ID', 'Source File', ) @@ -264,6 +310,8 @@ def kakaoTalkMessages(context): rows = 0 for row in get_sqlite_db_records(message_path, query): sender = str(row[4]) if row[4] is not None else '' + message = _decrypt_field(sender, row[6]) + attachment = _decrypt_field(sender, row[7]) data_list.append(( convert_cocoa_core_data_ts_to_utc(row[0]) if row[0] else '', convert_cocoa_core_data_ts_to_utc(row[1]) if row[1] else '', @@ -272,8 +320,8 @@ def kakaoTalkMessages(context): sender, users.get(sender, ''), row[5] if row[5] is not None else '', - row[6] or '', - row[7] or '', + message if message is not None else (row[6] or ''), + attachment if attachment is not None else (row[7] or ''), row[8] if row[8] is not None else '', context.get_relative_path(message_path), )) @@ -344,7 +392,7 @@ def kakaoTalkUsers(context): 'Custom Name', 'Status Message', 'Email', - 'Phone Number (encrypted, as stored)', + 'Phone Number', 'Friend Type (as stored)', 'Block Type (as stored)', 'User Type (as stored)', @@ -365,9 +413,11 @@ def kakaoTalkUsers(context): 'FROM ZUSER') rows = 0 for row in get_sqlite_db_records(talk_path, query): - data_list.append(tuple( - (value if value is not None else '') for value in row - ) + (context.get_relative_path(talk_path),)) + values = [value if value is not None else '' for value in row] + # ZPHONENUMBER (index 7) is encrypted, keyed on this row's own ZID (index 0). + phone = _decrypt_field(row[0], row[7]) + values[7] = phone if phone is not None else values[7] + data_list.append(tuple(values) + (context.get_relative_path(talk_path),)) rows += 1 if rows: source_paths.append(talk_path) From c552eeec56f6163e0e86352a690f47098ebfecd8 Mon Sep 17 00:00:00 2001 From: Alexis Brignoni Date: Tue, 22 Sep 2026 17:44:18 -0400 Subject: [PATCH 3/4] KakaoTalk iOS: render message media inline and add a conversation view Now that the attachment field is decrypted, a media message's attachment shares a token with its local file name in the chat media folder. The Messages artifact matches the file inside the message's own chat and renders it inline in a Media column, and declares a conversation data view so LAVA shows it too. The Chat Media artifact still lists every kept file, including any whose message the store no longer holds. Co-Authored-By: Claude Opus 4.8 --- scripts/artifacts/kakaoTalk.py | 77 ++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/scripts/artifacts/kakaoTalk.py b/scripts/artifacts/kakaoTalk.py index 1f42a2435..013f0a674 100644 --- a/scripts/artifacts/kakaoTalk.py +++ b/scripts/artifacts/kakaoTalk.py @@ -34,15 +34,33 @@ "the numeric user id the message records, which is a link the store keeps rather " "than a correlation; a sender with no matching user row is reported with the id and " "a blank name. Type is reported as stored: no mapping for the integer was " - "recoverable, because the client is closed source. " + "recoverable, because the client is closed source. Media renders the local file a message's " + "decrypted attachment names, inline on that message's own row: the attachment and " + "the file name share a token, and the file is matched inside the message's own " + "chat folder, so it is a link the store records rather than a correlation. Five of " + "the six media messages on the device with media resolved to a file this way; a " + "message with no media, or whose file the store no longer holds, has a blank " + "Media column, and the Chat Media artifact lists every kept file including those. " "Field mapping was done against two private samples; no sample data is recorded for " "them. A Message.sqlite that does not carry this client's own table and column " "layout is skipped and logged, so another app's file of the same name cannot be " "reported as KakaoTalk.", "paths": ('*/Library/PrivateDocuments/Message.sqlite*', - '*/Library/PrivateDocuments/Talk.sqlite*'), + '*/Library/PrivateDocuments/Talk.sqlite*', + '*/Library/PrivateDocuments/chat/*/*', + '*/Library/PrivateDocuments/chatVideo/*/*', + '*/Library/PrivateDocuments/chatAudio/*/*'), "output_types": "standard", - "artifact_icon": "message-circle" + "artifact_icon": "message-circle", + "data_views": { + "conversation": { + "conversationDiscriminatorColumn": "Chat ID", + "textColumn": "Message", + "timeColumn": "Sent", + "senderColumn": "Sender Name", + "mediaColumn": "Media" + } + }, }, "kakaoTalkChats": { "name": "KakaoTalk - Chats", @@ -136,11 +154,11 @@ "file sits in is named with a chat id, which is the link the store records, so each " "file is attributed to a chat rather than correlated to one: on the device with " "media, seven distinct chat folder ids appeared, five of them present in the chat " - "table and two not, and those two are reported with the id and no room name. **The link stops " - "at the chat.** The message row's own attachment column is encrypted, so nothing in " - "the extraction ties a file to an individual message, and no guess from size or " - "time is made. That is why the media is not rendered on the message rows and this " - "artifact exists separately. Files whose name begins with a thumbnail marker are " + "table and two not, and those two are reported with the id and no room name. This artifact lists every media " + "file the client kept, including one whose message the store no longer holds, which " + "the Messages artifact cannot. Where a message's decrypted attachment names the file, " + "the Messages artifact also renders it inline on that message's own row, so the two " + "are complementary. Files whose name begins with a thumbnail marker are " "the client's own reduced copies and are labelled as such rather than dropped. Chat Room " "Name was blank on every media row of the devices tested: the media in these folders " "belonged to chats the store left without a room name, and the one chat that did carry " @@ -282,6 +300,42 @@ def _chats_by_id(talk_path): return chats +def _media_index(media_files): + """Per chat id, the media files under that chat's folder, non-thumbnails first. + + A file's own folder is named with the chat id, so a message's media is looked up in + its chat and then matched by the token the decrypted attachment shares with the name. + """ + index = {} + for path in media_files or []: + found = MEDIA_DIR.search(path.replace('\\', '/')) + if not found: + continue + name = os.path.basename(path.replace('\\', '/')) + index.setdefault(found.group('chatid'), []).append((name, path)) + for chat_id in index: + index[chat_id].sort(key=lambda item: bool(THUMB.search(item[0]))) + return index + + +def _attachment_media(attachment_json, chat_id, media_by_chat): + """The local media file a message's decrypted attachment names, or ''. + + The attachment carries tokens (in its url, thumbnailUrl and key fields) that also appear + in the local file's name; the file is matched inside the message's own chat only, so the + link is the token the store records rather than a correlation. + """ + if not attachment_json or chat_id not in media_by_chat: + return '' + tokens = set(re.findall(r'[A-Za-z0-9_-]{16,}', attachment_json)) + if not tokens: + return '' + for name, path in media_by_chat[chat_id]: + if any(token in name for token in tokens): + return path + return '' + + @artifact_processor def kakaoTalkMessages(context): data_headers = ( @@ -293,6 +347,7 @@ def kakaoTalkMessages(context): 'Sender Name', 'Type', 'Message', + ('Media', 'media'), 'Attachment', 'Server Log ID', 'Source File', @@ -305,6 +360,7 @@ def kakaoTalkMessages(context): if not message_path: continue users = _users_by_id(entry.get('talk')) + media_by_chat = _media_index(entry.get('media')) query = ('SELECT sentAt, readAt, updateAt, chatId, userId, type, message, attachment, ' 'serverLogId FROM Message') rows = 0 @@ -312,6 +368,10 @@ def kakaoTalkMessages(context): sender = str(row[4]) if row[4] is not None else '' message = _decrypt_field(sender, row[6]) attachment = _decrypt_field(sender, row[7]) + media_ref = '' + media_path = _attachment_media(attachment, str(row[3]), media_by_chat) + if media_path: + media_ref = check_in_media(media_path, os.path.basename(media_path)) or '' data_list.append(( convert_cocoa_core_data_ts_to_utc(row[0]) if row[0] else '', convert_cocoa_core_data_ts_to_utc(row[1]) if row[1] else '', @@ -321,6 +381,7 @@ def kakaoTalkMessages(context): users.get(sender, ''), row[5] if row[5] is not None else '', message if message is not None else (row[6] or ''), + media_ref, attachment if attachment is not None else (row[7] or ''), row[8] if row[8] is not None else '', context.get_relative_path(message_path), From b8b3e238fb09b73d7a2dfd168fe70b6d87cc0d78 Mon Sep 17 00:00:00 2001 From: Brigs Date: Tue, 22 Sep 2026 18:06:42 -0400 Subject: [PATCH 4/4] KakaoTalk: order Messages columns by declared conversation roles check_conversation_column_order required the declared role columns to follow the timeColumn and other date columns before any other column. Reorders Messages to Sent, Read, Updated, Sender Name, Message, Media, then Chat ID and the rest, and reorders the row tuple in the same edit. Co-Authored-By: Claude Opus 4.8 --- scripts/artifacts/kakaoTalk.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/artifacts/kakaoTalk.py b/scripts/artifacts/kakaoTalk.py index 013f0a674..febb51229 100644 --- a/scripts/artifacts/kakaoTalk.py +++ b/scripts/artifacts/kakaoTalk.py @@ -342,12 +342,12 @@ def kakaoTalkMessages(context): ('Sent', 'datetime'), ('Read', 'datetime'), ('Updated', 'datetime'), - 'Chat ID', - 'Sender User ID', 'Sender Name', - 'Type', 'Message', ('Media', 'media'), + 'Chat ID', + 'Sender User ID', + 'Type', 'Attachment', 'Server Log ID', 'Source File', @@ -376,12 +376,12 @@ def kakaoTalkMessages(context): convert_cocoa_core_data_ts_to_utc(row[0]) if row[0] else '', convert_cocoa_core_data_ts_to_utc(row[1]) if row[1] else '', convert_unix_ts_to_utc(row[2]) if row[2] else '', - row[3] if row[3] is not None else '', - sender, users.get(sender, ''), - row[5] if row[5] is not None else '', message if message is not None else (row[6] or ''), media_ref, + row[3] if row[3] is not None else '', + sender, + row[5] if row[5] is not None else '', attachment if attachment is not None else (row[7] or ''), row[8] if row[8] is not None else '', context.get_relative_path(message_path),