*OS动态分析与调试

Published: 2024年11月20日

In Kernel.

日志系统

NSLog

这是我们最熟悉的日志接口,它位于Foundation框架,它本质是对ASL或ULS的封装,它的原型如下:

// 常用版本
FOUNDATION_EXPORT void NSLog(NSString *format, ...) NS_FORMAT_FUNCTION(1,2);
// va_list版本
FOUNDATION_EXPORT void NSLogv(NSString *format, va_list args) NS_FORMAT_FUNCTION(1,0);

它的格式字符串和Objc的其他函数一样,和C也类似,只是多了%@去输出ObjC对象(调用description),在输出时它会添加日期时间(毫秒精度)、进程名、PID、线程 ID,之后才是消息内容,这些都是在它内部组装的,到底层时是一个完整的字符串,且它是同步操作,因此性能损失明显,除了简单调试不建议使用它。

ASL

Apple System Log其实是一个已经退休的系统,它是Apple 05年引入的一个syslog实现,关于syslog可以看俺之前的文章,apple实现了一个结构化、易查询且高性能的增强版,比如ASL存储的是二进制格式,当然了它提供工具能导出文本视图便于分析。

它同样是一个CS架构,通过unix domain socket通信,由syslogd守护进程接收日志,写入/var/log/asl,并支持广播给订阅者,客户端(如syslog(3)就是用的asl)使用如下接口:

#include <asl.h>

/* 最简单的用法,使用默认客户端 */
asl_log(NULL, NULL, ASL_LEVEL_ERR, "Something went wrong: %s", strerror(errno));
asl_log(NULL, NULL, ASL_LEVEL_INFO, "User logged in: %s", username);

/* 打开一个具名客户端 */
aslclient client = asl_open(
    "com.beta.mao",   // ident: 标识符,出现在Sender字段
    "com.beta",         // facility: 设施名
    ASL_OPT_STDERR         // 选项: 同时输出到stderr
);

// 选项标志
// ASL_OPT_STDERR    — 同时写到stderr(开发时有用)
// ASL_OPT_NO_DELAY  — 立即连接syslogd,不延迟
// ASL_OPT_NO_REMOTE — 忽略远程过滤器配置

// 设置这个客户端的过滤级别
// 只有<= filter的消息才会被发送
asl_set_filter(client, ASL_FILTER_MASK_UPTO(ASL_LEVEL_DEBUG));
//                      ↑ 展开为 0xFF,允许所有级别通过 

// 使用客户端写日志
asl_log(client, NULL, ASL_LEVEL_DEBUG, "Debug message: value=%d", value);

// 它也可以通过ASL_KEY_READ_UID和ASL_KEY_READ_GID来指定某条消息只能被有权限的用户读取
aslmsg msg = asl_new(ASL_TYPE_MSG);
asl_set(msg, ASL_KEY_MSG, "Sensitive user data");

// 只有 root(UID 0)可以读
asl_set(msg, ASL_KEY_READ_UID, "0");

// -1 表示所有人可读(默认)
// asl_set(msg, ASL_KEY_READ_UID, "-1");
asl_send(client, msg);
asl_free(msg);


// 用完关闭
asl_close(client);

ASL定义了8个日志级别,第二个例子就用ASL_FILTER_MASK_UPTO(level)宏调整了客户端会发送的级别:

#define ASL_LEVEL_EMERG   0   // 系统不可用,极少使用
#define ASL_LEVEL_ALERT   1   // 需要立即处理
#define ASL_LEVEL_CRIT    2   // 严重错误
#define ASL_LEVEL_ERR     3   // 普通错误
#define ASL_LEVEL_WARNING 4   // 警告
#define ASL_LEVEL_NOTICE  5   // 正常但值得注意(默认级别)
#define ASL_LEVEL_INFO    6   // 信息性消息
#define ASL_LEVEL_DEBUG   7   // 调试信息

#define ASL_FILTER_MASK(level)        (1 << (level))
#define ASL_FILTER_MASK_UPTO(level)   ((1 << ((level) + 1)) - 1)

同时syslogd也存在记录级别,可通过syslog命令临时修改,或是直接修改/etc/asl.conf/etc/asl/下的每模块配置文件,由于它已经过时了(现在只剩兼容接口,其内部调用的是下一节的ULS),所以不再详细讲它的配置与查询语法,这里就提一下它的常见消费端,asl_search API已经过时,Console.app(控制台)是图形化的很好用,爱用,常用,下面还会用;还有就是syslog了:

# 监视模式,类似于`tail -f`查看实时日志流,添加count则显示最近count条新日志
syslog -w [count]

# 指定输出格式,如std(默认格式) raw(原始格式 输出所有信息) bsd(和system.log里的一样)  xml (plist格式),也可以自定义格式,能用哪些占位符自己查
syslog -F '%Datetime% %Sender%[%PID%]: %Message%'

# 过滤: 它支持非常强大的过滤语法,可使用多种及多个表达式,这里简单列几个
# 按进程名过滤
syslog -k Sender MyApp

# 按级别过滤(显示 ERR 及以上)
syslog -k Level Leq 3

# 组合条件(AND 关系)
syslog -k Sender MyApp -k Level Leq 4

# 查看指定 ASL 文件
syslog -f /var/log/asl/2026.03.27.G80.asl

# 输出为文本格式(默认是紧凑格式)
syslog -k Sender MyApp -T std

# 查看所有字段(包括自定义字段)
syslog -k Sender MyApp -F raw

还有很多很多...感兴趣可以直接看帮助~

ULS

现在*OS都在使用统一日志系统(Unified Logging System),有点肯定是多多,比如用户态内核态都能用、用最少的损耗存储最多的信息、隐私数据隔离等等,这里直接看它的架构,它分三部分:

1.客户端: 日志的生产者,即各种使用os_log接口的程序

2.服务端: 日志中转,这里有两个服务: logd负责处理大量的日志输出,如果日志被标记为持久化则将其存储在/var/db/diagnostics下,diagnosticd为日志查看器提供实时流支持

3.日志查看器: 日志的消费者,用来看日志的,在Apple下自带的是控制台和log命令,这个下面介绍

