盘古石 2026 决赛

2026盘古石取证决赛wp(手机+APK)

围绕 盘古石 的公开复盘与解题记录。

上传者:玫幽倩 发布日期:2026-06-03 57 次阅读

一、手机取证

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