一、LLDB调试器基础:iOS逆向的第一把钥匙

说实话,LLDB这东西,我刚开始接触iOS逆向时也觉得挺唬人的。黑乎乎的终端窗口,一堆命令,看着就头大。但用久了你会发现,它其实就是个「对话窗口」——你跟App说话,App回答你。

LLDB全称是Low Level Debugger,苹果官方钦定的调试器。Xcode里跑App时,背后就是它在工作。但我们要做的,是脱离Xcode,直接用它来「盘问」一个正在运行的App。

1.1 为什么是LLDB?

你可能会问:Frida不香吗?嗯,Frida确实香,但LLDB有个不可替代的优势——它是系统原生的。你想想看,当你需要调试一个刚启动的App,或者App被反调试保护时,LLDB往往是最先能进去的。我在项目中遇到过好几次,Frida挂不上,但debugserver一attach就进去了。

核心概念:LLDB是一个C/S架构的调试器。debugserver是服务端,跑在iPhone上;LLDB是客户端,跑在你的Mac上。两者通过USB或网络通信。

1.2 基础命令速览

我个人习惯把LLDB命令分成三类:控制流、查看状态、修改状态。记住这三类,基本就够用了。

类别 命令 作用
控制流 continue / c 继续执行
控制流 next / n 单步跳过
控制流 step / s 单步进入
查看状态 frame variable 查看当前栈帧变量
查看状态 po 打印对象
修改状态 expression / e 执行表达式/修改变量

小技巧:po是print object的缩写,但很多人不知道它其实是expression -O -- 的别名。所以当你po一个基本类型时,可能会报错——这时候用p就好。

二、LLDB脚本编写:让调试自动化

手动敲命令,一次两次还行。但如果你要反复做同样的操作——比如每次断点停下都打印某个对象的属性——那就该写脚本了。

LLDB脚本支持Python。嗯,你没看错,Python。苹果在LLDB里内嵌了一个Python解释器。这意味着你可以用Python写调试脚本,甚至定义自己的命令。

2.1 一个简单的脚本例子

我曾经在逆向一个支付SDK时,需要反复查看某个加密函数的输入输出。手动敲了十几次后,我写了个脚本:

# 定义一个命令,打印当前线程的所有调用栈
def print_backtrace(debugger, command, result, internal_dict):
    target = debugger.GetSelectedTarget()
    process = target.GetProcess()
    thread = process.GetSelectedThread()
    
    for frame in thread:
        print(f"帧号: {frame.GetFrameID()}, 地址: {hex(frame.GetPC())}")
        
# 注册到LLDB
def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand('command script add -f backtrace.print_backtrace bt')
    print("命令 'bt' 已注册,输入bt即可打印调用栈")

把这个脚本保存为backtrace.py,然后在LLDB里执行command script import backtrace.py,之后你直接敲bt就能看到漂亮的调用栈了。

注意:LLDB的Python API版本跟系统Python版本不一定一致。我在iOS 14上就踩过坑——macOS的Python是3.9,但iPhone上的debugserver用的是Python 2.7。写脚本时尽量用兼容写法。

三、使用debugserver附加进程

这是整个动态调试的起点。没有这一步,后面全是空谈。

3.1 准备工作

首先,你的iPhone需要越狱。这是前提,没办法绕过去。越狱后,从Cydia或Sileo安装debugserver。苹果其实自带了一个debugserver,但权限受限,我们需要一个「增强版」。

我个人习惯用Theos自带的debugserver,或者从Xcode的DeveloperDiskImage里提取出来,然后用ldid重新签名:

# 从DeveloperDiskImage提取
# 路径一般在:Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport/

# 重签名
ldid -Sent.xml debugserver

# 拷贝到iPhone
scp debugserver root@你的iPhoneIP:/usr/bin/

3.2 附加进程的两种方式

方式一:通过进程名附加

# 在iPhone上启动debugserver
debugserver *:1234 -a GameApp

# 在Mac上连接
lldb
(lldb) process connect connect://你的iPhoneIP:1234

方式二:等待进程启动

# 先启动debugserver等待
debugserver *:1234 -w /path/to/GameApp.app/GameApp

# 然后启动App
# debugserver会自动附加

避坑指南:我曾经在连接时一直报"connection refused",折腾了半天发现是iPhone和Mac没在同一个WiFi下。如果你用USB连接,可以用usbmuxd转发端口,稳定得多。

四、断点设置与内存读写

断点设置,说白了就是告诉LLDB:「嘿,执行到这个地方时停一下,我要看看里面在搞什么鬼。」

4.1 断点设置技巧