客户端和服务端会通过XPC和共享内存(Apple取名叫Firehose机制)通信,而内核额外会通过/dev/oslog/dev/oslog_stream这两个特殊的字符设备与用户态通信,大体如下:

image

关于怎么去写日志可以查文档,这里只提一下它的一个特性,就是除了标准的printf格式化字符外,它还支持%{}扩展,比如%{private}s在非调试模式且没设置专门的Profile下会显示为<private>,而%{errno}d/%{time_t}d/%@/可用来显示错误、时间和对象,而%{uuid_t}.16P/%{xnu.opaque}p/%.*P/%{xnu.innards}...可用来输出特定格式、混淆内容甚至输出特定接口等

我们主要关注的是怎么查看日志,控制台是最常用的,它可以通过""操作"菜单显示信息和调试级别的日志,通过搜索词元精确查找并保存过滤条件,还可以选定设备去看实时日志,包括显示连接的iPhone等设备的日志。不过功能最强大的还得是log命令,它支持多种子命令:

  ~ log --help
usage:
    log <command>

global options:
    -v, --verbose   # 详细模式: 输出更多调试信息,方便排查命令本身的问题

commands:
    collect         # 将系统日志导出并打包成一个.logarchive文件,可以从有故障的机器上采集,然后发到Mac上用控制台查看
    config          # 查看或更改日志系统的全局设置,可以用它开启某个进程的Debug级别持久化存储,否则有些日志重启就没了
    diagnose        # 对导出的logarchive文件进行健康检查或自动分析,用于检测日志包是否完整,或者是否存在严重的系统性错误
    erase           # 删除系统现有的日志数据,当想清理磁盘空间,或者想在一个干净的环境下开始捕捉特定Bug的日志时使用
    repack          # 使用谓词(Predicate)对现有的logarchive文件进行过滤并生成精简版,如果原始日志包太大,可以用它剔除无关信息,只保留关心的部分
    show            # 查看并搜索系统已存储的历史日志,这是最常用的命令之一,支持时间段查询、按进程名过滤以及多种输出格式(如 JSON)
    stream          # 实时观察系统正在生成的日志(类似 tail -f),开发调试时的首选,能在触发某个操作时瞬间看到系统的反馈
    stats           # 显示日志系统的运行统计数据,可以查看日志占用了多少磁盘空间、各进程的日志生成速率等,用于性能调优

这里面config/erase/collect需要sudo权限,每个子命令可以用log help <command>查看特定的参数,这里就简单列出几条常用的参数:

log stream --level debug --process "BetaMa0" --predicate 'subsystem == "com.beta.app"' # 实时查看指定的APP指定的子系统的日志
log show --start "2024-11-20 14:00:00" --end "2024-11-20 14:15:00" --predicate 'subsystem == "com.apple.network" AND eventMessage CONTAINS[c] "error"' --style syslog # 获取指定时间段,com.apple.network子系统的历史日志,并格式化输出为syslog格式
sudo log collect --last 1h --output my_debug_logs.logarchive # 打包一段日志,让别人分析

官方Trace/Debug工具

lldb

这玩意儿怎么用没啥说的,简单聊一下原理和权限吧,后面的都需要~

调试机制

在Linux下我们的调试使用ptrace,但是在*OS下ptrace被严重阉割了,基本没啥用,所有的动态分析工具都不再使用它,而是直接使用Mach原语,没座,就是下面这些:

/* 附加调试 */
task_for_pid(mach_task_self(), pid, &task_port); // 获取目标程序的任务端口
// 若是要重头开始调,是在posix_spawn()启动时添加POSIX_SPAWN_START_SUSPENDED标志让子进程挂起


/* 内存操作 */
mach_vm_read_overwrite(task, remote_addr, size, local_buf, &bytes_read); // 读目标进程内存

mach_vm_write(task, remote_addr, local_buf, size); // 写目标进程内存(设断点、patch 代码都靠这个)

mach_vm_protect(task, addr, size, FALSE, VM_PROT_READ | VM_PROT_WRITE); // 修改内存保护属性(写代码段前需要先改权限)

/* 线程枚举与寄存器 */
task_threads(task, &thread_list, &thread_count); // 枚举所有线程

thread_get_state(thread, ARM_THREAD_STATE64, &state, &count); // 读寄存器(arm64 通用寄存器)

thread_set_state(thread, ARM_THREAD_STATE64, &state, count); // 写寄存器(修改 PC 实现跳转等)

/* 断点/异常捕获 */
task_set_exception_ports(
    task,
    EXC_MASK_ALL,          // 捕获所有异常类型
    exception_port,        // lldb 自己的 mach port
    EXCEPTION_DEFAULT,
    THREAD_STATE_NONE
); // 注册一个exception port,在触发断点或异常时会交由调试器处理

多提一嘴,虽然主要用Mach原语去操作,但是还是会调用ptrace仅剩的功能告知BSD层其状态~

权限

*OS下存在多种加固,比如CS_RESTRICT会限制dyld,而CS_RUNTIME(Hardened Runtime)会限制调试和代码注入,即即使满足一些基本的检查,也还需要额外的特权:

int
cantrace(proc_t cur_procp, kauth_cred_t creds, proc_t traced_procp, int *errp)
{
    // 不能trace自己,不能trace已被traced的进程,非root调试者ruid必须等于目标的ruid,且目标不能是setuid binary
    if ((kauth_cred_getruid(creds) != kauth_cred_getruid(traced_cred) ||
        ISSET(traced_procp->p_flag, P_SUGID)) &&
        (my_err = suser(creds, ...)) != 0) {
        ...
        return 0;
    }
    // 目标调用了PT_DENY_ATTACH(P_LNOATTACH)则直接拒绝
    if (ISSET(traced_procp->p_lflag, P_LNOATTACH)) {
        *errp = EBUSY;
        return 0;
    }
    // 特权检查 MACF hook → AMFI policy
    my_err = mac_proc_check_debug(&cur_ident, cur_cred, &traced_ident);
        // ...
}

现在说一下特权:

1.get-task-allow: 这是对被调试程序,有这个权限就能被其他的任意程序获取任务端口

2.task_for_pid-allow: 这是对调试器的,有这个权限能获取其他任务非系统进程(platform binary)的任务端口,不管目标是否有get-task-allow

