一、手机取证
1.请分析韦明辉手机,韦明辉手机的安卓ID是?[答案格式:1153f6447b96d02a]

问韦明辉的手机安卓ID,直接看基本信息就好了,直接就能看到
2.请分析韦明辉手机,韦明辉曾搜索过关于电脑远控的记录,请问使用的什么浏览器?[答案格式:谷歌]

这边也没什么别的浏览器,这边一看就是百度
而且包名也是百度的,所以本题答案为百度
3.请分析韦明辉手机,韦明辉曾用过一款笔记app,请问笔记应用的包名是?[答案格式:com.app.app]

要说的话这边笔记软件真是多的离谱,我给全部仿真了一遍,拿到了很多碎片化的内容
不过最后确定这一个的原因还是因为apk一开始一直在问这个笔记应用

不难看出这个笔记应用起码得是个需要登录的软件


因此别的上边这些都能排除

锁定apk应该是这个NoteVault,唯一一个需要登录的笔记apk软件,所以本题的包名为com.notevault.app
4.分析陈志鹏手机,手机的IMEI是?[答案格式:2342342323232]
依旧签到题

锁定是陈的手机之后直接查看基本信息即可
所以本题为7da9f4e846c8f493
5.分析陈志鹏手机,陈志鹏曾使用过一款隐私笔记app,该应用的包名是?[答案格式:com.app.app]
问隐私笔记,也是找了很久

毕竟软件是真的不少
直到最后一个个试完发现都没什么差别,不应该是这样子
最后发现有一个apk叫hidden

还是个flutter做的,很符合出题的情况,我们看看内容

发现还真是个加密的笔记数据库

符合apk中对于隐私笔记的描述,这个数据库确实是一个加密的,而且是一个隐私app

还能找到一个密码提示文档

更能坐实,本题的答案就是这个com.hidden.calculator
二、APK取证
1.请分析韦明辉手机,笔记应用的用户密码是?[答案格式:Abc123456]
这题其实隐含着这个笔记应用需要写账密的意思

因此我们可以从这一大堆的笔记软件中锁定这题说的到底是哪一个
因为别的打开仿真完基本上都能直接看见内容,工作笔记有一个加密部分但是不存在账密这一说
于是我们最终锁定这个应用NoteVault

然后就是找账密了,其实不难找,但是难爆破

我们在配置文件轻松找到盐和密码的哈希,用户名是weiweiwei,但是密码是什么呢,显然没有,需要我们爆破
这边比赛确实没爆出来,数字都尝试过了,最后发现是和参考格式一样的1大写+2小写+6数字
hashcat -m 1410 -a 3 hash.txt ?u?l?l?d?d?d?d?d?d

得到密码是Wei123123

直接仿真后登录即可,所以本题答案是Wei123123
2.请分析韦明辉手机,笔记应用中,公共笔记有几条?[答案格式:1]
要是前边爆破了的话,直接看看就直接知道了

显然是3条
但是我们还是以没有成功看一遍
先分析该应用,刚刚已经定位了,我们直接去文件夹看看,发现存在database数据库

但是是加密数据库,我们也找不到更多的信息,只能先去Jadx进行分析

直接搜索文件名,可以定位到SqliteDbManager
信息量很大这个地方

这直接把数据库密码明文标注出来了,那我们解密试试看NoteVault_DB_SecureKey_2024

成功解密,所以这边我们只要看看有几条就好了

明显是3条,答案为3
3.请分析韦明辉手机,笔记应用公共笔记数据库名称是?[答案格式:adb.db]

上一题已经确定了数据库名字是notevault.db了
4.请分析韦明辉手机,存储公开笔记的数据库密码是?[答案格式:Abc_ABC_1234]
第二题也分析过了,就明文写在Jadx里边

而且我们试过的确可以解密,所以密码就是NoteVault_DB_SecureKey_2024
5.分析韦明辉手机中笔记应用,给出电脑c盘的恢复秘钥的前6位?[答案格式:1234565]

笔记直接写了
但是万一没有爆破成功呢?我们看看没爆破出来的做法

根据题目意思,明显就是数据库内容,不难发现笔记内容都被加密了,我们先解密
根据数据库里边的内容其实有点怀疑是AES加密,像是AES-GCM,我们直接搜索看看(其实软件打开写了是AES加密)

定位到主要的加密逻辑类AESUtil


在这边我们可以看到加密的过程,确实是AES-GCM,看上边这张图片就可以发现密文其实是salt + iv + encryptedBytes的base64这样子存起来的

