使用 LLDB 动态调试提取 SQLCipher 数据库密钥
Published in:2026-01-09 |
Words: 4.3k | Reading time: 16min | reading:

macOS 逆向实战:使用 LLDB 动态调试提取 SQLCipher 数据库密钥

在 macOS 的逆向工程和应用安全分析中,LLDB (Low Level Debugger) 是最强大的工具之一。它是 Xcode 默认的调试器,不仅可以用于开发,更是动态分析第三方应用的利器。

很多应用(例如 某App)使用 SQLCipher 对 SQLite 数据库进行加密。通常,应用在运行时必须调用 sqlite3_key 函数将密钥传递给数据库引擎。只要我们在这一瞬间“冻结”住程序,就能从内存中提取出原始密钥。

本文将演示如何使用 LLDB 的 “冷启动拦截技术” 来提取 某App 的数据库密钥。

前置知识:ARM64 架构下的参数传递

在 Apple Silicon (M1/M2/M3/M4/M5) 芯片的 macOS 上,程序运行在 ARM64 架构下。了解函数调用约定(Calling Convention)是逆向的基础:

当程序调用一个函数时,参数通常存放在通用寄存器中:

  • x0 寄存器:第 1 个参数(通常是数据库句柄 db
  • x1 寄存器:第 2 个参数(在 sqlite3_key 中,这是密钥内容的内存地址指针
  • x2 寄存器:第 3 个参数(在 sqlite3_key 中,这是密钥的长度

我们的目标非常明确:拦截 sqlite3_key,检查 x2 是否为 32(标准密钥长度),如果是,就读取 x1 指向的内存。


实战步骤:冷启动拦截法

相比于“先打开应用再附加调试器”,冷启动拦截(Wait-for-attach) 更加稳健。它允许我们在应用代码执行的第一行之前就挂载调试器,确保不会漏掉数据库初始化的关键时刻。

第一步:清理环境

为了确保从头开始,先彻底关闭目标应用。在终端运行:

1
killall 某App

第二步:设下“埋伏圈”

在终端输入以下命令。-n 指定进程名,-w (waitfor) 表示等待进程启动。

1
sudo lldb -n "某App" -w

提示:使用 sudo 是为了获得读取其他进程内存的权限。输入后终端会显示 Waiting for process to launch...

第三步:启动并下断点

手动点击 Dock 栏或启动台中的 某App 图标。

你会发现应用图标跳动了一下,但没有出现窗口。此时终端会提示 Process xxxxx stopped。这说明调试器已经成功附身,并且暂停了程序运行。

现在,我们需要设置一个正则断点。为什么用正则?因为应用可能静态链接了 WCDB 等库,函数名可能变成了 wcdb_sqlite3_key

1
(lldb) br set -r "sqlite3_key"

如果显示 Breakpoint 1: x locations,说明断点设置成功。

第四步:放行程序

断点设好了,我们需要让程序继续跑,直到它撞上我们的陷阱。

1
(lldb) c

此时终端会显示 Resuming,应用界面开始加载。

第五步:捕获猎物

去操作 某App(例如点击登录按钮)。核心逻辑是:只要应用试图解密数据库查看历史消息,它就必须调用加密函数。

关键时刻:
你会发现 某App 的界面突然卡死,转圈圈停止。同时,终端会出现如下提示:

1
2
Process 12345 stopped
* thread #1... stop reason = breakpoint 1.1

看到了 stopped,说明我们成功拦截到了密钥传递的瞬间!

第六步:验证与提取

1. 验明正身
首先检查密钥长度。输入读取寄存器命令:

1
(lldb) reg read x2
  • 如果输出 0x0000000000000020(即十进制的 32),恭喜你,这就是我们要找的原始密钥。
  • 如果输出 0x00...07 或其他值,说明这可能是一个默认空密码或其他操作。输入 c 让程序继续运行,等待下一次断点触发。

2. 读取密钥
确认长度为 32 后,读取 x1 寄存器指向的内存内容:

1
(lldb) memory read -s 1 -f x -c 32 $x1
  • -s 1:按 1 字节分组
  • -f x:十六进制格式
  • -c 32:读取 32 字节
  • $x1:目标地址

终端将打印出两行 16 进制数据,这就是该数据库的解密密钥(Raw Key)。


常见错误排查 (Troubleshooting)

在调试过程中,新手最容易遇到以下报错:

错误 1:

error: Command requires a process which is currently stopped.

原因: 你试图在汽车高速行驶时检查引擎。
解决: 当终端显示 Process resuming 时,说明程序正在运行,不要输入任何读取命令。去操作 App 界面触发逻辑,直到终端显示 stop reason = breakpoint 之后,再执行 reg readmemory read

错误 2:

failed to get reply to handshake packet

原因: 权限不足或 macOS 的 SIP (系统完整性保护) 阻止了调试。
解决: 确保使用了 sudo。如果依然不行,可能需要在 Recovery 模式下暂时关闭 SIP。

技术实现:macOS 平台app密钥提取

除了手动使用 LLDB 调试,项目在 macOS 平台上实现了一套自动化的密钥提取机制。由于 macOS 的安全机制(SIP、ASLR 等)比 Windows 更为严格,因此实现过程也更具挑战性。

核心挑战

  1. 权限隔离:macOS 严格限制进程间访问,普通进程无法读取其他进程的内存。
  2. ASLR (地址空间布局随机化):每次启动,app的内存地址都会随机变化,无法像 Windows 那样使用固定偏移量。
  3. 内存布局复杂:密钥可能存储在堆(Heap)的任意位置,且现代应用内存空间巨大(数 GB),全量扫描效率极低。
  4. 多版本兼容:app不同版本(3.x, 4.x)的内部结构和密钥存储方式可能不同。

实现方案一:非侵入式内存扫描(首选)

本项目首先尝试采用 Ptrace + vmmap + 特征扫描 的组合方案。这种方式不需要重启app,对用户体验影响最小。

1. 权限获取与进程附加

首先,程序需要以 Root 权限运行(sudo),利用 syscall.PtraceAttach 系统调用附加到app进程。

1
2
3
4
5
6
// 尝试使用ptrace附加到进程(需要root权限)
err := syscall.PtraceAttach(int(info.ProcessID))
if err != nil {
// 处理权限错误,提示用户使用 sudo
}
defer syscall.PtraceDetach(int(info.ProcessID))

2. 动态内存布局分析

为了解决 ASLR 和内存空间巨大的问题,我们不进行盲目的全量扫描。而是调用 macOS 自带的 vmmap 工具,获取app进程当前的内存映射表。

1
2
3
4
5
// 使用vmmap获取进程内存布局
func getMemRegions(pid int) ([]MemRegion, error) {
cmd := exec.Command("vmmap", fmt.Sprintf("%d", pid))
// ...
}

通过解析 vmmap 的输出,我们智能地筛选出可能包含密钥的内存区域:

  • 保留MALLOC_TINY, MALLOC_NANO(小对象堆,密钥通常在此),以及 __DATA 段(全局变量)。
  • 过滤:只读区域(r--)、MALLOC_LARGE(大对象堆)、显存映射等不太可能存储密钥的区域。
  • 优化:对于过大的区域(>50MB),只扫描前部,平衡性能与成功率。

3. 内存读取与特征扫描

利用 syscall.Ptrace (PT_READ_D) 读取目标进程内存。为了提高效率和稳定性,我们实现了分块读取和错误跳过机制。

在读取到的内存中,我们使用**特征码(Magic Pattern)**来定位密钥结构体。

特征 1:设备符号定位
app的密钥结构体附近通常包含设备类型的字符串(如 “iphone”, “android”, “ipad”)。我们首先搜索这些锚点。

1
2
3
4
func hasDeviceSybmol(buffer []byte) int {
deviceNames := []string{"iphone", "ipad", "android", "OHOS"}
// ...
}

特征 2:密钥长度模式
找到设备符号后,我们在其前后范围内搜索符合密钥长度特征的数据(通常是 32 字节或 16 字节的长度字段)。

1
2
3
4
keyLenPatterns := [][]byte{
{0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 32字节密钥
{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 16字节密钥
}

4. 密钥验证

找到潜在的密钥指针后,我们读取指针指向的内存内容,并尝试用它来解密用户的 Media.db 数据库头。

1
2
3
4
// 验证密钥是否正确
if checkDataBaseKey(mediaDB, keyBuffer) {
return string(hexKey)
}

只有通过验证的密钥才会被返回,确保了提取结果的准确性。

实现方案二:LLDB 自动化拦截(Fallback)

如果内存扫描方案失败(例如app版本更新导致特征码失效),程序会自动切换到 LLDB 自动化拦截 模式。这种方式准确率极高,但需要重启app。

1. 原理

利用 LLDB 的 Python Scripting Bridge,我们可以编写脚本自动设置断点并读取寄存器。这相当于把本文开头的“手动逆向步骤”完全自动化了。

2. 自动化流程

  1. 环境清理:程序检测并关闭正在运行的app进程。
  2. 脚本注入:动态生成一个 LLDB Python 脚本,该脚本包含以下逻辑:
    • 监听 sqlite3_key 函数调用。
    • 当断点触发时,自动读取寄存器(ARM64 下为 x2 长度,x1 指针)。
    • 验证密钥长度是否为 32。
    • 读取密钥内容并打印到标准输出。
    • 分离调试器并退出,让app继续运行。
  3. 启动调试:程序启动 lldb,加载上述脚本,并设置为等待模式 (-w)。
  4. 启动应用:程序调用 open -a app 启动app。
  5. 捕获密钥:程序监听 LLDB 的输出,一旦捕获到密钥,立即解析并返回。
1
2
3
4
5
6
7
8
# LLDB Python 脚本核心逻辑示例
def intercept_key(frame, bp_loc, dict):
# ... 获取寄存器 ...
if key_len == 32:
key_data = process.ReadMemory(key_ptr, key_len, error)
print(f"app_KEY_FOUND:{key_data.hex()}")
process.Detach()
os._exit(0)

兼容性设计

为了适应app的更新,代码中包含了多重兼容逻辑:

  • 路径兼容:同时支持旧版 Documents/Msg 和新版 xw_files 目录结构。
  • 文件名兼容:同时查找 Media.db, media.db, media_0.db
  • 架构兼容:同时支持 Intel (x86_64) 和 Apple Silicon (ARM64) 架构的指针长度处理。

实战

某 App 数据库密钥动态抓取技术方案

1. 背景与挑战

该 App 使用 SQLCipher 对本地 SQLite 数据库进行加密。在旧版本中,我们可以通过扫描进程内存,寻找特定的“设备类型字符串”(如 iphoneandroid)附近的指针来暴力定位密钥。

然而,随着 App 版本更新(v4.x+),通过内存特征扫描面临以下挑战:

  • 内存布局改变:密钥不再紧跟在设备特征字符串之后。
  • 符号剥离 (Symbol Stripping):App 内部静态链接了数据库组件,并移除了 sqlite3_key 等关键函数的符号,导致无法直接通过函数名下断点。
  • 内存保护:系统库的高位内存区域(如 0x1FF...)不可读,导致扫描器频发 invalid argument 错误。

因此,我们需要采用更底层的 动态调试(Dynamic Debugging) 方案。


2. 核心思路:降维打击

既然 App 隐藏了内部数据库函数的符号,但它无法隐藏系统级 API 的调用

macOS/iOS 的底层加密通常依赖 CommonCrypto 框架。无论 App 内部逻辑如何封装,最终在进行 AES 解密时,大概率会调用系统的 CCCryptorCreateCCCrypt 函数。

攻击路径转变:

  • 拦截应用层:sqlite3_key (失败,找不到符号)
  • 拦截系统层:CCCryptorCreate (成功,系统函数无法隐藏)

ARM64 调用约定 (关键知识)

在 Apple Silicon (M1/M2/M3) 上,函数参数通过寄存器传递:

  • x0 - x2: 前三个参数
  • x3: 第 4 个参数 -> Key 的内存地址 (Pointer)
  • x4: 第 5 个参数 -> Key 的长度 (Length)

由于 SQLCipher 的默认密钥长度固定为 32 字节,我们可以利用这一点进行精确过滤。


3. 手动抓取流程 (LLDB 命令行)

3.1 环境清理与启动

为了防止抓错进程(例如抓到了 App 的后台守护进程),我们需要彻底关闭 App,并直接指定二进制路径启动。

1
2
3
4
5
6
# 1. 强制清理环境
sudo killall -9 [App名称]
sudo killall -9 lldb

# 2. 启动调试器 (直接加载主二进制文件)
sudo lldb /Applications/[App名称].app/Contents/MacOS/[App名称]

3.2 设置“智能”陷阱

系统加密函数调用非常频繁(网络请求、文件缓存都在用)。如果不加条件,App 会瞬间卡死。我们要设置一个条件断点

指令含义:在 CCCrypt 函数入口下断点,但仅当第 5 个参数(密钥长度 x4)等于 32 时才暂停。

1
2
(lldb) br set -n CCCrypt -c '$x4 == 32'
(lldb) br set -n CCCryptorCreate -c '$x4 == 32'

3.3 运行与触发

  1. 在 LLDB 中输入 r 启动 App。
  2. 在 App 界面点击 登录
  3. App 尝试解密数据库读取历史消息,触发断点,界面卡死。

3.4 提取密钥

当终端显示 Process stopped 时,读取 x3 寄存器指向的内存:

1
2
# 读取 x3 指向地址的 32 个字节,以 16 进制显示
(lldb) memory read -s 1 -f x -c 32 $x3

输出示例图:

1
2
3
4
5
(lldb) memory read -s 1 -f x -c 32 $x3
0x600002b84120: 0x98 0x04 0xc3 0xec 0x3b 0xfc 0x36 0xb3
0x600002b84128: 0xd0 0x87 0x6a 0xec 0x65 0xfc 0x7d 0x78
0x600002b84130: 0x0a 0x94 0x46 0x71 0x62 0xf6 0x9f 0xef
0x600002b84138: 0x59 0x12 0x31 0x0d 0xd6 0x5c 0xe7 0x7a

这一串 64 位的 Hex 字符串即为数据库密钥。


4. 自动化代码实现 (Golang + Python)

为了将上述手动过程封装为工具,我们使用 Golang 调用 LLDB,并通过 Python 脚本控制 LLDB 的行为。

4.1 核心流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
graph TD
A[启动工具] --> B{清理环境};
B --> C[启动 LLDB 子进程];
C --> D[加载 Python 脚本];
D --> E[启动 App 主程序];
E --> F[用户点击登录];
F --> G{触发 CCCrypt?};
G -- No --> F;
G -- Yes --> H{Key长度==32?};
H -- No --> G;
H -- Yes --> I[Python提取内存数据];
I --> J[打印 ww_KEY_FOUND 标记];
J --> K[Go 捕获标记并验证];
K -- 验证成功 --> L[杀掉进程并返回密钥];

4.2 Python 脚本 (嵌入到 Go 中)

这是 LLDB 内部执行的脚本,用于逻辑判断和数据提取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import lldb
import os

def intercept_cccrypt(frame, bp_loc, dict):
# 获取当前线程和进程
thread = frame.GetThread()
process = thread.GetProcess()

# 获取 x4 寄存器 (Key Length)
len_reg = frame.FindRegister("x4")
if not len_reg.IsValid(): return False

key_len = len_reg.GetValueAsUnsigned()

# 核心过滤逻辑:只关心长度为 32 的密钥
if key_len == 32:
# 获取 x3 寄存器 (Key Pointer)
ptr_reg = frame.FindRegister("x3")
if ptr_reg.IsValid():
key_ptr = ptr_reg.GetValueAsUnsigned()

error = lldb.SBError()
# 读取内存
key_data = process.ReadMemory(key_ptr, key_len, error)

if error.Success():
# 输出特殊标记,供宿主程序捕获
print(f"KEY_FOUND:{key_data.hex()}")

# 返回 False 表示不暂停程序,继续运行,避免 UI 长期卡死
return False

def __lldb_init_module(debugger, internal_dict):
target = debugger.GetSelectedTarget()
# 针对系统库下断点,防止符号缺失
bp = target.BreakpointCreateByName("CCCryptorCreate", "libcommonCrypto.dylib")
bp.SetScriptCallbackFunction("intercept_cccrypt")

4.3 Go 调用代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func GetKeyViaLLDB() (string, error) {
// 1. 准备 Python 脚本文件
tmpScript := createTempPythonScript()
defer os.Remove(tmpScript)

// 2. 构造 LLDB 命令
// -Q: 静默模式
// -b: 批处理模式
// -o: 加载脚本并启动程序
targetBin := "/Applications/App.app/Contents/MacOS/AppBinary"
cmd := exec.Command("sudo", "lldb", targetBin, "-Q", "-b",
"-o", fmt.Sprintf("command script import %s", tmpScript),
"-o", "r")

stdout, _ := cmd.StdoutPipe()
cmd.Start()

// 3. 实时监听输出
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "KEY_FOUND:") {
key := strings.Split(line, ":")[1]

// 4. 实时验证 (假设 checkKey 是验证函数)
if checkKey(key) {
cmd.Process.Kill() // 拿到正确的 Key 后杀掉调试器
return key, nil
}
}
}
return "", fmt.Errorf("key not found")
}

5. 总结

方案 适用版本 优点 缺点
内存特征扫描 v3.x 及部分旧版 无感,不需要重启 App,不中断用户操作。 在新版中失效,因内存布局改变和权限限制导致扫描极慢或失败。
LLDB 动态拦截 v4.x (当前最新) 成功率极高,直接从 CPU 寄存器拿数据,无视代码混淆。 侵入式,需要 Root 权限,需要重启 App,且操作期间 App 界面会有短暂卡顿。

最佳实践建议:
工具应采用 混合策略。优先尝试非侵入式的内存扫描;如果失败,则引导用户授权 Root 权限,使用 LLDB 重启 App 进行暴力提取。

通过 LLDB,我们不需要拆解应用的安装包,也不需要进行复杂的静态分析,仅仅通过了解其使用的底层数据库库函数(sqlite3),利用 Wait-for-attach寄存器读取,就能在运行时通过“上帝视角”拿到关键数据。

这也提醒开发者:本地数据的安全性不能仅依赖于客户端的加密逻辑,因为在拥有设备物理访问权限的攻击者(或调试器)面前,密钥往往是透明的。

通过这套 “内存扫描为主,LLDB 拦截为辅” 的双重机制, 能够在不依赖硬编码地址的情况下,稳定地从运行中的app进程提取数据库密钥,实现了“一键导出”的便捷体验。


See

-1. https://lldb.llvm.org/use/tutorial.html

Next:
深入解析 n8n 与 MoneyPrinterTurbo