3.com.apple.system-task-ports*: 这是一系列特权,有它就能调试所有进程,包括系统进程:

task port 强度(从弱到强):
  name port  <  inspect port  <  read port  <  control port

entitlement 覆盖范围(从窄到宽):
  com.apple.system-task-ports.name.safe   → 仅name port,任意目标
  com.apple.system-task-ports.read        → read port,任意目标(含系统进程)
  task_for_pid-allow                      → control port,仅普通用户进程
  com.apple.system-task-ports             → control port,任意目标(含系统进程)
  com.apple.system-task-ports.control     → 以上全部(新系统上的统一替代)

4.com.apple.security.cs.debugger:在拿到任务控制端口后,对普通进程已经可以调试了,但是若目标进程启用了Hardened Runtime,则还需要该权限才能调试,如做内存空间debug映射,修改内存等

注:在iOS上,非开发模式还有额外的限制,如硬件断点/观察点和任务控制端口不可用,异常等也是只读的,完全无法调试,所以需要开启开发者模式。

Dtrace

DTrace(Dynamic Tracing)我之前在Linux里提过,它是Sun公司开发并开源的,后来被多个系统移植,Apple也早早引入了DTrace并针对macOS做了适配,包括:

1.完整的D语言脚本支持

2.提供dtrace命令行工具

3.支持大多数标准的provider,如syscall、proc、io、vm、sched、profile、fbt、pid等,还增加了objc来追踪ObjC方法调用

DTrace和eBPF在很多设计上很像(应该锁后者借鉴了很多思路😛),它也是用户态编写并编译d脚本,交给内核执行,内核使用DIF虚拟机执行编译后的字节码:

                          用户态
    ┌─────────────────────────────────────────────────────┐
    │    libdtrace(D 编译器 + DOF 生成 + 运行时控制)        │
    │                  ↓ DOF(二进制程序)                   │
    └─────────────────────────────────────────────────────┘
                         ↕  ioctl
    ════════════════════════════════════════════════════════
                          内核态
    ┌─────────────────────────────────────────────────────┐
    │                DTrace Framework(核心框架)           │
    │  Probe 管理 │ ECB 管理 │ DIF 虚拟机 │ 缓冲区管理        │
    ├─────────────────────────────────────────────────────┤
    │       Providers(各类探针提供者)                      │
    │  fbt │ sdt │ systrace │ profile │ fasttrap │ ...    │
    └─────────────────────────────────────────────────────┘

需要注意,macOS要关闭SIP才能使用它的完整功能,而iOS就不必做梦了,内核压根没编译该能力,所以它只能在macOS上用。

原理

DTrace是通过动态修改内核函数的机器码来实现的,以FBT为例,在arm64下它识别如下指令:

// 帧推入指令(函数 prologue): stp fp, lr, [sp, #val]
#define FBT_IS_ARM64_FRAME_PUSH(x)      \
    (((x) & 0xffc07fff) == 0xa9007bfd || ((x) & 0xffc07fff) == 0xa9807bfd)

// 帧弹出指令(函数 epilogue): ldp fp, lr, [sp, #val]
#define FBT_IS_ARM64_FRAME_POP(x)       \
    (((x) & 0xffc07fff) == 0xa9407bfd || ((x) & 0xffc07fff) == 0xa8c07bfd)

// 返回指令
#define FBT_IS_ARM64_RET(x)             \
    (((x) == 0xd65f03c0) || ((x) == 0xd65f0fff))

// 补丁值,它是一条非法指令,触发undefined instruction异常,在fbt_enable()时会被写入
#define FBT_PATCHVAL                   0xe7eeee7e

当调用函数时,会触发如下链路:

fbt_init // 初始化
    
dtrace_register("fbt", pops)      // 注册 Provider
    
fbt_provide_module()    // 它会扫描内核符号表,给每个函数创建entry和return的probe
    
ml_nofault_copy(FBT_PATCHVAL  函数入口)    // 写入非法指令
     (执行到此处)
UND 异常  fbt_perfCallback()
    
dtrace_invop(pc, regs)  fbt_invop()
    
dtrace_probe(fbt->fbtp_id, arg0, arg1, ...)  // 触发探针
    
模拟执行原始指令,恢复正常执行流  // 它会使用fbt_probe去记录补丁地址,返回值,触发指令,原始指令等信息,所以能恢复正常流程

不仅ftb,所有的provider都会调用到dtrace_probe它,它的流程为:

dtrace_probe(id, ...)
    
probe = dtrace_probes[id-1]        // O(1) 查找
    
for (ecb = probe->dtpr_ecb; ecb; ecb = ecb->dte_next)
    ├─ dte_cond 安全条件检查
    ├─ dtrace_buffer_reserve()      // 预留缓冲区空间
    ├─ 写入记录头(EPID + 纳秒时间戳)
    ├─ dtrace_dif_emulate(predicate) // 谓词求值
         └─  false  continue
    └─ 执行 action 链表
          ├─ DIF 表达式求值
          ├─ dtrace_aggregate()      // 聚合操作
          └─ 写入数据

通过这就走完了一个完整的探测流程,下面介绍它的使用方式~

Probe

从上图可以看到Dtrace里有很多概念,但最核心是Probe(探针),每个Probe由provider:module:function:name四元组组成,provider表示probe的来源,如syscall为系统调用,profile为基于时间的观测器;module是probe所在的代码模块,因probe而不同,如对内核函数边界(fbt)来说可能是mach_kernel或是某个驱动,对syscall来说通常为空因为系统调用不属于某个特定模块;function是probe所在的具体函数名;name是探针名,是probe在函数中的具体位置或事件类型,常见值有:

name 含义
entry / return 刚进入函数时/函数返回时触发
tick-1sec profile provider 专用,每秒触发一次
997hz profile provider 专用,每秒触发 997 次
exec-success proc provider 专用,exec 成功后触发
create / exit proc provider 专用,进程创建/退出
start / done io provider 专用,I/O 开始/完成

这些字段可以用*通配符或留空,留空表示匹配所有,例如:

syscall:::entry          /* module 和 function 留空,匹配所有系统调用入口 */
syscall::*read*:entry    /* function 名含 read 的系统调用入口 */