最常用的几种方式:

# 按函数名设置
(lldb) b -[ViewController viewDidLoad]

# 按地址设置
(lldb) b 0x1004d8c00

# 按行号设置(需要加载符号表)
(lldb) b main.m:15

# 条件断点
(lldb) b -[GameManager updateScore:] if $arg1 > 100

嗯,这里要注意条件断点。$arg1是LLDB的约定,表示第一个参数。在OC方法里,$arg1是self,$arg2是_cmd,$arg3开始才是真正的参数。所以上面那个条件断点,实际上是在说:当updateScore:的参数大于100时停下。

4.2 内存读写实战

断点停下后,我们就可以为所欲为了。看看内存里有什么:

# 读取内存
(lldb) memory read 0x16f0e1c00
(lldb) x/10gx 0x16f0e1c00  # 读取10个8字节

# 写入内存
(lldb) memory write 0x16f0e1c00 0x41414141

# 修改寄存器
(lldb) register write x0 100

核心思路:动态调试的本质就是「断点-观察-修改」的循环。你断在关键函数上,观察参数和返回值,然后修改它们来改变程序行为。

五、实战:动态修改游戏App的分数

好了,理论说完了,我们来点实际的。假设我们有个游戏App,叫"CrazyRunner",里面有个分数显示。我们的目标是——把分数改成99999。

5.1 找到关键函数

首先,我们需要知道分数是怎么存储和更新的。用class-dump或者Hopper反编译一下,找到跟分数相关的类和方法。假设我们找到了:

@interface ScoreManager : NSObject
- (void)addScore:(int)points;
- (int)currentScore;
@property (nonatomic, assign) int score;
@end

5.2 设置断点并观察

在addScore:方法上下断点:

(lldb) b -[ScoreManager addScore:]
(lldb) c  # 让App继续运行

# 当你在游戏中获得分数时,断点触发
# 查看参数
(lldb) po $arg1  # self
(lldb) p $arg3   # points参数

# 输出类似:
# (int) $0 = 10

好,现在我们看到每次加10分。接下来,我们直接修改这个值:

# 把加分数改成1000
(lldb) expression $arg3 = 1000
(lldb) c

游戏里分数一下涨了1000。但这样太慢了,我们直接修改内存中的score属性:

# 在断点中,直接修改self的score
(lldb) expression (void)[$arg1 setScore:99999]
(lldb) c

搞定。分数直接变成99999。

5.3 更优雅的方式:用脚本自动化

手动改太累了。写个Python脚本,每次断点触发时自动修改:

import lldb

def auto_hack_score(debugger, command, result, internal_dict):
    target = debugger.GetSelectedTarget()
    process = target.GetProcess()
    thread = process.GetSelectedThread()
    frame = thread.GetFrameAtIndex(0)
    
    # 获取self
    self_obj = frame.FindVariable("self")
    # 调用setScore:
    score_obj = target.CreateValueFromExpression("score", "99999")
    error = lldb.SBError()
    self_obj.GetChildMemberWithName("_score").SetValueFromCString("99999", error)
    
    if error.Success():
        print("分数已修改为99999!")
    else:
        print(f"修改失败: {error}")

def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand('command script add -f hack.auto_hack_score hackscore')
    print("命令 'hackscore' 已注册")

重要提醒:这只是教学演示。实际游戏可能有反调试、分数校验、服务器验证等保护措施。我在一个商业游戏里试过,刚改完分数就被踢下线了——人家服务器端也存了一份分数。所以,动态修改只适用于纯本地游戏,或者用来研究学习。

六、本章知识体系

下面这张图,是我自己总结的LLDB动态调试知识体系。你可以把它当作一个地图,每次调试时对照着看:

LLDB动态调试知识体系 LLDB调试器 基础命令:c/n/s/po/p/memory read/write Python脚本:command script import/command script add debugserver附加:process connect / -a / -w 断点设置:b/br/条件断点 内存读写:memory/register 实战:修改游戏分数

这张图把整个流程串起来了。从左到右看:先掌握基础命令,然后学会写脚本提高效率,接着用debugserver连上目标App,最后设置断点、读写内存,完成实战目标。

我的建议:初学者别急着写脚本。先把基础命令敲熟,特别是po和expression。这两个命令能解决80%的问题。等你觉得手动敲烦了,再学脚本自动化。

好了,这一章的内容就到这里。LLDB是个越用越顺手的工具,关键是动手练。找个简单的游戏App,试着改改分数、改改金币数量,你会发现自己很快就上手了。


公众号:蓝海资料掘金营,微信deep3321