decrypt写的很清楚了需要切片
salt = raw[:16]
iv = raw[16:28]
ciphertext_tag = raw[28:]
所以我们大概解密的流程就是
数据库字段密文
↓
Base64 解码
↓
前 16 字节 = salt
↓
第 16 到 28 字节 = iv
↓
第 28 字节到最后 = ciphertext + GCM tag
↓
用 NoteVault_SecureKey_2024 + salt
↓
PBKDF2WithHmacSHA256 迭代 10000 次
↓
生成 256 bit AES key
↓
AES/GCM/NoPadding 解密
↓
得到 UTF-8 明文
由此写得脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
NoteVault DB + AES-GCM encrypted-field batch decryptor.
For this sample:
DB password = NoteVault_DB_SecureKey_2024
field master key = NoteVault_SecureKey_2024
It first decrypts the SQLCipher-v4 database into a temporary normal SQLite DB,
then decrypts TEXT columns whose names end with '_encrypted'.
"""
import argparse
import base64
import csv
import json
import os
import sqlite3
import tempfile
from pathlib import Path
from typing import Dict, List, Any
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
def pbkdf2(password: bytes, salt: bytes, iterations: int, length: int, algorithm) -> bytes:
return PBKDF2HMAC(
algorithm=algorithm,
length=length,
salt=salt,
iterations=iterations,
backend=default_backend(),
).derive(password)
def decrypt_sqlcipher_v4(
encrypted_db: str,
db_password: str,
out_db: str,
page_size: int = 4096,
reserve: int = 80,
kdf_iter: int = 256000,
) -> None:
"""
Pure-Python SQLCipher v4 default decrypt.
Defaults used by SQLCipher 4:
KDF: PBKDF2-HMAC-SHA512, 256000 iterations, 32-byte key
Cipher: AES-256-CBC
page reserve: 80 bytes, 16-byte IV at the page trailer
The first 16 bytes of page 1 are the SQLCipher salt. The normal SQLite
header 'SQLite format 3\0' is restored after decryption.
"""
data = Path(encrypted_db).read_bytes()
if len(data) < page_size or len(data) % page_size != 0:
raise ValueError(f"File size {len(data)} is not a multiple of page_size={page_size}")
salt = data[:16]
key = pbkdf2(
db_password.encode("utf-8"),
salt,
kdf_iter,
32,
hashes.SHA512(),
)
out = bytearray()
for pgno, off in enumerate(range(0, len(data), page_size), start=1):
page = data[off : off + page_size]
if pgno == 1:
enc_offset = 16
prefix = b"SQLite format 3\x00"
enc_len = page_size - 16 - reserve
else:
enc_offset = 0
prefix = b""
enc_len = page_size - reserve
if enc_len <= 0 or enc_len % 16 != 0:
raise ValueError("Invalid encrypted page length; check page_size/reserve")
ciphertext = page[enc_offset : enc_offset + enc_len]
iv = page[enc_offset + enc_len : enc_offset + enc_len + 16]
decryptor = Cipher(
algorithms.AES(key),
modes.CBC(iv),
backend=default_backend(),
).decryptor()
plaintext_part = decryptor.update(ciphertext) + decryptor.finalize()
plain_page = prefix + plaintext_part + bytes(reserve)
if len(plain_page) != page_size:
raise RuntimeError(f"Bad page length at page {pgno}: {len(plain_page)}")
out.extend(plain_page)
Path(out_db).write_bytes(out)
# Verify the result is a readable SQLite database.
con = sqlite3.connect(out_db)
try:
result = con.execute("PRAGMA integrity_check").fetchone()[0]
if result.lower() != "ok":
raise ValueError(f"SQLite integrity_check failed: {result}")
finally:
con.close()
def decrypt_field_aes_gcm(value: str, master_password: str) -> str:
"""
Field format:
base64( salt[0:16] || iv[16:28] || ciphertext+tag[28:] )
KDF:
PBKDF2WithHmacSHA256, iterations=10000, key length=256 bits
Cipher:
AES/GCM/NoPadding
"""
raw = base64.b64decode(value)
if len(raw) < 16 + 12 + 16:
raise ValueError("ciphertext too short")
salt = raw[:16]
iv = raw[16:28]
ciphertext_and_tag = raw[28:]
key = pbkdf2(
master_password.encode("utf-8"),
salt,
10000,
32,
hashes.SHA256(),
)
plaintext = AESGCM(key).decrypt(iv, ciphertext_and_tag, None)
return plaintext.decode("utf-8", errors="replace")
def get_tables(con: sqlite3.Connection) -> List[str]:
rows = con.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
return [r[0] for r in rows]
def batch_decrypt(sqlite_db: str, master_password: str) -> List[Dict[str, Any]]:
con = sqlite3.connect(sqlite_db)
con.row_factory = sqlite3.Row
output: List[Dict[str, Any]] = []
try:
for table in get_tables(con):
info = list(con.execute(f'PRAGMA table_info("{table}")'))
columns = [row[1] for row in info]
col_types = {row[1]: (row[2] or "").upper() for row in info}
encrypted_cols = [
c for c in columns
if c.endswith("_encrypted") and ("TEXT" in col_types.get(c, "") or "CHAR" in col_types.get(c, ""))
]
if not encrypted_cols:
continue
for row in con.execute(f'SELECT * FROM "{table}"'):
item: Dict[str, Any] = {"table": table}
for c in columns:
item[c] = row[c]
for c in encrypted_cols:
plain_name = c[: -len("_encrypted")]
if not isinstance(row[c], str):
continue
try:
item[plain_name] = decrypt_field_aes_gcm(row[c], master_password)
except Exception as e:
item[plain_name] = f"<DECRYPT_FAILED: {type(e).__name__}: {e}>"
output.append(item)
finally:
con.close()
return output
def write_csv(rows: List[Dict[str, Any]], csv_path: str) -> None:
fieldnames: List[str] = []
for row in rows:
for key in row.keys():
if key not in fieldnames:
fieldnames.append(key)
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def main() -> None:
parser = argparse.ArgumentParser(description="Decrypt NoteVault SQLCipher DB and AES-GCM fields")
parser.add_argument("db", help="encrypted notevault.db path")
parser.add_argument("--db-pass", default="NoteVault_DB_SecureKey_2024", help="SQLCipher database password")
parser.add_argument("--master-pass", default="NoteVault_SecureKey_2024", help="AES-GCM field master password")
parser.add_argument("--plain-db", default="notevault_plain.db", help="output decrypted SQLite database path")
parser.add_argument("--csv", default="notevault_decrypted.csv", help="output CSV path")
parser.add_argument("--json", default="notevault_decrypted.json", help="output JSON path")
args = parser.parse_args()
decrypt_sqlcipher_v4(args.db, args.db_pass, args.plain_db)
rows = batch_decrypt(args.plain_db, args.master_pass)
write_csv(rows, args.csv)
Path(args.json).write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[+] decrypted sqlite: {args.plain_db}")
print(f"[+] decrypted csv: {args.csv}")
print(f"[+] decrypted json: {args.json}")
print(json.dumps(rows, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

解密得到三条公开笔记的内容
恢复秘钥:C盘恢复秘钥:067474-555071-622369-111650-651354-121858-406439-542289
password:数字
虚拟币地址:6273XY7F87XX
所以这题答案就是067474
6.请分析韦明辉手机,笔记应用隐私空间的密码是?[答案格式:123456]

其实私密空间密码在配置文件里边直接就能看见一个字符串,但是显然不会那么简单

直接搜索发现在UserPreference

进来发现是走的AESUtil
也就是说我们在刚刚的类AESUtil往下划,直接就能看见对于隐私空间的加解密

发现了密文格式在这边是Base64(salt+iv+encryptedBytes)

我们在数据库里还看见了config_key和config_value,所以
密文 = private_space_password
解密口令 = private_space_key
看这个Jadx也能明确,密钥派生依旧是PBKDF2WithHmacSHA256然后迭代10000次
import base64
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt_with_password(cipher_b64: str, password: str) -> str:
raw = base64.b64decode(cipher_b64)
salt = raw[:16]
iv = raw[16:28]
ciphertext_and_tag = raw[28:]
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32, # 256 bit = 32 bytes
salt=salt,
iterations=10000,
)
key = kdf.derive(password.encode("utf-8"))
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(iv, ciphertext_and_tag, None)
return plaintext.decode("utf-8")
if __name__ == "__main__":
private_space_password = (
"zkTkORgxvbRQI9ilBSelZ172slPhlMGkkZFy7oCOb+NJxOgV0OHi5GK9hKoV0hNsD3s/"
)
private_space_key = "e7GVttnoeahmWeFc"
result = decrypt_with_password(private_space_password, private_space_key)
print("[+] private_space_password =", result)

直接解得密码是8374723
7.请分析韦明辉手机,隐私空间笔记内容使用的什么数据库存储?[答案格式:sqlite]

我们很容易定位到这个隐私部分的数据库

依旧先定位

定位过来之后一看
明显就是objectbox的数据库类型啊,更何况文件名是.mdb,和ObjectBox使用LMDB作为存储引擎符合
8.请分析韦明辉手机,隐私笔记数据内容解密秘钥是?[答案格式:Abc_ABC_1234]
要开始解密了,说到解密,再次回到AESUtil

定位到解密的内容部分,可以看到在调用我们的getDerivedKeyPrivate

所以是NoteVault_PrivateVault_2024
9.请分析韦明辉手机,打手电话是多少?[答案格式:18036310808]
上一题都知道密文、知道密钥、知道加密算法了
自然可以解密了
import re
import base64
import hashlib
from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
MASTER_PASSWORD = "NoteVault_PrivateVault_2024"
def decrypt_private_b64(value: str) -> str:
raw = base64.b64decode(value)
salt = raw[:16]
iv = raw[16:28]
ciphertext_and_tag = raw[28:]
key = hashlib.pbkdf2_hmac(
"sha256",
MASTER_PASSWORD.encode("utf-8"),
salt,
10000,
dklen=32
)
return AESGCM(key).decrypt(iv, ciphertext_and_tag, None).decode("utf-8")
data = Path("data.mdb").read_bytes()
# 提取可见 base64 密文
strings = re.findall(rb"[A-Za-z0-9+/=]{20,}", data)
for s in strings:
try:
text = s.decode()
plain = decrypt_private_b64(text)
print("[+] cipher:", text)
print("[+] plain :", plain)
print()
except Exception:
pass

得到打手电话是18067876543
当然了,这个还有个更好玩的
我们前边不是先账密登录了嘛
可以继续找隐藏部分

在这边看见了一个隐藏空间login相关类

定位到这边
这边绑定的是版本号的控件tvVersion
**所以这边进入隐藏空间的方法很简单—— 在“关于”页面里连续点版本号 6 次 **

成功进入,我们知道密码的,第六题那个8374723

登录进入隐私笔记自然能看见答案了
10.请分析韦明辉手机,内容通联app数据库密码是?[答案格式:a-abc1234Abc]
这手机取证最好玩的就是这边有不少的内部通联app题目
打决赛的当然打过初赛了

那内部通联这个名词也太耳熟了,一看就看见老熟人了,这边直接就是social chat app

不确定我们还能直接看看base.apk的哈希值,发现一模一样,就是同一个apk啊
https://mp.weixin.qq.com/s/LzLFYgOPb_G6un2dPZkf5w
所以具体的做法可以直接翻我的初赛apk的wp
我们看这个图标就能发现是一个flutter编译的,它的特点就是需要去native层解析,解析起来可麻烦了,在初赛我们就是通过漫游得到了这边的数据库密码是"截取"的,明文就存在com.socialchat.social_chat_app/shared_prefs/FlutterSharedPreferences.xml文件中

至于是怎么截取的,我们初赛就是通过frida脚本做的
function waitForAppContext(callback) {
let count = 0;
const timer = setInterval(function () {
Java.perform(function () {
try {
const ActivityThread = Java.use("android.app.ActivityThread");
const app = ActivityThread.currentApplication();
if (app !== null) {
clearInterval(timer);
const ctx = app.getApplicationContext();
console.log("[+] getApplicationContext ok");
callback(ctx);
return;
}
count++;
console.log("[*] waiting for Application context... " + count);
} catch (e) {
count++;
console.log("[*] waiting context error:", e);
}
if (count >= 30) {
clearInterval(timer);
console.log("[-] still cannot get Application context");
}
});
}, 500);
}
setImmediate(function () {
console.log("[*] no-login social_chat.db decrypt hook loaded");
Java.perform(function () {
waitForAppContext(function (ctx) {
try {
const prefs = ctx.getSharedPreferences("FlutterSharedPreferences", 0);
let raw = prefs.getString("flutter.db_password", null);
console.log("[+] flutter.db_password =", raw);
if (raw === null) {
console.log("[-] 没读到 flutter.db_password");
console.log("[-] 确认 XML 是否在 /data/user/0/com.socialchat.social_chat_app/shared_prefs/FlutterSharedPreferences.xml");
return;
}
const realPassword = raw.substring(2, raw.length - 1);
console.log("[+] raw password =", raw);
console.log("[+] real password =", realPassword);
const dbPath = "/data/user/0/com.socialchat.social_chat_app/databases/social_chat.db";
console.log("[+] db path =", dbPath);
forceLoadSqlcipher();
setTimeout(function () {
waitForSqlcipherAndRun(dbPath, realPassword);
}, 1000);
} catch (e) {
console.log("[-] main logic error:", e);
}
});
});
});
当然了,这边其实可以不用,毕竟apk都没变,我们复盘过初赛的马上就能意识到是截取的

可以看见的确是一模一样,掐头去尾,头少2,尾巴少1即可
所以密码为
s-dbw1776776199621Goo

能成功解密,成功验证答案
11.请分析韦明辉手机,内容通联工具中,韦明辉一共撤回过几次聊天?[答案格式:1]

基本可以判断一模一样了,内容还是一样加密的
我们依旧拿出初赛用过的脚本即可,我习惯先生成一个无密码版本的,再解密成csv
import hashlib
import sqlite3
from pathlib import Path
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
PASSPHRASE = "s-dbw1776776199621Goo"
def aes_cbc_decrypt(key, iv, ciphertext):
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
return decryptor.update(ciphertext) + decryptor.finalize()
def decrypt_sqlcipher4_db():
input_db = Path("social_chat.db")
output_db = Path("social_chat_plain.db")
if not input_db.exists():
print("[-] 未找到 social_chat.db")
return
data = input_db.read_bytes()
page_size = 4096
reserve_size = 80
usable_size = page_size - reserve_size
if len(data) % page_size != 0:
raise RuntimeError("数据库大小不是 4096 的整数倍")
page_count = len(data) // page_size
print(f"[+] 数据库页数: {page_count}")
file_salt = data[:16]
print("[+] SQLCipher 文件 salt:", file_salt.hex())
aes_key = hashlib.pbkdf2_hmac(
"sha512",
PASSPHRASE.encode("utf-8"),
file_salt,
256000,
dklen=32,
)
print("[+] SQLCipher AES key:", aes_key.hex())
out = bytearray()
for page_no in range(1, page_count + 1):
start = (page_no - 1) * page_size
end = page_no * page_size
page = data[start:end]
iv = page[usable_size:usable_size + 16]
if page_no == 1:
ciphertext = page[16:usable_size]
plaintext_part = aes_cbc_decrypt(aes_key, iv, ciphertext)
out += b"SQLite format 3\x00"
out += plaintext_part
out += b"\x00" * reserve_size
else:
ciphertext = page[:usable_size]
plaintext_page = aes_cbc_decrypt(aes_key, iv, ciphertext)
out += plaintext_page
out += b"\x00" * reserve_size
output_db.write_bytes(out)
print(f"[+] 已生成明文数据库: {output_db}")
return output_db
def verify_plain_db(output_db):
print("[+] 验证明文数据库...")
conn = sqlite3.connect(output_db)
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = [row[0] for row in cur.fetchall()]
print("[+] 表名:")
for table in tables:
print(" ", table)
if "chat_records" in tables:
print("\n[+] chat_records 前5条记录:")
cur.execute("SELECT * FROM chat_records LIMIT 5;")
for row in cur.fetchall():
print(" ", row)
conn.close()
print("[+] 验证完成")
def main():
try:
output_db = decrypt_sqlcipher4_db()
verify_plain_db(output_db)
except Exception as e:
print(f"[-] 解密失败: {e}")
if __name__ == "__main__":
main()

import argparse
import base64
import csv
import json
import sqlite3
from pathlib import Path
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def pkcs7_unpad(data: bytes) -> bytes:
if not data:
return data
pad = data[-1]
if 1 <= pad <= 16 and data[-pad:] == bytes([pad]) * pad:
return data[:-pad]
return data
def aes_cbc_decrypt_base64(cipher_b64: str, key: bytes, iv: bytes) -> str:
cipher_data = base64.b64decode(cipher_b64)
decryptor = Cipher(
algorithms.AES(key),
modes.CBC(iv)
).decryptor()
plain = decryptor.update(cipher_data) + decryptor.finalize()
plain = pkcs7_unpad(plain)
return plain.decode("utf-8", errors="replace")
def get_key_iv(conn: sqlite3.Connection, user_id: str | None):
conn.row_factory = sqlite3.Row
cur = conn.cursor()
if user_id:
row = cur.execute(
"SELECT id, config_data FROM user WHERE id = ?",
(user_id,)
).fetchone()
else:
row = cur.execute(
"SELECT id, config_data FROM user LIMIT 1"
).fetchone()
if not row:
raise RuntimeError("user 表中没有找到用户记录")
config_data = row["config_data"]
if not config_data:
raise RuntimeError("user.config_data 为空")
config = json.loads(config_data)
key = base64.b64decode(config["enc_key"])
iv = base64.b64decode(config["enc_iv"])
print("[+] use user_id:", row["id"])
print("[+] AES key:", key.hex())
print("[+] AES iv :", iv.hex())
return key, iv
def convert_messages(input_db: Path, output_csv: Path, user_id: str | None):
conn = sqlite3.connect(input_db)
conn.row_factory = sqlite3.Row
key, iv = get_key_iv(conn, user_id)
cur = conn.cursor()
rows = cur.execute(
"""
SELECT *
FROM message
ORDER BY create_at ASC
"""
).fetchall()
if not rows:
print("[-] message 表为空")
conn.close()
return
fieldnames = list(rows[0].keys())
# 增加一列明文
if "content_plain" not in fieldnames:
fieldnames.append("content_plain")
output_rows = []
for row in rows:
item = dict(row)
cipher_content = row["content"]
try:
plain = aes_cbc_decrypt_base64(cipher_content, key, iv)
except Exception as e:
plain = f"[DECRYPT_ERROR] {e}"
item["content_plain"] = plain
output_rows.append(item)
with output_csv.open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(output_rows)
conn.close()
print("[+] message 总数:", len(output_rows))
print("[+] 已导出:", output_csv)
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"-i",
"--input",
default="social_chat_plain.db",
help="输入:已经解密后的普通 SQLite 数据库"
)
parser.add_argument(
"-o",
"--output",
default="messages_plain.csv",
help="输出 CSV"
)
parser.add_argument(
"-u",
"--user-id",
default=None,
help="指定 user.id,不填则默认取 user 表第一条"
)
args = parser.parse_args()
input_db = Path(args.input)
output_csv = Path(args.output)
if not input_db.exists():
raise FileNotFoundError(f"找不到数据库文件: {input_db}")
convert_messages(input_db, output_csv, args.user_id)
if __name__ == "__main__":
main()

得到messages_plain.csv
这边最好也是做过初赛
在初赛周文杰的内部通联app题目中,我们其实可以仿真登录软件
因此我们可以判断各字符的对应作用
周文杰和宰相第一句话撤回了


而对应的字段是state_bits
所以这边我们只需要筛选state_bits是1的对话即可,必定是撤回部分

最后筛选发现是6条撤回内容,答案为6
12.请分析韦明辉手机,内容通联工具中,韦明辉有几个好友?[答案格式:1]
这倒是简单了,我们只要打开刚刚解密过的那个数据库就好了

这边到conversation的表,发现type是D,也就是好友的共有5人
答案是5
13.分析陈志鹏手机,隐私笔记app的数据库是?[答案格式:adb_adb.db]

就像我在手机取证说的一样,这次的笔记软件真是多啊
直到我们最后发现有一个apk叫hidden

还是个flutter做的,很符合出题的情况,我们看看内容

又发现了是一个加密的数据库,所以可以确定了我们要找的就是这一个数据库
所以数据库名字是hidden_notes.db
14.分析陈志鹏手机,隐私笔记app数据库密码保存在什么文件中?[答案格式:adbcd.txt]

随便翻一下就发现在这个包的数据文件夹下放着一个password.json里存了一个很像密码的
所以这题初步判断就是这一个文件,后边我们继续验证
15.分析陈志鹏手机,隐私笔记数据库密码是?[答案格式:ab-abc12345]
虽然很大概率就是上一题那个文件,但是直接解密数据库失败了
面对这样子的情况,发现和之前那个内部通联怎么那么像,都是解不开,都是差不多的密码,而且还都是flutter做的
于是试了试掐头去尾,前2后1失败了,脑洞打开尝试反着来结果前1后2直接成功了,比赛的时候惊喜的很
所以答案其实就是
gs-ll20260423

直接打开了
当然真正的做法其实和那个内部通联差不多
由于还是flutter编译的,要么就是拿着那个libsqlcipher.so去看字符串,可以看到我们上题的password.json其实
我们这边尝试根据password更改尝试以及直接抓SQL,写frida脚本(不会写ai了一份
// Frida script for com.hidden.calculator
// Run:
// frida -U -f com.hidden.calculator -l hidden_calculator_dump_v2.js --no-pause
const PKG = "com.hidden.calculator";
const DB_NAME = "hidden_notes.db";
// 你现在已知的密码
const MANUAL_PASSWORD = "gs-ll20260423";
function loge(prefix, e) {
try {
console.log(prefix, String(e));
if (e && e.stack) {
console.log(e.stack);
}
} catch (_) {
console.log(prefix, e);
}
}
function waitForAppContext(callback) {
let count = 0;
const timer = setInterval(function () {
Java.perform(function () {
try {
const ActivityThread = Java.use("android.app.ActivityThread");
const app = ActivityThread.currentApplication();
if (app !== null) {
clearInterval(timer);
const ctx = app.getApplicationContext();
console.log("[+] getApplicationContext ok");
callback(ctx);
return;
}
count++;
console.log("[*] waiting for Application context... " + count);
} catch (e) {
count++;
loge("[*] waiting context error:", e);
}
if (count >= 30) {
clearInterval(timer);
console.log("[-] still cannot get Application context");
}
});
}, 500);
}
function fileExists(path) {
const File = Java.use("java.io.File");
return File.$new(path).exists();
}
function readTextFile(path) {
const FileInputStream = Java.use("java.io.FileInputStream");
const ByteArrayOutputStream = Java.use("java.io.ByteArrayOutputStream");
const JString = Java.use("java.lang.String");
const fis = FileInputStream.$new(path);
const baos = ByteArrayOutputStream.$new();
const buf = Java.array("byte", new Array(4096).fill(0));
let n = 0;
while ((n = fis.read(buf)) > 0) {
baos.write(buf, 0, n);
}
fis.close();
return JString.$new(baos.toByteArray(), "UTF-8").toString();
}
function b64decodeToString(s) {
try {
const Base64 = Java.use("android.util.Base64");
const JString = Java.use("java.lang.String");
const bytes = Base64.decode(String(s), 0);
return JString.$new(bytes, "UTF-8").toString();
} catch (e) {
return null;
}
}
function uniqPush(arr, seen, value, reason) {
if (value === null || value === undefined) return;
value = String(value).trim();
if (value.length === 0) return;
if (seen[value]) return;
seen[value] = true;
arr.push({
value: value,
reason: reason
});
}
function collectFromRaw(raw, candidates, seen, reasonPrefix) {
if (raw === null || raw === undefined) return;
raw = String(raw).trim();
if (raw.length === 0) return;
uniqPush(candidates, seen, raw, reasonPrefix + " raw");
if (raw.indexOf("s:") === 0) {
uniqPush(candidates, seen, raw.substring(2), reasonPrefix + " strip s:");
}
if (raw.length > 3) {
uniqPush(
candidates,
seen,
raw.substring(2, raw.length - 1),
reasonPrefix + " substring(2,len-1)"
);
}
if (
(raw[0] === "\"" && raw[raw.length - 1] === "\"") ||
(raw[0] === "'" && raw[raw.length - 1] === "'")
) {
uniqPush(
candidates,
seen,
raw.substring(1, raw.length - 1),
reasonPrefix + " strip quotes"
);
}
const b64 = b64decodeToString(raw);
uniqPush(candidates, seen, b64, reasonPrefix + " base64");
const rev = raw.split("").reverse().join("");
uniqPush(candidates, seen, rev, reasonPrefix + " reverse");
const b64rev = b64decodeToString(rev);
uniqPush(candidates, seen, b64rev, reasonPrefix + " base64(reverse)");
}
function collectPasswordCandidates(ctx) {
const candidates = [];
const seen = {};
const dataDir = String(ctx.getApplicationInfo().dataDir.value);
const dbPath = ctx.getDatabasePath(DB_NAME).getAbsolutePath().toString();
console.log("[+] package =", ctx.getPackageName());
console.log("[+] dataDir =", dataDir);
console.log("[+] db path =", dbPath);
if (MANUAL_PASSWORD && MANUAL_PASSWORD.length > 0) {
uniqPush(candidates, seen, MANUAL_PASSWORD, "MANUAL_PASSWORD");
}
const jsonPaths = [
dataDir + "/app_flutter/password.json",
dataDir + "/files/password.json",
dataDir + "/databases/password.json",
dataDir + "/shared_prefs/password.json"
];
for (let i = 0; i < jsonPaths.length; i++) {
const p = jsonPaths[i];
console.log("[*] check password file:", p);
if (!fileExists(p)) {
continue;
}
console.log("[+] found password file:", p);
const text = readTextFile(p);
console.log("[+] password file raw =", text);
collectFromRaw(text, candidates, seen, "file:" + p);
try {
const JSONObject = Java.use("org.json.JSONObject");
const obj = JSONObject.$new(text);
const keys = obj.keys();
while (keys.hasNext()) {
const k = keys.next().toString();
const v = obj.optString(k, "").toString();
console.log("[+] json", k, "=", v);
collectFromRaw(v, candidates, seen, "json." + k);
}
} catch (e) {
console.log("[*] not strict JSON, skipped json parse");
}
}
// 顺手枚举 SharedPreferences,防止密码存在 XML 里
const prefNames = [
"FlutterSharedPreferences",
PKG + "_preferences",
"password",
"settings"
];
for (let i = 0; i < prefNames.length; i++) {
try {
const name = prefNames[i];
const prefs = ctx.getSharedPreferences(name, 0);
const all = prefs.getAll();
console.log("[*] check SharedPreferences:", name, "size =", all.size());
const it = all.entrySet().iterator();
while (it.hasNext()) {
const entry = it.next();
const k = entry.getKey().toString();
const vObj = entry.getValue();
if (vObj === null) continue;
const v = vObj.toString();
if (
k.toLowerCase().indexOf("password") >= 0 ||
k.toLowerCase().indexOf("pass") >= 0 ||
k.toLowerCase().indexOf("db") >= 0 ||
k.toLowerCase().indexOf("key") >= 0 ||
v.toLowerCase().indexOf("gs-") >= 0
) {
console.log("[+] pref", name + "." + k, "=", v);
collectFromRaw(v, candidates, seen, "pref." + name + "." + k);
}
}
} catch (e) {
loge("[-] prefs read error:", e);
}
}
console.log("[+] candidate password count =", candidates.length);
for (let i = 0; i < candidates.length; i++) {
console.log(" [" + i + "]", candidates[i].reason, "=>", candidates[i].value);
}
return {
dbPath: dbPath,
candidates: candidates
};
}
function tryLoadSqlcipher(ctx) {
const System = Java.use("java.lang.System");
const appInfo = ctx.getApplicationInfo();
let nativeLibraryDir = "";
try {
nativeLibraryDir = String(appInfo.nativeLibraryDir.value);
} catch (e) {
nativeLibraryDir = "";
}
console.log("[+] nativeLibraryDir =", nativeLibraryDir);
const loadPaths = [];
if (nativeLibraryDir && nativeLibraryDir.length > 0) {
loadPaths.push(nativeLibraryDir + "/libsqlcipher.so");
}
// 如果你手动 push 了 libsqlcipher.so 到 /data/local/tmp,也可以被这里加载
loadPaths.push("/data/local/tmp/libsqlcipher.so");
for (let i = 0; i < loadPaths.length; i++) {
const p = loadPaths[i];
try {
if (!fileExists(p)) {
console.log("[*] lib not exists:", p);
continue;
}
System.load.overload("java.lang.String").call(System, p);
console.log("[+] System.load ok:", p);
return true;
} catch (e) {
loge("[-] System.load failed: " + p, e);
}
}
try {
System.loadLibrary.overload("java.lang.String").call(System, "sqlcipher");
console.log("[+] System.loadLibrary sqlcipher ok");
return true;
} catch (e) {
loge("[-] System.loadLibrary sqlcipher failed:", e);
}
return false;
}
function quoteIdent(name) {
return "\"" + String(name).replace(/"/g, "\"\"") + "\"";
}
function cursorRowToString(cursor) {
const names = cursor.getColumnNames();
const n = names.length;
const parts = [];
for (let i = 0; i < n; i++) {
const col = names[i].toString();
let val = null;
try {
const type = cursor.getType(i);
if (type === 0) {
val = "NULL";
} else if (type === 1) {
val = String(cursor.getLong(i));
} else if (type === 2) {
val = String(cursor.getDouble(i));
} else if (type === 3) {
val = cursor.getString(i);
} else if (type === 4) {
val = "<BLOB length=" + cursor.getBlob(i).length + ">";
} else {
val = cursor.getString(i);
}
} catch (e) {
try {
val = cursor.getString(i);
} catch (_) {
val = "<read error>";
}
}
parts.push(col + "=" + val);
}
return parts.join(" | ");
}
// 这个 APK 里 rawQuery 被混淆成 n(String, String[])
function dumpQueryObf(db, sql, maxRows) {
console.log("\n[SQL]", sql);
let cursor = null;
try {
cursor = db.n.overload(
"java.lang.String",
"[Ljava.lang.String;"
).call(db, sql, null);
let row = 0;
while (cursor.moveToNext()) {
console.log(" [row " + row + "] " + cursorRowToString(cursor));
row++;
if (row >= maxRows) {
break;
}
}
console.log(" [+] rows shown =", row);
} catch (e) {
loge(" [-] query failed:", e);
} finally {
if (cursor !== null) {
try {
cursor.close();
} catch (_) {}
}
}
}
// 这个 APK 里 openDatabase 被混淆成 l(String, String, int, k.e, t.c)
function openDbObf(SQLiteDatabase, dbPath, pwd, flags) {
return SQLiteDatabase.l.overload(
"java.lang.String",
"java.lang.String",
"int",
"k.e",
"t.c"
).call(SQLiteDatabase, dbPath, pwd, flags, null, null);
}
function tryOpenAndDump(ctx, dbPath, candidates) {
if (!fileExists(dbPath)) {
console.log("[-] database not exists:", dbPath);
console.log("[-] 先打开一次 App,让它初始化数据库");
return;
}
tryLoadSqlcipher(ctx);
let SQLiteDatabase = null;
try {
SQLiteDatabase = Java.use("net.zetetic.database.sqlcipher.SQLiteDatabase");
console.log("[+] Java.use SQLiteDatabase ok");
} catch (e) {
loge("[-] Java.use SQLiteDatabase failed:", e);
return;
}
const OPEN_READONLY = 1;
for (let i = 0; i < candidates.length; i++) {
const pwd = candidates[i].value;
console.log("\n[*] try password [" + i + "]", candidates[i].reason, "=>", pwd);
let db = null;
try {
db = openDbObf(SQLiteDatabase, dbPath, pwd, OPEN_READONLY);
console.log("[+] open database success");
console.log("[+] real db password =", pwd);
dumpQueryObf(db, "PRAGMA cipher_version;", 5);
dumpQueryObf(
db,
"SELECT type, name, sql FROM sqlite_master WHERE type IN ('table','view') ORDER BY type, name;",
80
);
let c = null;
try {
c = db.n.overload(
"java.lang.String",
"[Ljava.lang.String;"
).call(
db,
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;",
null
);
while (c.moveToNext()) {
const table = c.getString(0).toString();
dumpQueryObf(db, "SELECT * FROM " + quoteIdent(table) + " LIMIT 30;", 30);
}
} catch (e) {
loge("[-] table enum failed:", e);
} finally {
if (c !== null) {
try {
c.close();
} catch (_) {}
}
}
try {
db.close();
} catch (_) {}
return;
} catch (e) {
loge("[-] open failed:", e);
try {
if (db !== null) {
db.close();
}
} catch (_) {}
}
}
console.log("[-] all candidate passwords failed");
console.log("[-] 如果这里还失败,再看错误是否是 no such table / file is not database / wrong key");
}
setImmediate(function () {
console.log("[*] hidden_notes.db decrypt hook v2 loaded");
Java.perform(function () {
waitForAppContext(function (ctx) {
try {
const result = collectPasswordCandidates(ctx);
setTimeout(function () {
Java.perform(function () {
tryOpenAndDump(ctx, result.dbPath, result.candidates);
});
}, 800);
} catch (e) {
loge("[-] main logic error:", e);
}
});
});
});

可能抓还是有点问题,不过很容易就能找到现在这个倒是真的,不知道有多少同学在搞之前就想到了截取并猜测成功的()
得到密码是gs-ll20260423
16.分析陈志鹏手机,隐私笔记数据库中有几个表?[答案格式:1]
我们其实已经有密码了,也有数据库了,直接解密就好了

所以是一共3个表
17.分析陈志鹏手机,隐私笔记用户密码加密存储在数据库哪个表中?[答案格式:note]

加密密码,明显存在表password里,所以本题答案为password
18.分析陈志鹏手机,隐私笔记中,对用户密码哈希共迭代多少次?[答案格式:10]
上一题已经发现了用户密码被加密存在password这个库里边
但是我们要知道迭代多少次其实主要还是要看逻辑,有空或许还是要对flutter的反编译有了解才行
String hashPassword(String password, String salt) {
String hash = password + salt;
for (int i = 0; i < 10; i++) {
hash = sha256.convert(utf8.encode(hash)).toString();
}
return hash;
}
这边分析下来大概是这样子的一个逻辑
所以其实是迭代了10次
19.分析陈志鹏手机,隐私笔记用户密码是?[答案格式:123456]
我们已经用密码解开了外层
现在已经可以清晰看见内层了

不难看出字段全部二次加密,而且这个是很标准的IV:密文
这意味着我们需要进行解密,对Flutter反编译之后理解可以得到这个内层是利用了AES-CBC-PKCS7然后解密


得到如上内容,所以密码是13901237890@3456
(这边比赛说是错的,我也不知道该是多少,因为这边想要进入笔记本应该是需要我们数字打完之后按'='号,但是这个显然有个@符号根本就大不了,adb我又没有,因此无法判定真伪,希望是真的)
20.分析陈志鹏手机,隐私笔记中,陈志鹏记录了几条笔记。[答案格式:1]

依旧同一份图片,写了一共5条笔记
21.分析陈志鹏手机,笔记应用中层记录了其安全屋地址,请问地址是?[答案格式:龙江市山河县光明街道22号]

也是直接写在明面上了,所以是佳美市龙山县太阳城街道25号
22.分析陈志鹏手机,内部通联app数据库密码是?[答案格式:a-adb1234565Abc]
我是真没想到陈志鹏最后也要搞一搞这个内部通联app,就是同一个
验证哈希发现依旧一模一样,上边10题讲过了,就不啰嗦了
data/data/com.socialchat.social_chat_app/shared_prefs/FlutterSharedPreferences.xml发现密码

掐头去尾得到本题答案
s-dbw1776761865507Goo

确认密码
23.分析陈志鹏手机,陈志鹏内部通联app中有几个好友?[答案格式:1]

和12题几乎一模一样的考点,就是解密后看数据库的conversation表而已
得到是5人
24.分析陈志鹏手机,陈志鹏曾要求犯罪团伙其他人编写过远控木马,该木马加密协议用的什么加密算法?[答案格式:ABC-123]
"要求",很容易想到是聊天记录内部的内容,我们根据11题的做法直接用脚本解密成csv打开看看聊天记录
搜索“加密”看看能不能搜到

发现了这样子的聊天记录
得到加密协议用的是AES-256,和答案格式也一样
所以本题答案为AES-256
25.分析陈志鹏手机,第二次接收的远控木马保存在手机的完整路径是。[答案格式:/storage/emulated/0/Android/data/com.app.app/files/app/木马.txt]

索引搜索密码即可,甚至没有干扰
仿照答案格式,全路径,所以应该是
/storage/emulated/0/Android/data/com.socialchat/files/Downloads/木马_v1.2.zip