除了上面提到的,它还有三个特殊Probe:

BEGIN   { /* DTrace启动时执行一次 */ }
END     { /* DTrace退出时执行一次,常用于输出聚合 */ }
ERROR   { /* probe执行出错时触发 */ }

D语法

dtrace使用d语言编写脚本,其文件名通常以.d结尾,文件基本结构为:

/* 注释 */
#pragma D option quiet          /* 编译器选项 */

/* 全局变量声明(可选) */
int count;

/* probe描述 / 谓词 / 动作 */
provider:module:function:name
/predicate/             /* 可选的谓词,决定是否执行动作 */
{
    action;             /* 动作语句 */
}
/* 这里的谓词+动作为ECB/ECF */

首先是编译器选项,它支持设置如下选项:

#pragma D option quiet          /* 不打印"dtrace: N probes enabled" */
#pragma D option bufsize=64m    /* 输出缓冲区大小 */
#pragma D option dynvarsize=32m /* 动态变量(关联数组等)内存上限 */
#pragma D option stackframes=100 /* ustack/stack 最大帧数 */
#pragma D option flowindent     /* 函数调用自动缩进 */
#pragma D option defaultargs    /* 未提供的$N参数默认为 0 */
#pragma D option destructive    /* 允许stop()/raise()/system()等破坏性操作 */
#pragma D option aggsize=64m    /* 聚合变量内存上限 */
#pragma D option aggrate=1sec   /* 聚合数据刷新频率 */
#pragma D option switchrate=10hz /* 缓冲区切换频率 */

然后是变亮,它可以定义多种数据类型:

/* 标量变量(全局) */
int x;           /* 全局整数,所有 CPU 共享 */
string s;
uint64_t count;


/* 线程局部变量 */
self->start = timestamp;   /* 每个线程独立的变量 */


/* 关联数组 */
counts[execname]++;                    /* 以进程名为 key */
latency[pid, tid] = timestamp;         /* 多维 key */

/* 子句局部变量 */
this->x = arg0;    /* 仅在当前 probe 触发的单次执行中有效 */

/* 聚合变量 */
@counts[execname] = count();
@bytes[execname]  = sum(arg2);
@dist[execname]   = quantize(arg2);

并且它内置了一些变量可以直接使用:

变量 类型 含义
pid int 当前进程PID
tid int 当前线程ID
execname string 进程名
uid / gid int 用户/组ID
timestamp int 纳秒时间戳(系统启动以来)
walltimestamp int 纳秒墙钟时间
cpu int 当前CPU编号
arg0~arg9 uint64 probe参数(整数)
args[] 类型化数组 类型化的probe参数
probeprov string 当前probe的provider名
probemod string 当前probe的module名
probefunc string 当前probe的function名
probename string 当前probe的name
curthread thread_t* 当前线程结构体指针
curlwpsinfo lwpsinfo_t* 当前线程信息
curpsinfo psinfo_t* 当前进程信息
errno int 最近系统调用的errno(return probe)
stackdepth int 当前调用栈深度

它还提供了多种函数,如聚合函数:

函数 说明
count() 计数
sum(x) 求和
avg(x) 平均值
min(x) 最小值
max(x) 最大值
stddev(x) 标准差
quantize(x) 2 的幂次直方图
lquantize(x, lo, hi, step) 线性直方图
llquantize(x, factor, lo, hi, steps) 对数线性直方图

Action函数:

/* 输出 */
trace(expr)              /* 追踪一个值 */
tracemem(addr, size)     /* 追踪内存块 */
printf(fmt, ...)         /* 格式化输出 */
printa(fmt, @agg)        /* 格式化输出聚合 */
print(expr)              /* 打印类型化值(DTrace 1.9+)*/

/* 栈追踪 */
stack()          /* 内核调用栈 */
stack(depth)     /* 指定深度的内核调用栈 */
ustack()         /* 用户态调用栈 */
ustack(depth)    /* 指定深度的用户态调用栈 */
jstack()         /* Java 栈(需要 JVM helper)*/

/* 字符串处理 */
copyinstr(addr)          /* 从用户态地址读取字符串 */
copyinstr(addr, maxlen)  /* 限制长度 */
copyin(addr, size)       /* 从用户态读取内存块 */
stringof(addr)           /* 将内核地址转为字符串 */
substr(str, start)       /* 子字符串 */
substr(str, start, len)
strjoin(s1, s2)          /* 字符串拼接 */
strlen(str)              /* 字符串长度 */
strtoll(str, base)       /* 字符串转整数 */

/* 进程控制 */
stop()           /* 暂停当前进程(发送 SIGSTOP)*/
raise(sig)       /* 向当前进程发送信号 */
system(cmd)      /* 执行 shell 命令(慎用)*/
exit(status)     /* 退出 DTrace */

/* 时间获取 */
timestamp        /* 纳秒,单调递增 */
vtimestamp       /* 虚拟时间(仅计算当前线程在 CPU 上的时间)*/

并不是每次执行匹配的probe都会执行Action,他还受谓词控制(如果存在),谓词是/包围的布尔表达式,为真才执行action,如:

/* 只追踪Safari */
syscall::read:entry
/execname == "Safari"/
{
    printf("Safari read %d bytes\n", arg2);
}

/* 多条件 */
syscall:::entry
/pid == $1 && uid != 0/
{
    trace(probefunc);
}

最后是控制流,熟悉eBPF就能猜到DTrace是不支持循环的,只支持有限的条件判断,如:

/* 三元运算符 */
this->result = (arg0 > 0) ? "positive" : "non-positive";

syscall::read:return
{
    if (arg1 > 1024) {
        printf("large read: %d\n", arg1);
    } else {
        printf("small read: %d\n", arg1);
    }
}

编写的d脚本会被编译为DIF字节码,通过dtrace_ioctl交给内核,再由沙盒化的RISC虚拟机执行(dtrace_dif_emulate),完整流程为:

  D 脚本 ──> 编译──> DOF ──ioctl(DOF)──> 内核解析/创建 ECB
                                              
                                      ioctl(GO) 启用探针
                                              
  ioctl(BUFSNAP) <──复制──── 切换缓冲区 <── probe 触发/写数据

