1. 中级Dtrace
打开Finding Ray应用程序。 在模拟器上生成并运行该项目。该项目的大部分内容都是用Swift编写的,但许多Swift子类是从NSObject继承的。
Swift代码从任何类继承,DTrace
都可以处理。 只要使用objc$target provider
,我们仍然可以分析从NSObject
继承的Swift对象的Objective-C代码。 这种方法的缺点是,如果Swift类实现了任何新方法或实现了任何重写的方法,我们将不会在任何Objective-C探测中看到它们。
1.1 DTrace与Swift理论
我们来谈谈如何使用DTrace
来分析Swift代码。有一些优点,也有一些缺点需要考虑。
首先,好消息是:Swift与DTrace
模块配合得很好!这意味着很容易根据它实现的特定模块筛选出Swift代码。模块(也称为probemod)可能是Xcode中包含Swift代码的目标名称(除非在Xcode的构建设置中更改了目标名称)。
这意味着我们可以像这样过滤SomeTarget
模块中实现的以下Swift代码:
pid$target:SomeTarget::entry
这将在SomeTarget
模块中实现的每个函数的开始处设置一个探测。因为没有限定ObjuleC代码,所以这个探针也会选择C/C++代码。
坏消息是,由于模块的信息被占用,Swift类名和函数名都进入了Swift方法的DTrace函数部分(probefunc
)。这意味着我们需要在DTrace
查询方面更具创造性。
在之前的Swift(Swift 3)迭代中,DTrace
返回的probefunc
Swift名称是mangled
Swift名称,但这在Swift 4中不再适用!DTrace
现在在输出中使用未混合的Swift名称!因此,不用再费心了。让我们看一个快速的DTrace
探测示例。假设我们有一个名为ViewController
的UIViewController
子类重写viewDidLoad
。就像这样:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
}
如果要在此函数上创建断点,则此断点的全名如下:
SomeTarget.ViewController.viewDidLoad() -> ()
如果我们想在SomeTarget
目标中搜索Swift
实现的每个viewDidLoad
,可以创建如下所示的DTrace
探测描述:
pid$target:SomeTarget:*viewDidLoad*:entry
下面我们在Finding Ray项目里面试试。运行项目,并在终端中输入:
sudo dtrace -n 'pid$target:Finding?Ray::entry' -p `pgrep "Finding Ray"`
Password:
dtrace: description 'pid$target:Finding?Ray::entry' matched 233 probes
我选择了一个Xcode项目名,它故意有一个空格。注意在使用DTrace
脚本时,需要执行哪些操作来解析Xcode目标中的空格。probemod
部分使用?
作为空间的占位符通配符。此外,在为进程名进行pgrep
时,需要将查询包围起来,否则它将不起作用。
输入完密码后,在Finding Ray
模块中的所有非Objective-C函数中,我们将获得233次探针输入命中。
单击Ray并在模拟器中拖动他,同时监视终端中被击中的所有方法。输出还是有点太多了了。我们只希望打印Swift函数
。不需要查看探测ID和CPU列。终止DTrace
脚本并将其替换为以下内容:
sudo dtrace -qn 'pid$target:Finding?Ray::entry { printf("%s\n", probefunc); } ' -p `pgrep "Finding Ray"`
我们添加了-q
(或-quiet
)选项。这将告诉DTrace
不要显示找到的探测数量,也不要在探测被击中时显示其默认输出。幸运的是,我们还添加了printf
语句来手动地打印probefunc
。等待DTrace
启动,然后再次拖动。
好一些了,但是我们仍然得到一些Swift编译器生成的方法,而这些方法不是我们编写的。我们不想看到Swift编译器创建的任何代码,只想看到我们在Swift类中编写的代码。终止以前的DTrace
脚本,并将此探测描述扩展为仅包含已实现的代码,而不包含Swift编译器的代码:
sudo dtrace -qn 'pid$target:Finding?Ray::entry { printf("%s\n", probefunc); } ' -p `pgrep "Finding Ray"` | grep -E "^[^@].*\."
grep
使用正则表达式查询来表示返回不包含@
且在输出中包含.
的任何内容,就是说不返回任何@objc
桥接方法。
扩展脚本以删除grep
过滤,而是跟踪Finding Ray
模块中的所有Swift函数条目和出口,并使用DTrace
的flowindent
选项。flowindent
选项将正确缩进函数项并返回。
sudo dtrace -qFn 'pid$target:Finding?Ray::*r* { printf("%s\n", probefunc); } ' -p `pgrep "Finding Ray"`
我们已经添加了-F
选项开启了flowindent
。查看探针描述中的name
部分,*r*
。这是干什么的?从DTrace
的角度来看,进程中的大多数函数对每个汇编指令都有入口、返回和函数偏移量。其中偏移量以十六进制表示。这个参数的意思就是“给我任何包含字母‘r’的名字。”这将包含探测描述名称中的entry
和return
,但忽略任何函数偏移。
启用每个Swift函数的enter
和return
探测后,我们可以清楚地看到正在执行哪些函数以及从何处执行它们。等待DTrace
开始,然后拖动图片。你会得到如下的输出:
1.2 DTrace变量与控制流
DTrace
有几种方法可以在脚本中创建和引用变量。在DTrace
的使用速度和便利性之间的较量中,它们都有各自的优缺点。
标量变量
创建变量的第一种方法是使用标量变量。这些是简单的变量,只能接受固定大小的项。不需要在DTrace
脚本中声明标量变量的类型或任何与此相关的变量。我倾向于在DTrace
脚本中使用标量变量来表示布尔值。这是由于DTrace
的条件逻辑有限。只有谓词和三元运算符才能真正构成分支逻辑。
例如,下面是使用标量变量的实际情况:
#!/usr/sbin/dtrace -s #pragma D option quiet
dtrace:::BEGIN
{
isSet = 0;
object = 0;
}
objc$target:NSObject:-init:return / isSet == 0 /
{
object = arg1;
isSet = 1;
}
objc$target:::entry / isSet && object == arg0 /
{
printf("0x%p %c[%s %s]\n", arg0, probefunc[0], probemod, (string)&probefunc[1]);
}
这个脚本声明两个标量变量:isSet
标量变量将检查object
标量变量是否已设置。否则,脚本将把下一个对象设置为对象变量。这个脚本将跟踪在对象变量上使用的所有Objective-C方法调用。
子句局部变量
由this->
这个词表示。在变量名之前使用,可以接受任何类型的值,包括char*
。子句局部变量可以在同一个探测中生存。如果你试图在另一个探测器上引用它们,那就行不通了。例如,请考虑以下内容:
pid$target::objc_msgSend:entry
{
this->object = arg0;
}
pid$target::objc_msgSend:entry / this->object != 0 / {
/* Do some logic here */
}
objc$target:::entry {
this-f = this->object; /* Won’t work since different probe */
}
我倾向于尽可能多地使用子句局部变量,因为它们非常快,而且我不必像处理下一类变量那样手动释放它们。
线程局部变量
线程局部变量以速度为代价提供了最大的灵活性。但是,必须手动释放它们,否则会泄漏内存。线程局部变量可以在变量名前面加上self->
。线程局部变量的好处是它们可以在不同的探测中使用,例如:
objc$target:NSObject:init:entry {
self->a = arg0;
}
objc$target::-dealloc:entry / arg0 == self->a / {
self->a = 0;
}
这将把self->a
分配给正在初始化的任何对象。释放此对象时,还需要通过将a
设置为0手动释放它。
下面针对DTrace
中使用变量,让我们讨论一下如何使用变量执行条件逻辑。
DTrace条件
DTrace
内置了极其有限的条件逻辑。DTrace
中没有if/else
语句!这是一个有意识的决定,因为DTrace脚本被设计为执行非常快。但是,当我们希望基于特定探测或该探测中包含的信息有条件地执行逻辑时,它确实会给我们带来问题。为了克服这个限制,有两个值得注意的方法可以用来执行条件逻辑。
- 使用三元运算符
考虑下面这个OC逻辑:
int b = 10;
int a = 0;
if (b == 10) {
a = 5;
} else {
a = 6;
}
在DTrace
中它可以用三元运算符转换为:
int b = 10;
int a = 0;
a = b == 10 ? 5 : 6
下面是另一个没有其他语句的条件逻辑示例:
int b = 10;
int a = 0;
if (b == 10) {
a++;
}
在DTrace
中:
nt b = 10;
int a = 0;
a = b == 10 ? a + 1 : a
- 使用多个
DTrace
子句和一个谓词。
第一个DTrace
子句将设置第二个子句所需的信息,以查看它是否应该在谓词中执行操作。
例如,假设我们希望跟踪函数开始和停止之间的每个调用。通常,我建议只设置一个DTrace
脚本来捕获所有内容,然后使用LLDB来执行命令。但是如果我们只想在DTrace
中做这个呢?对于此特定示例,我们希望使用以下DTrace
脚本跟踪由-[UIViewController initWithNibName:bundle:]
执行的所有Objective-C方法调用:
#!/usr/sbin/dtrace -s
#pragma D option quiet
dtrace:::BEGIN
{
trace = 0;
}
objc$target:target:UIViewController:-initWithNibName?bundle?:entry {
trace = 1
}
objc$target:target:::entry / trace / {
printf("%s\n", probefunc);
}
objc$target:target:UIViewController:-initWithNibName?bundle?:return {
trace = 0
}
一旦输入initWithNibName:bundle:
,就设置跟踪变量。从那时起,每个Objective-C方法都将显示,直到initWithNibName:bundle:
返回。
1.3 检查进程内存
这可能会让人吃惊,但我们编写的DTrace
脚本实际上是在内核本身中执行的。这就是它们速度如此之快的原因,也是不需要更改已编译程序中的任何代码来执行动态跟踪的原因。因为内核可以直接访问!
DTrace
在计算机上到处都有探测器。内核中有探测,用户区中有探测,甚至有探测使用fbt provider
来描述内核和用户区之间的交叉部分。
下面是一个可视化显示,在的计算机上DTrace
探测的百分比非常小。
通过探索open系统调用和open_nocancel系统调用,将我们的关注范围缩小到数千个探测中的两个。这两个函数都是在内核中实现的,负责任何类型的文件打开,以便进行读、写或两者兼有。系统open
具有以下功能签名:
int open(const char *path, int oflag, ...);
在内部,open
有时会调用open_nocancel
,它具有以下函数签名:
int open_nocancel(const char *path, int flags, mode_t mode);
这两个函数都包含一个char*
作为第一个参数。在使用arg0
和arg1
进行DTrace
探测之前,我们就已经从函数中获取了参数。
我们还没有做的是取消这些指针的引用以查看它们的数据。我们可以使用DTrace
在内存中进行探索,甚至可以在open
系统调用中获得第一个参数的字符串表示。
不过,有一个问题。在内核中执行DTrace
脚本。argX
参数已提供给我们,但它们是指向程序地址空间中的值的指针。由于DTrace
在内核中运行,因此我们需要手动将正在读取的任何数据复制到内核的内存空间中。
这是通过copyin
和copyinstr
函数完成的。copyin
将获取一个包含要读取的字节数的地址,而copyinstr
希望复制char*
表示形式。
对于open
系统调用,我们可以通过以下Dtrace
读取第一个参数:
sudo dtrace -n 'syscall::open:entry { printf("%s", copyinstr(arg0)); }'
例如,如果PID
为12345
的进程试图打开/Applications/SomeApp.app/
,则DTrace
可以使用copyinstr(arg0)
读取第一个参数。
对于这个例子,DTrace
将读取arg0
,arg0
等于0x7fff58034300
。使用copyinstr
函数,将取消对0x7fff58034300
内存地址的引用,以char*
的表示形式获取路径名/Applications/SomeApp.app/
。
1.4 探测open系统调用
用检查进程内存的知识, 创建一个检测系统调用open
函数的DTrace
脚本。在终端中输入下面内容:
sudo dtrace -qn 'syscall::open*:entry { printf("%s opened %s\n",
execname, copyinstr(arg0)); ustack(); }'
这将打印open
或open_nocancel
的内容,以及调用open*
系统调用的用户区堆栈跟踪。
下面将系统调用的open*
函数集中在Finding Ray
进程上。
sudo dtrace -qn 'syscall::open*:entry / execname == "Finding Ray" / { printf("%s opened %s\n", execname, copyinstr(arg0)); ustack(); }'
重新构建并运行程序。堆栈记录现在将会只显示在Finding Ray
应用中的系统调用的任何open*
函数。
通过paths过滤open系统调用
在Finding Ray
项目中,我们对Ray.png
图片做了一些处理,但是不记得是在哪个位置了。好消息是我们可以用DTrace
和grep
找到Ray.png
被打开的位置。 杀掉当前的DTrace
脚本然后添加一个grep
查询, 就像下面这个样子:
sudo dtrace -qn 'syscall::open*:entry / execname == "Finding Ray" / { printf("%s opened %s\n", execname, copyinstr(arg0)); ustack(); }' | grep Ray.png -A40
这将所有的输出传入到grep
并且搜索任何Ray.png
图片的引用。如果搜索到了,则打印出接下来的40行代码。
有一种更优雅的方法可以做到这一点。可以使用DTrace
子句的谓词部分搜索用户区char*
输入中的Ray.png
字符串。我们使用strstr
函数执行这个检查。这个函数接受两个字符串,并返回指向第一个字符串中第二个字符串的第一个匹配项的指针。如果找不到匹配项,则返回空值。这意味着我们可以检查此函数在谓词中是否等于空,以搜索包含Ray.png
的路径!
sudo dtrace -qn 'syscall::open*:entry / execname == "Finding Ray" && strstr(copyinstr(arg0), "Ray.png") != NULL / { printf("%s opened %s\n", execname, copyinstr(arg0)); ustack(); }' 2>/dev/null
构建并重新运行这个程序。去掉了grep
并且用一个条件句来判断Finding Ray
进程中代表的文件路径中是否包含Ray.png
。 此外,我们可以轻松精确地找出负责打开Ray.png
图片的堆栈记录。
1.5 DTrace和破坏性的操作
注意:下面将要展示的操作是非常危险的。我再重复一遍: 接下来的操作是非常危险的。如果搞砸了,可能失去一些最喜欢的图片。
安全起见, 请关闭所有正在使用图像的应用程序(比如, Photos、PhotoShop等等)!!!
我们将使用DTrace
执行破坏性操作。也就是说,通常DTrace
只会监视我们的电脑,但现在我们要改变程序的逻辑。
将监视Finding Ray
应用程序执行的open*
系统调用。如果open*
系统调用,在其第一个参数中包含短语.png
(也称为char*
类型的参数,指向它打开的路径),我们将用另一个png
图像替换该参数。
这都可以通过copyout
和copyoutstr
命令来完成。在本例中,将显式地使用copyoutstr
。我们会注意到这些名字与copyin
和copyinstr
相似。这个上下文中的in
和out
指的是将数据复制到DTrace
可以读取数据的位置,或复制到进程可以读取数据的位置。
在工程目录中,有一个名为troll.png
的独立图像。在Finder中用⌘ + N创建一个新窗口,然后按⌘ + Shift + H导航到主目录。将troll.png
放到这个目录中。
下面我们将要在现有程序中写入内存。程序内存中只有有限的空间分配给这个字符串。这可能是一个长字符串,因为我们在模拟器中,并且进程一般只读取在它自己的沙盒中找到的图像。
还记得Ray.png
的地址吗?这是我电脑上的完整路径。你的显然会不同。
/Users/xxx/Library/Developer/CoreSimulator/Devices/85225EEE-8D5B-4091-A742-5BEBAE1C4906/data/Containers/Bundle/Application/E2E33947-E19D-4873-B493-42479ED45EF4/Finding Ray.app/Ray.png
我们的计划是用一个更短的路径来使用DTrace
读取一个图片,在程序的内存中看起来应该是这个样子:
/Users/xxx/troll.png\0eveloper/CoreSimulator/Devices/85225EEE-8D5B-4091-A742-5BEBAE1C4906/data/Containers/Bundle/Application/E2E33947-E19D-4873-B493-42479ED45EF4/Finding Ray.app/Ray.png
看到这里的\0
了吗?这是char*
的终结符。因此本质上这个字符串仅仅只是:
/Users/xxx/troll.png
因为那就是一个字符串是如何用NULL结尾的。
获取你的路径长度
在写数据时,需要知道完整路径中有多少字符指向troll.png
。
echo ~/troll.png | wc -m
21
open*
中的arg0
指向内存中的某个内容。如果要在这个位置写入比这个字符串长的内容,那么这可能会损坏内存并杀死程序。显然,我们不需要这样做,所以我们要做的是将troll.png
保存在字符数较短的目录中。还将通过DTrace
谓词执行检查,以确保有足够的空间。
sudo dtrace -wn 'syscall::open*:entry / execname == "Finding Ray" && arg0 > 0xfffffffe && strstr(copyinstr(arg0), ".png") != NULL && strlen(copyinstr(arg0)) >= 21 / { this->a = "/Users/xxx/troll.png"; copyoutstr(this->a, arg0, 21); }'
在新的DTrace
脚本激活之后重新构建并运行Finding Ray
应用程序。假如执行是顺利的,那么当Finding Ray
进程每次尝试打开一个包含.png
字符的文件的时候,都会返回一个troll.png
来替代。
其他破坏性的操作
除了copyoutstr
和copyout
之外,DTrace
还有其他一些破坏性的操作需要注意:
-
stop(void)
: 将会冻结当前正在运行的用户进程(通过内部的pid
参数给定的进程)。如果我们想停止执行一个用户进程,并将LLDB
附加到进程上并进一步浏览的时候,这是一个完美的方式。 -
raise(int signal)
: 负责为探针增加一个信号到进程上。 -
system(string program, ...)
:可以让我们像在终端中一样执行一个命令。它有一个额外的好处,可以我们访问所有DTrace
内部的变量,例如execname
和probemod
,用于printf
格式化。
要小心的使用这些系统函数。如果使用错误,真的很容易就造成大量的破坏。