代码样例

/usr/bin之下有很多自带的dtrace,这里对每种provider举例说明:

syscall Provider

追踪系统调用,是最常用的 provider:

/* 统计各进程系统调用次数 */
syscall:::entry
{
    @calls[execname] = count();
}

/* 追踪open系统调用的文件路径 */
syscall::open:entry,
syscall::open_nocancel:entry
{
    printf("%s opened: %s\n", execname, copyinstr(arg0));
}

/* 统计read的字节数分布 */
syscall::read:return
/arg1 > 0/
{
    @bytes[execname] = quantize(arg1);
}
proc Provider

追踪进程/线程生命周期:

proc:::exec-success   { printf("exec: %s\n", curpsinfo->pr_psargs); }
proc:::exit           { printf("exit: %s (pid=%d)\n", execname, pid); }
proc:::fork           { printf("fork: parent=%d child=%d\n", pid, arg0); }
proc:::signal-send    { printf("%s sent signal %d to pid %d\n", execname, arg1, args[1]->pr_pid); }
profile Provider

基于时间的采样,用于CPU profiling:

/* 每秒997次采样(质数避免与系统频率共振)*/
profile-997hz
/pid == $target/
{
    @stacks[ustack()] = count();
}

/* 固定时间间隔 */
profile:::tick-1sec
{
    printa(@counts);
    clear(@counts);
}
fbt Provider(需关闭 SIP)

内核函数边界追踪(Function Boundary Tracing):

/* 追踪所有TCP相关内核函数 */
fbt::tcp*:entry
{
    printf("%s called\n", probefunc);
}

/* 测量内核函数耗时 */
fbt::vfs_read:entry   { self->ts = timestamp; }
fbt::vfs_read:return  /self->ts/
{
    @lat[probefunc] = quantize(timestamp - self->ts);
    self->ts = 0;
}
pid Provider

追踪用户态进程的函数调用:

/* 追踪指定进程的所有函数调用 */
pid$target:::entry
{
    printf("%s\n", probefunc);
}

/* 追踪特定库的函数 */
pid$target:libSystem*:malloc:entry
{
    printf("malloc(%d) called\n", arg0);
    ustack();
}

/* 追踪函数返回值 */
pid$target:libSystem*:malloc:return
{
    printf("malloc returned %p\n", arg1);
}
objc Provider(macOS 特有)

追踪Objective-C方法调用:

/* 追踪所有NSString方法 */
objc$target:NSString:-*:entry
{
    printf("[NSString %s]\n", probefunc);
}

/* 追踪特定方法 */
objc$target:*ViewController:-viewDidLoad:entry
{
    printf("viewDidLoad called in %s\n", probemod);
    ustack();
}
io Provider

追踪I/O操作:

io:::start
{
    printf("%s: %s %d bytes at offset %d\n",
        execname,
        args[0]->b_flags & B_READ ? "read" : "write",
        args[0]->b_bcount,
        args[0]->b_blkno);
}

/* 统计各进程 I/O 字节数 */
io:::done
{
    @bytes[execname] = sum(args[0]->b_bcount);
}
vm Provider

追踪虚拟内存事件:

vm:::pgin    { @pageins[execname]  = count(); }
vm:::pgout   { @pageouts[execname] = count(); }
vm:::anon-create { printf("anon region created by %s\n", execname); }
sched Provider

追踪调度器行为:

sched:::on-cpu   { self->oncpu = vtimestamp; }
sched:::off-cpu  /self->oncpu/
{
    @runtime[execname] = sum(vtimestamp - self->oncpu);
    self->oncpu = 0;
}

使用

dtrace

这是最直接的命令了,如:

# 列出所有可用 probe(数量巨大,建议加过滤)
sudo dtrace -l | head -50
sudo dtrace -l -n 'syscall:::entry' | wc -l

# 单行命令(-n)
sudo dtrace -n 'syscall:::entry { @[execname] = count(); }'

# 运行脚本文件
sudo dtrace -s script.d

# 指定目标进程($target 变量)
sudo dtrace -s script.d -p 1234

# 启动并追踪命令($target 自动绑定)
sudo dtrace -s script.d -c "ls -la"

# 常用选项
sudo dtrace -n '...' \
    -o output.txt      \   # 输出到文件
    -q                 \   # quiet 模式(不打印 probe 触发计数)
    -x bufsize=64m     \   # 设置缓冲区大小
    -x stackframes=50  \   # 栈帧深度
    -x dynvarsize=64m  \   # 动态变量空间
    -x flowindent          # 函数调用缩进显示
dtruss等

/usr/bin下有很多自带的基于dtrace的工具,比如strace替代品dtruss:

sudo dtruss -p <PID>          # 追踪进程系统调用
sudo dtruss -f ls /tmp        # 追踪命令及其子进程
sudo dtruss -c ls /tmp        # 统计系统调用次数

还有追踪文件打开操作的opensnoop,追踪磁盘I/O的iosnoop,追踪进程创建的execsnoop和追踪新进程的newproc等~

instruments

这是xcode里的性能分析与调试工具套件,它本质是DTrace的图形化前端,它内置了很多模板,是我们可以用很简单的方式去使用DTrace,之后细嗦~

image

KDebug

它是XNU内核里一个强大的追踪设施,从能力上它比DTrace要弱点,但是它胜在iOS也能用(尽管它的编译选项支持完全禁用,iOS没有这么做)。

原理

先看眼它的架构图:

┌─────────────────────────────────────────────────────────────────────┐
                           User Space                                
  ┌──────────────┐  ┌─────────────────┐  ┌─────────────────────┐     
   kdebug_trace    sysctl interface    typefilter mmap         
     syscall       (KERN_KDEBUG)       (8KB 位图共享内存)        
  └──────┬───────┘  └────────┬────────┘  └──────────┬──────────┘     
└─────────┼───────────────────┼──────────────────────┼────────────────┘
           syscall            sysctl                mmap
                                                   
┌─────────────────────────────────────────────────────────────────────┐
                           Kernel                                    
  ┌───────────────────────────────────────────────────────────────┐  
            kdebug.c  (核心事件记录引擎)                              
    KDBG宏  kernel_debug()  _write_trace_record()                 
  └──────────────────────────────┬────────────────────────────────┘  
                                                                     
  ┌──────────────────────────────▼────────────────────────────────┐  
                    分层存储系统                                     
    kd_control(全局控制) + kd_buffer(缓冲管理)                        
    kd_bufinfo[cpu0] kd_bufinfo[cpu1] ... (Per-CPU 链表头)          
                                                                 
    kd_storage  kd_storage  ...  (每单元2048条事件)                
  └───────────────────────────────────────────────────────────────┘  
                                                                      
  [过滤层] typefilter(8KB位图) / RANGE过滤 / EXACT精确匹配               
  [协处理器] kd_coproc  kernel_debug_enter() 注入IOP事件               
  [triage] kdebug_triage.c  线程故障诊断轻量缓冲                        
  [kperf] kdebug_trigger.c  事件触发性能采样                           
└─────────────────────────────────────────────────────────────────────┘

不像DTrace动态Patch机器码,kdebug是在编译时就静态编译进了stub,并且用全局变量控制是否生效:

#define __improbable(x) __builtin_expect(!!(x), 0) // 它通过__builtin_expect来优化分支预测,减少分支判断带来的损耗

#if (KDEBUG_LEVEL >= KDEBUG_LEVEL_STANDARD)
#define KERNEL_DEBUG_CONSTANT(x, a, b, c, d, e)                               \
    do {                                                                      \
            if (KDBG_IMPROBABLE(kdebug_enable & ~KDEBUG_ENABLE_PPT)) {            \
                    kernel_debug((x), (uintptr_t)(a), (uintptr_t)(b), (uintptr_t)(c), \
                            (uintptr_t)(d),(uintptr_t)(e));                               \
            }                                                                     \
    } while (0)
// 最终会变成 if (kdebug_enable) { kernel_debug(...); }

它的初始化流程如下:

kernel_startup
    └── kdebug_startup()                    (bsd/kern/kdebug_common.c)
           ├── lck_spin_init()              初始化trace/triage两套存储锁
           ├── kdebug_init(nkdbufs, ...)    (bsd/kern/kdebug.c)
                  ├── typefilter_create()  创建 8KB 类型过滤器位图
                  └── kdebug_trace_start()
                          ├── kdbg_reinit()          分配 Per-CPU 缓冲区
                          ├── KDBG_NOWRAP 标志设置    ( wrap 模式)
                          ├── kdbg_enable_typefilter()
                          ├── kdbg_set_tracing_enabled(true)
                          └── kernel_debug_early_end() 将早期静态缓冲区事件迁移到真实缓冲区
           └── create_buffers_triage()      创建并立即启用 triage 缓冲区

Debug ID

它在报告时只记录一个32位的debugid和前四个64位参数,并且自动添加时间和线程ID、CPU ID等信息,其中debugid由四部分组成,构成如下:

╭──────────┬──────────┬───────────────┬────╮
│  class   │ subclass │     code      │ fn │
│  8 bits  │  8 bits  │   14 bits     │ 2b │
╰──────────┴──────────┴───────────────┴────╯
 31      24 23      16 15            2 1  0

这里的class是功能大类,如DBG_MACHDBG_BSDDBG_FSYSTEM等,subclass是子类,如DBG_MACH_SCHEDDBG_MACH_VM等,code是子类内的具体事件编号,而fn是事件状态,如DBG_FUNC_NONE=0(瞬时)、DBG_FUNC_START=1(区间开始)、DBG_FUNC_END=2(区间结束)等。debug id唯一标识一个事件的类型,在使用时有三层概念:

event ID = class + subclass + code      // 不含fn,描述"是什么事件",同一个event ID的START和END事件可以组成一个时间区间
debug ID = event ID + func                      // 完整的32位,是实际写入`kd_buf.debugid`的值,包含完整语义
CSC      = class + subclass                     // 高16位,用于typefilter过滤位图的索引,即通过CSC决定是否要记录日志

使用

用户态通过sysctl操作kdebug:

int mib[3] = { CTL_KERN, KERN_KDEBUG, 操作码 };
sysctl(mib, 3, oldp, &oldlen, newp, newlen);
// 对于需要附加参数的操作(如 KERN_KDENABLE):
int mib[4] = { CTL_KERN, KERN_KDEBUG, 操作码, 参数值 };
sysctl(mib, 4, oldp, &oldlen, newp, newlen);

它支持的所有操作如下:

#define KERN_KDEFLAGS           1    // 设置flags(或运算,仅限KDBG_USERFLAGS: NOWRAP, CONTINUOUS_TIME, DISABLE_COPROCS, MATCH_DISABLE)
#define KERN_KDDFLAGS           2    // 清除flags(与非运算,仅限 KDBG_USERFLAGS 范围内的位)
#define KERN_KDENABLE           3    // 启用/禁用trace(mib[3]=1启用,0禁用;需先调用KERN_KDSETUP)
#define KERN_KDSETBUF           4    // 设置缓冲区大小(单位: 事件条数 kd_buf;必须在KERN_KDSETUP前调用)
#define KERN_KDGETBUF           5    // 读取缓冲区状态(返回kbufinfo_t: 事件容量, 是否停止, flags, 线程数, owner_pid)
#define KERN_KDSETUP            6    // 分配Per-CPU缓冲区(真正申请内存;必须在KERN_KDENABLE之前调用)
#define KERN_KDREMOVE           7    // 停止trace并释放所有缓冲区(清理现场,重置状态)
#define KERN_KDSETREG           8    // 设置过滤器(支持按Class, Subclass, Range或精确EventID过滤)
#define KERN_KDGETREG           9    // 读取当前过滤器设置(已废弃,返回 EINVAL)
#define KERN_KDREADTR           10   // 批量复制事件到用户态(oldp=kd_buf 数组,返回写入的条数)
#define KERN_KDPIDTR            11   // 设置进程 PID 白名单(KDBG_PIDCHECK 模式,只记录特定进程事件)
#define KERN_KDTHRMAP           12   // 读取线程映射表(历史接口,现已被 stackshot 替代)
#define KERN_KDPIDEX            14   // 设置进程 PID 黑名单(KDBG_PIDEXCLUDE 模式,排除特定进程事件)
#define KERN_KDSETRTCDEC        15   // 已废弃(设置 RTC 解码器)
#define KERN_KDGETENTROPY       16   // 已废弃(获取熵)
#define KERN_KDWRITETR          17   // 将事件以RAW V1格式直接写入文件描述符(fd 由 mib[3] 传入)
#define KERN_KDWRITEMAP         18   // 将线程映射表写入文件描述符(fd 由 mib[3] 传入)
#define KERN_KDTEST             19   // 内核自测(仅 DEVELOPMENT/DEBUG 内核可用)
#define KERN_KDREADCURTHRMAP    21   // 读取当前实时线程映射(不需要缓冲区已初始化)
#define KERN_KDSET_TYPEFILTER   22   // 设置8KB类型过滤位图(按 class/subclass 组合过滤,自动切换模式)
#define KERN_KDBUFWAIT          23   // 阻塞等待缓冲区达到半满阈值或超时(适合高效读取循环)
#define KERN_KDCPUMAP           24   // 读取CPU映射表(RAW V1 格式,关联 cpuid 与硬件名称)
#define KERN_KDCPUMAP_EXT       25   // 读取扩展CPU 映射表(包含协处理器/IOP 等额外信息)
#define KERN_KDSET_EDM          26   // 设置"事件匹配后自动禁用"掩码(用于捕获特定瞬间并停止 trace)
#define KERN_KDGET_EDM          27   // 读取当前的事件匹配禁用 (EDM) 掩码设置
#define KERN_KDWRITETR_V3       28   // 将事件以RAW V3分块格式写入文件(支持更高精度和协处理器数据)

所有软件都会遵守一定的顺序去调用他们,例如:

/* --- Kdebug 生命周期配置与执行 --- */
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDSETBUF, n_events}, 4, NULL, NULL, NULL, 0);      // 设置期望的缓冲区大小(事件条数)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDSETUP}, 3, NULL, NULL, NULL, 0);               // 分配内核缓冲区(Per-CPU 链表)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDEFLAGS, KDBG_NOWRAP}, 4, NULL, NULL, NULL, 0); // 设置行为标志(如禁止循环覆盖)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDSET_TYPEFILTER}, 3, NULL, NULL, filter, 8192); // 设置 8KB class/subclass 过滤位图
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDSETREG}, 3, NULL, NULL, &reg, sizeof(reg));    // 设置范围/精确值过滤器 (kd_regtype)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDPIDTR}, 3, NULL, NULL, &reg, sizeof(reg));     // 设置进程白名单 (value1=pid)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDPIDEX}, 3, NULL, NULL, &reg, sizeof(reg));     // 设置进程黑名单 (value1=pid)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDENABLE, 1}, 4, NULL, NULL, NULL, 0);           // 启动 trace (KDEBUG_ENABLE_TRACE)

/* --- 数据采集与监控 --- */
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDBUFWAIT}, 3, &count, &len, NULL, timeout_ms);  // 阻塞等待缓冲区半满或超时
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDGETBUF}, 3, &info, &info_len, NULL, 0);        // 查询当前缓冲区状态 (kbufinfo_t)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDREADCURTHRMAP}, 3, buf, &len, NULL, 0);        // 读取当前线程映射 (kd_threadmap)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDREADTR}, 3, events, &len, NULL, 0);            // 批量读出事件到 kd_buf 数组

/* --- 停止与清理 --- */
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDENABLE, 0}, 4, NULL, NULL, NULL, 0);           // 停止trace
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDREMOVE}, 3, NULL, NULL, NULL, 0);              // 释放缓冲区,重置全局状态,这一步很重要,如果没做好会导致物理内存泄漏

注: kdebug同时只能被一个进程使用,当它被使用时其他进程会返回失败~

相关工具

这些工具用起来相对简单,这里就直接列一下:

1.kdv(kDebugView): 这是Jonathan Levin开发的工具,可在这里下载,使用方式极其简单~

2.tailspin: 它包含一个守护进程/usr/libexec/tailspind和一个命令行工具/usr/bin/tailspin,可以用tailspin save保存日志去其他地方分析

3.ktrace: 它是 libktrace 的命令行接口,可以读取 .tracev3 文件并与内核设施交互

4.kperf: 是一个内核分析框架,它在Kdebug的基础上增加了采样(Sampling)功能,当触发特定的调试ID时,kperf 会执行采样动作,记录线程信息、内核/用户堆栈、内存信息等

5.kpc: 这是 kperf 的子功能,专门用于访问机器的性能计数器(Performance Counters)

6.Instruments.app: 它也支持kdebug哦~

7.fs_usage: 用于追踪文件系统活动

8.sc_usage: 用于追踪系统调用的使用情况

9.latency: 用于监控系统延迟

三方Instruments工具

Objective-See Utils

怀念Windows的sysinternals,Apple把macOS的权限收的紧紧的,内核扩展已经没戏了,还好剩点良心留了个系统扩展,Objective-See就在Endpoint Security Framework基础上开发了几个好用的工具,包括ProcessMonitorFile MonitorDNSMonitor,用法很简单就不多说了~

tweak

tweak就是开发一个动态库去hook原始逻辑,从而修改/添加功能,和android类似,ios也有很多框架可以做这事,通常大家只会逮着最好用的使,但一个完整的Tweak开发链路涉及很多部分: 编写Hook(Theos + Logos)→ 打包(.deb)→ 注入加载(ElleKit/libhooker/Substrate)→ 分发(Sileo/Cydia),我先简单列一下防止摸着头脑:

工具类别简介

开发框架

它提供hook代码开发、编译、打包甚至部署一条龙,主要就是提供构建系统(如Makefile模板)和预处理系统(如Logos),常见的有:

1.Theos: 这是主流的开发框架,现在还在活跃维护

2.Dragon: 这是一个较新的框架,旨在提供更轻量、更快速地构建能力

3.iOSOpenDev: 是个早期的基于xcode的开发插件

4.MonkeyDev: 这是国人基于iOSOpenDev开发的插件,也是和xcode融合,提供了大量额外的能力简化开发过程

使用它们能通过少量的代码开发出一个dylib,并被大包为deb供目标系统安装,这也是等下要重点介绍的过程。

Hook引擎

上面编写的库本身是没有hook能力的,相反它们是依赖其他运行时库来提供hook甚至库注入能力,这个运行时库就是这里说的Hook引擎,不过在聊它们前,咱需要先看看ios上的hook底层技术有哪些:

1.GOT/PLT Hook: 这是对导出符号的,包括C/swift的,比如经典的fishhook可做这个

3.Inline Hook: C和Swift都需要,就是动态劫持机器码跳转,比如我们熟悉的Dobby

4.Method swizzling: 这是ObjC专有的,OC的方法调用本质就是消息发送(objc_msgSend),方法实现存在一张可修改的表里,可以在运行时修改,甚至官方就提供了这个能力,如method_exchangeImplementationsmethod_setImplementationclass_addMethod等,所以这是最简单的

好了,现在可以看看一些流行的引擎了:

1.MobileSubstrate / Cydia Substrate: 这是最经典的Hook框架,提供 MSHookMessageEx(ObjC 方法 hook)和MSHookFunction(C函数hook)两个核心API,是整个越狱生态的基石,自带SubstrateLoader负责库加载

2.libhooker: 它是Procursus/Odyssey越狱生态中的替代品,性能更好,兼容Substrate API同时支持arm64e,内置loader

3.Substitute: 它是Sileo/Chimera越狱使用的开源Hook框架,作为Substrate的开源替代

应用管理

即包管理/应用商店的角色,比如:

1.Cydia: 它是经典的包管理器,其本质是APT/dpkg的图形化前端,不过从18年开始作者基本就不维护它了

2.Sileo: 这是目前主流的包管理器,同样是基于APT/dpkg的,用它!

其他还有像Zebra、installer 5等小众工具可用~

开发简介

这里以theos为例,它支持直接安装在ios上开发,不过我们还是在macOS搞方便,直接运行下面的命令安装:

 bash -c "$(curl -fsSL https://raw.githubusercontent.com/theos/theos/master/bin/install-theos)"

它会把工具注册到环境变量,所以可以直接用NIC(New Instance Creator)初始化一个项目,它里面有很多模板,比如:

~$ $THEOS/bin/nic.pl
NIC 2.0 - New Instance Creator
------------------------------
  [1.] iphone/application_modern
  [2.] iphone/application_swift_modern
  [3.] iphone/application_swiftui
  [4.] iphone/control_center_module-11up
  [5.] iphone/framework
  [6.] iphone/library
  [7.] iphone/null
  [8.] iphone/preference_bundle
  [9.] iphone/preference_bundle_swift
  [10.] iphone/theme
  [11.] iphone/tool
  [12.] iphone/tool_swift
  [13.] iphone/tweak
  [14.] iphone/tweak_swift
  [15.] iphone/tweak_with_simple_preferences
  [16.] iphone/xpc_service_modern
Choose a Template (required): 13    # 这里是最常用的模板
Project Name (required): Example    # 项目名称,一般取和hook目的相关的
Package Name [com.yourcompany.example]: dev.theos.example    # 逆DNS的名称
Author/Maintainer Name [Craig Federighi]: Craig Federighi <notfederighi@theos.dev>                    # 开发者信息,官方说最好写真的,方便其他使用者联系
[iphone/tweak] MobileSubstrate Bundle filter [com.apple.springboard]:    # 要hook的目标bundle id
[iphone/tweak] List of applications to terminate upon installation (space-separated, '-' for none) [SpringBoard]:
Instantiating iphone/tweak in example/...
Done.

# 之后就创建了项目目录和文件,如这里为4个文件
~$ cd example

~/example$ ls
Example.plist  Makefile  Tweak.x  control

我们可能需要修改Makefile去设置编译目标、部署地址等,修改control去设置hook相关,如制定hook引擎,而hook逻辑是在Tweak.x里编写,它使用logos语法,详见Logos: Syntax,写好后就可以编译打包甚至直接安装到目标设备,如:

# 修改control  PredicatePatch cat control
Package: me.local.predicatepatch
Name: PredicatePatch
Version: 0.0.1
Architecture: iphoneos-arm64
Description: An awesome MobileSubstrate tweak!
Maintainer: B3taMa0
Author: B3taMa0
Section: Tweaks
# Depends: mobilesubstrate (>= 0.9.5000) # 指定运行时依赖,因为我使用tweakloader,这一行可以直接删除

# 修改makefile  PredicatePatch cat Makefile
THEOS_PACKAGE_SCHEME = rootless # 现在越狱都是rootless的,这行保持不变,它会安装在/var/jb下面
TARGET := iphone:clang:latest:14.0  # 这里的目标也要写新点,旧的不支持arm64
INSTALL_TARGET_PROCESSES = Taobao4iPhone
ARCHS = arm64 arm64e                                # 构建目标,通常都是arm64e,可以写多个,生成fat bin
include $(THEOS)/makefiles/common.mk
TWEAK_NAME = PredicatePatch
PredicatePatch_FILES = Tweak.x
PredicatePatch_CFLAGS = -fobjc-arc

include $(THEOS_MAKE_PATH)/tweak.mk

# 开始开发  PredicatePatch cat Tweak.x

#import <Foundation/Foundation.h> // 导入头文件

@interface _NSPredicateUtilities : NSObject
+ (void)_predicateSecurityAction;
@end

%hook _NSPredicateUtilities

+ (void)_predicateSecurityAction {
    // 这里啥都不做,hook 安全检查为空,绕过检查
}

%end

Frida

关于Frida的原理和使用不再介绍,可看之前的文章,这里就提一下安装方式:

1.越狱设备: Github上没有直接针对ios的frida-server供下载,而是要通过在Sileo/Cydia中添加源(https://build.frida.re)去下载frida

2.非越狱设备: 就是重打包的路子,将FridaGadget.dylib插入应用重签名

参考

[1] *OS Internals: Volume I - Chapter 15 -- Jonathan Levin

[2] 逆向调试利器: Frida -- crifan

[3] macOS LOGS: ASL To Unified Logging

[4] Apple Unified Log

[5] Unified System Logs: The key to root cause

social