公开 slides 详情:https://xz.aliyun.com/t/13190
]]>申请 https 的可信证书可以提高自己 website 的安全性,之前因为觉得都是静态内容所以没必要用 https,主要就是嫌申请 https 证书比较麻烦. 最近折腾了一下发现,使用命令行的 certbot 可以非常方便的获取 Let’s encrypt 的免费证书.
Github 地址:https://github.com/certbot/certbot
本质上来说,certbot 就是一个 ACME client,这也是 Let’s Encrypt 官网推荐的签发证书的方式,适用于对自己的 domain 具有 shell 访问能力的情况,使用所谓的 ACME 协议来自动化的签发证书,很大程度上简化了证书签发的步骤,
可以从 github 来安装 certbot,但文档中也给出了直接用 docker 启动的方法,还是非常方便的
1 | sudo docker run -it --rm --name certbot -v "/etc/letsencrypt:/etc/letsencrypt" -v "/var/lib/letsencrypt:/var/lib/letsencrypt" -p 80:80 certbot/certbot certonly |
其中 certonly
是获取 cert 的命令,映射 80 的目的是为了一会 ACME server 来访问我们的 VPS 验证所有权.
如果不加任何参数运行 certonly
的话就会进入到下面的交互式界面:
1 | Saving debug log to /var/log/letsencrypt/letsencrypt.log |
选项 1 是让 certbot 自己运行 HTTP server 来通过验证,而选项 2 则是我们自己需要放置 .well-known/acme-challenge/
目录的内容来通过验证.
如果不怕自己 VPS 的 web 服务中断的话,第 1 种方式还是比较方便的.
随后就是填写邮件信息和同意服务协议:
1 | Enter email address (used for urgent renewal and security notices) |
接下来就可以输入自己需要签发证书的 domain 了,这里可以用 ,
或者空格隔开多个 domain,但不能使用通配符 *
.
这时 Certbot 会在本地启动一个 http server,然后通知 ACME server 来访问我们 domain 对应的 website,以验证 domain 的所有权.
1 | Please enter the domain name(s) you would like on your certificate (comma and/or |
到这里就签发完毕了,在 host 的 /etc/letsencrypt/live/example.me/
目录下就生成证书 fullchain.pem
和 privkey.pem
.
然后我们就可以在自己的 web server 配置 SSL 证书了.
用这种方式生成的短期证书有效期是 90 天,在过期之后我们还需要对其进行更新(renew)操作,只需要将上面的命令 certonly
改为 renew
即可,该命令会自动更新 /etc/letsencrypt/live/
目录下有效期少于 30 天的证书.
2021 年的 Defcon 终于落下了帷幕,想到 2021 年到现在都还没有更新 blog,之前队内一直年更游记的”史官” maskray 教授也 “退休” 了,想着还是得有人记录一下队伍的参赛历程。
本次比赛是我作为 Tea Deliverers 成员参加的第 5 届 Defcon CTF 比赛,同时也是主办方 Order Of the Overflow 在位的最后一届 Defcon 了,在全球疫情肆虐的大环境下,不知道这块 CTF 界的金字招牌未来将会往何处去……
初赛结束之后,队长 kelwin 很快召集一些负责统筹的成员开始了决赛的准备工作。
今年除了 TD 去年的老阵容外,加入了长亭的新人 SuperFashi, nan chen 等,还请到了 NeSE 的几位主力选手(afang5472, V1me, kjdy 等)前来参加,实力又进一步增强。
鉴于主办方 OOO 每年的出题人、出题风格都比较固定,kelwin 决定在赛前组织大家进行一下训练和工具准备,以便更好地应对即将到来的决赛。队员们根据技能点的不同被分成 bin 组,koh(King Of the Hill) 组和运维支持组(web 的同学被迫运维),bin 组的同学挑选了一些往年的 challenge 进行逆向、利用的练习,积累经验。运维支持组的同学根据主办方往年提供的记录比赛状态的 json 文件完成一些可视化的工作,以及开发可以自动运行 exploit 的平台。
另外,从往年经验来看,我们的防御方面协作不是很好,往往一个题找到了漏洞之后大家都在调试写利用,而迟迟没有人去 patch. 于是决定今年成立一个 patch 小组,由我来负责,职责是及时跟进每个题目的逆向情况以及是否被 exploit,确保能够及时有人去 patch 漏洞,同时也希望对 patch 进行统一的管理,避免版本过多造成混乱。
在今年的特殊条件下,Defcon 仍然选择了在 Las Vegas 的 Paris 和 Bally 酒店现场举行,CTF 的主办方 OOO 则选择了 hybrid 模式,为队伍提供了线下和线上两种参赛的方式。那么自然而然地,比赛时间也就遵循了 Las Vegas 的时间
转换到北京时间
这个作息对于亚洲时区的选手来说可以说相当 “阴间” 了(夜猫子们除外),下文记述不做特别说明都是北京时间。
从前几年的情况看,由于做题人数有限,而且同一个时间段 active 的题目不会很多,往往出现大家前 2 天都怼在同一道题目上重复消耗大量精力的情况,导致在比赛后期放出的一些相对简单的题目没有同学去做。为了避免这种情况再次发生,我们决定将人员分为 3 波,分别应对 8.7 凌晨,8.7 白天,8.8 凌晨的时间,确保在每个时段都有精力充沛的队员能够 work 在新出的 challenge 上,包括”作业”(round 结尾放出的给大家回去做的题)。
详细赛制可以看官方说明 dc-ctf-2021-finals ,对赛制很熟悉的看官可以跳过这一节。
比赛的轮次叫做 tick,正常为 5 min / tick.
每支队伍的总分由 Attack,Defense 和 KoH 组成,比重分别是 35%, 35% 和 30%. 每个分项会根据所有队伍分数进行归一化,三项的第一名分别是 350, 350 和 300 分,也就是说如果一支队伍三个分项都是第一,总分是 1000 分。
具体到每个分项,attack 是攻击分数,每提交 1 个 flag 获得 1 分。值得注意的是除了正常题目端口,还提供 stealth port,从这个 port 打出的流量不会提供给 victim 队伍,可以用来防止重放,但如果这个 tick 给 stealth port 发了流量,则获得的分数减半,即 1 个 flag 0.5 分。
Defense 为防御分数,每个服务在每个 tick 可以上传一个 patch,如果通过检查则会 deploy,与国内的 CTF 比赛不同的是,deploy 完成的 patch 不会再持续进行 SLA check,所以只需要通过上传时候的检查就可以。如果该 tick 有队伍被打而你的队伍没有被打,则获得 1 分。
KoH 每轮会根据题目不同计算一个排行榜,排名前 5 的队伍将分别获得 10, 6, 3, 2, 1 分,其他队伍不得分。
本来的计划是所有国内队员全部前往北京长亭总部集结,因为线下交流相对来说方便一些也更为高效。
然而没想到 8 月初北京疫情突然紧张,进京的航班被大面积取消,而且需要核酸检测报告,导致在杭州、上海的队员甚至无法前往北京。除此之外,清华的校园管控也很快升级,学生出校变得极其困难,甚至队长 kelwin 都被隔离在家。无奈只好改为线上会议的形式,设置上海长亭的分基地(libmaru, iromise, gwynbleidd, shyoshyo)和清华实验室的分基地(cxm, ziiiro, s0ght 等),以视频连线的形式进行交流。其他没法去基地的同学(f1yyy, gml 等)也线上接入。
8.6 11:00pm,北京这边的同学陆续抵达了长亭,我在调试好硬件设备(接显示器键盘)后,准备看一会题目再回酒店休息。因为根据安排,我属于做“作业”的那一拨,8.6 晚上需要好好休息,8.7 早起应对新放出的题。不过我还是选择留在现场等比赛开始,因为注定一开始会有些状况发生,在现场还可以帮个忙。
8.7 0:00am,开始配置网络。主办方下发了 jumpbox 的 ssh key,通过 jumpbox 连到比赛网络中,cbmixx 配置好了 VPN 让大家接入。1:00am,比赛平台开启,但是非常卡,众人只加载出来一道 KoH —— zero-is-you,这是受 steam 上的 Baba is You 这款游戏的启发开发的一个小游戏,看样子是需要大家进行闯关。过了一会主办方说遇到了网络问题,暂时推迟到了 2:00am 开始,由于没有其他题目,众人就都研究起了这道 KoH 来.
约莫过了 10 分钟左右,正在研究比赛状态 json 解析的 zTrix 突然说,“主办方题目泄露了”. 众人都是一脸懵,原来是在 game state json 的尾部有一些题目的描述信息和下载地址,似乎是之前测试平台时留下的数据,mcfx 迅速爬取了所有题目描述和题目附件,发现全部都是 AWD 的 challenge. 很快平台就无法访问了,json 也下载不到了,但我们估计应该还有其他队伍也发现了这个问题。这样一来就不存在什么分拨和放题顺序的问题了,而我也被迫留下来分析泄露出的 binary.
泄露出的题目总共有 8 个
竟然以这种方式开场,这是我们谁也没有想到的,不知道主办方会怎么处理这个问题,亦或是他们根本就没发现?
1:48am 左右,Zardus 在 discord 上公开了之前的 json 文件,说 PPP 告诉了他们泄漏的事情,为了公平起见,将 json 发给所有队伍。
2:00am 的 captain meeting 上,发量日渐稀少的 Zardus 告诉众人,将要放出 ooopf 和 barb-metal,但还有题目没有泄露,而且泄露的题目也可能会有所改动(看来主办方准备现场改题)
于是上海的同志们分析起 ooopf,这题的情况我不是特别了解,也不再过多说明题目内容了。
4:00am ooows-flag-baby 放出,同时 StarBugs 一血了 ooopf.
我们这边则开始做 barb-metal,是一个接在 bootloader 之后的内核态 elf,功能是用 mrubyc 读入了一段 ruby 字节码并执行,模拟了一个 iot 设备,可以通过一个菜单读取历史温度数据、播放声音等等。本题的 flag 在初始时候被读入到了一个固定地址,Riatre 迅速找到了一个任意地址读的漏洞,但题目的前端是用 websocket 交互的自动化折腾了一会,随后在 5:00am 一血了这个题目,为了避免重放选择了打 stealth port.
然而一血优势没过多久,6:00am Nu1L 开始打非 stealth port,由于比较好重放,此时场上很多队都开始了攻击,此时防守就显得较为重要了。然而防守端比较麻烦,因为 ruby 字节码在读入的时候对签名进行了校验,待到逆完签名校验逻辑(使用的是 e = 3 的公钥)设计好伪造的方法,补完之后又有了新的 exploit 打过来,刚补好新的洞题目就下线了。总体上看这个题表现差强人意,攻防两端得分都是第 1,但由于很多队伍都能打所以一血的优势不太显著。
5:00am 的时候 shellphish 一血了 ooows-flag-baby,7:00am ooows-flag-baby 下线换成了 ooows-p92021.
11:00am 比赛结束,zero-is-you 下线了,主办方说接下来会放出 ooows-ogx 和 ooows-broadcooom. Zero-is-you 的表现不太乐观,被前面的队伍远远得甩在了后面。不过由于 KoH 只占 30%,我们还是排在了第 4 的位置,前面分别是 PPP, Katzebin 和 StarBugs.
ooows 的服务设计的比较有意思,作为云主题的 5 个服务出起来应该花了不少心思,web 界面上可以让用户上传一个 disk 镜像运行,并看到其 serial / vga 的输出
其内部的大致架构如下图所示
绿色的是用户可以控制的输入,即 guest 里的镜像,可以通过 port io / mmio 等于 hypervisor (vmm)交互,这里的 vmm 并非攻击的目标所以给了符号,方便队伍理解它虚拟化的工作机制。每个设备会单独起一个进程去处理,vmm 会为他们设置通信的 socketpair 以及 shared memory,将 guest 的 io 请求转发给他们。flag-baby / p92021 / ogx / broadcooom 都是实现了一个有漏洞的设备,而 hyper-o 则是实现了类似 kvm 的功能,编写了一个有问题的 kernel module,需要通过 guest 去 exploit.
flag-baby 是一个非常简单的用 shell 实现的设备,类似于一个示例,绕过里面的检查即可读取到 flag. 但这个题的交互不太容易,首先需要从实模式开始(即 BIOS 加载完 MBR 跳转过去的第一条指令)给 hypervisor 发送 io 请求来发送数据,实模式能做的事情比较有限也不利于编写更多的功能,需要切换到保护模式。感谢清华大学操作系统课 ucore lab1,让我们可以比较快的作出镜像;然而由于 MBR 的空间限制仅能够写 510 字节的代码,如果实现更多的功能,还另需读磁盘的功能;最后,用户直接访问到的是 web 界面,需要自动化上传镜像,从 serial / vga 读取结果,也有一定的工作量。好在相同架构的题有 5 个,所以写这些工具还是值得的。
8.7 白天时间一部分同学回去休息了,f1yyy,explorer, M4x, nan chen, WangDuo 等人继续分析 p92021,刚起来的生力军则开始分析接下来会出的 ogx 和 broadcooom. p92021 实现的是一个类似 9pfs 的共享文件系统,众人一开始猜测是个目录穿越什么的,也找了一个疑似的地方,结果后来被证明是假洞。不过 f1yyy 真不愧是 UAF 小能手,3:00pm 的时候又找到了一个 UAF,众人于是开始了艰难的风水和占位尝试。
由于调试不太方便,每次需要在 binary 中 patch 一个死循环来等待 attach 很麻烦,我写了一个 python 脚本模拟 vmm 与设备 process 交互的过程。随后 4:00pm 左右回酒店睡了一会,大概 9:00pm 左右回到了现场,此时 M4x, nan chen, WangDuo 等人已经完成了占位过程,但由于被占位的对象中有 string 的指针,在没有 leak 地址的情况下不好伪造,一时陷入了僵局。我考虑了一下 patch 方法,决定直接干掉漏洞点处的 delete 调用,如果能通过检查应该就没什么问题了,传统艺能之 nop free。
8.8 0:45am,主办方在赛前的 captain meeting 上说接下来会放出一个 KoH,ogx,之后是 broadcooom.
Riatre 在 8.7 大部分时间都在为 ooows 做准备,包括编写交互的脚本,准备防重放的工具等等,直到 8.7 很晚的时候才开始看题。而此时 M4x 已经想到可以使用固定地址的 shared memory 来伪造对象,从而伪造文件打开的路径读取 flag,最终还是能够达到目录穿越的效果。但众人很快又陷入了另一重困境之中——无法把 flag 的数据发送出来,原来这之后还有一个大坑,就是需要实现一遍 virtio 的交互过程…… 开赛时仍没有成型的 exploit,riatre 预测说我们开赛要被暴打。
1:00am 比赛开始,10 分钟后,StarBugs 就打出了 p92021 的一血,不过可能是没有自动化的缘故,每轮打的队伍都是零零散散的。之后终于自动化好了,发现仅有我们和 Katzebin 成功防守,也没有其他队进行攻击。整体状况比预想的要好,根据大家防守的情况看,甚至大部分队伍连洞都没有找到。PPP 在这道题上罕见地发挥失常,攻击端和防守端都没有建树,而 Katzebin 则趁机依靠防守的优势反超 PPP 登顶。
1:30am 放出了 ooows-ogx,我和 explorer 接替了白天 cxm,ziiiro 等人的工作继续分析。
3:00am 放出了新的 KoH —— www,刚刚睡下的 KoH 手们被再次唤醒。这是个可以互相在对方的 wall 上涂鸦的服务,如果涂鸦没被举报则获得分数,自己则需要隐藏真实的 IP 以免被别人举报扣分。在整个网络环境中,还有一些存在弱密码可以当做跳板机的 IP. 于是 web 手们来了精神,纷纷开始了内网扫描……
ogx 是一个类似 SGX 的设备,可以输入一段 shellcode 并在隔离的环境中运行,这里使用的主要是 MPK 进行的内存访问隔离,题目中有若干个 enclave 可供上传代码。其中 ogx_enclave_flag.bin
作为初始的代码被加载到 enclave 0 中执行,flag 也被读入到 enclave 0 的内存中。它的逻辑非常简单,就是不断地读取 flag 地址+0x1000 位置的内存并与 0 比较,如果是 0 则 sleep 10s,否则继续循环。从这个场景来看,应该是一个较为明显的 cpu 漏洞的利用,需要我们从隔离的其他 enclave 中将 envclave 0 里的 flag 数据偷出来。比较奇特的是本题 patch 的文件正是这个 ogx_enclave_flag.bin
,而且 patch 字节数的限制是 1 byte.
Riatre 提醒我们研读了一下 cacheout attack,但众人仍不得要领。
3:50am 左右我们的 p92021 exploit 终于 work 了,而且是防重放的实现,所以直接开始打正常的 port. 那么请问此时正在打 stealth port 的 StarBugs 会采取什么策略呢?
4:30am Katzebin 同样开始打 p92021,随后 StarBugs 和 Katzebin 都切换到了非 stealth port,紧接着其他队伍开始了重放。这样看来我们实现了很久的防重放 exploit 价值就没有那么大了,相当可惜。但本题总体表现还是非常不错的,在众队伍都可以打的情况下,收获了很多防御分数。
5:00am ooows-broadcooom 放出。
5:54am StarBugs 率先打满了 ooows-p92021 600 个 flag,题目下线。由于他们打的是 stealth port,所以即使比我们先打了 30 个 tick,攻击分只有 421 分,而没打过 stealth port 的我们也有 343 分的攻击分。
6:19am 左右,StarBugs 一血了 ogx,我们尝试 patch 了好几个地方,包括偏移、sleep 的时间等,似乎都没有完全防御住。随后 Katzebin 也开始了攻击,神奇的是一血的 StarBugs 竟然也没有补住这题。之后从流量中看,题目出现了 unintended 解法,由于 enclave 内部没有限制 syscall,所以直接 open / read / write 外部的 flag 文件即可,所以根本就不可能补住。
11:00am 第2个 round 结束,前4名变成了 Katzebin, PPP, StarBugs 和我们,主办方在 captain meeting 上说过一会将在 discord 上发出明天的两个新题 ooows-hyper-o 和 shooow-your-shell (KoH). 这样看来非常奇怪的 cooorling 将不会出现了。
除此之外,主办方还宣布了这是他们最后一届举办 Defcon ctf 的消息,一直以来总是被人吐槽的 OOO 真的要 “下课” 了,一时竟还有些不舍(xxxxx综合症么)是怎么回事。
我在 8:00am 左右跑到公司的沙发上躺了,直到快中午 12 点才起来和 f1yyy、explorer 等人开始分析 broadcooom. 这个题目从 8.7 晚上的时候就已经有同学开始在 work 了,但直到现在仍然还有很多逻辑未能弄清,需要一点点逆向。主 binary net
模拟了一个网卡设备,同时在处理的时候还使用了自己实现的一套 VM,实际的处理逻辑是位于 net-firmware
固件中,同时需要 patch 的也是这个固件文件。结合大爷的 idb 以及 afang5472 编写的初步 disassembler,我们逐渐理清了从 virtio 进来的网络数据包处理到发送给 MQTT 后端消息队列的数据流向,并对 net-firmware
里的处理逻辑有了基本的认识。
大约 4:00pm,我转向了 hyper-o,这道将在最后放出的 ooows 系列题目是一个 kvm 类似的内核模块,使用 vmx 指令来创建 VM,并读入 shellcode 执行。Discord 上放出的 hyper-o.ko
文件已经与之前泄露出的版本有很大的不同,去掉了一些功能,只留下了基本的映射内存,执行等,IO 部分的处理也简化了。外层则是一个简单的 vmm,直接从 stdin 输入 shellcode,主办方说这只是个临时的版本,正式开赛后会换成新的,但 hyper-o.ko
不会变。
比较蛋疼的是我本地老旧的 Ubuntu 16.04 似乎无法在应用了 kvm 的 qemu 里使用 vmx 指令,其他一些队员也出现了这种情况,可能与 kvm 和 qemu 的实现有一些关系。
8:20pm,远程支援的 equation314 在 hyper-o.ko
中找到了漏洞,可以通过操作 guest 内存来更改 EPT Pointer,从而伪造 EPT 页表任意读写 Host 的地址空间。大概 10:00pm 的样子,broadcooom 那边也找到了一个可以越界写改掉 firmware 代码的 bug,正在紧锣密鼓地研究如何触发以及编写 vm 代码的 shellcode.
然而,最后冲刺的 round 已近在咫尺。
1:30am 最后一个 round 开始,我们的 KoH 选手顿时活跃起来,投入到 shooow-your-shell 的博弈中。
这又是一个编写 shellcode 的 KoH 游戏(为什么要说又?),需要编写一段读取 /secret
文件内容并输出的 shellcode,在给定的 3 个架构 x86_64
, aarch64
和 riscv64
任意一个上成功即可。如果你的 shellcode 比别人长度短,或者使用的字符集合比别人小,则可以成为 king of the hill,同时上一段 shellcode 中没有被使用的字符(即 set(previous_shellcode) - set(shellcode)
)将被禁掉。
当一支队伍连续 900s 霸榜后,这次提交被标记为 winner,游戏将被重置。新的游戏将从之前所有的 winner 提交中随机抽取一个字符集作为禁止的字符集。基于这个设定,我们优化的目标并非只有将 shellcode 缩到最短或者字符集最小,还需要考虑对手的策略,可以故意提交一些使用字符比较多的 shellcode 以禁掉关键字节(例如 syscall
)。
根据我之前参加 seccon (最早采用 King Of the Hill 赛制的比赛之一)的经验,很多时候都可以利用 shellcode 的运行环境获得更多的能力。例如本题的 shellcode 是使用 qemu user mode 启动,并在一个静态链接了 libc 的 binary wrapper 中被读入,然后直接跳过去执行的。这就意味着 shellcode 执行时可以利用静态链接的 libc 代码,使用 rop 的方式去完成功能。afang5472 利用 add / pop / ret 构造出了字符集仅为 3 的 shellcode,作为杀手锏。KoH 小组的其他成员编写了搜索的脚本,用于在有限字符集中搜索可能的解,同时思考了很多奇葩战术策略。
不得不说每次 KoH 都能整出点新花样(事故),之后的比赛中这题状况频出,但由于当时 bin 组的成员正为 2 个老大难的 challenge 焦头烂额,所以对玩的不亦乐乎的 KoH 题目并未关注过多。具体可以参考 StarBugs 这位老哥的回顾 https://github.com/qxxxb/ctf/tree/master/2021/def_con_finals.
与此同时,broadcooom 和 hyper-o 的进展并不顺利。
最后一天的比赛照惯例隐藏积分榜,每个 tick 的时间缩短为 2.5min,这让我们难以获知自己的 patch 是否奏效了,只能从流量中大概搜索一下。开赛后马上 StarBugs 就一血了 hyper-o,但他们打的是 stealth port,没有流量可以分析。
Riatre 一上来传了一个 broadcooom 的 patch,被判定 SLA_FAILED 没有通过,过了一段时间再传一个同样了就 ACCEPTED 了,非常谜。
hyper-o 这边,我基本已经理清了整个利用的链路,然而一开始就写好的 bootloader 却失效了,cbmixx 尝试调了很长时间,发现似乎是 lgdt
指令没能执行成功,无法切换到保护模式,后面的利用就没法进行了。
2:30am 左右我发现已经有攻击流量了(其实已经有好几个 tick 有攻击流量了,没盯紧),并从其中找出了一段 payload,M4x 和 nan chen 测试后发现可以从最终寄存器的值中提取出 flag,遂开始准备重放。然而等到重放将要完成时,主办方宣布 hyper-o 题目部署更新,回到了之前 ooows 的界面形态,这样之前的 exploit 就不太能用了,还是得输出到 VGA.
2:43am Katzebin 一血了 broadcooom.
4:56am StarBugs 又再次一血了新的 hyper-o.
我们仔细分析了之前 hyper-o 的流量,发现里面有很多的 \x16
字符,原来是因为在终端里输入需要转义的缘故,而 payload 中正好含有 \x16
于是就被吃掉了,欲哭无泪。
5:30am,比赛结束,我们这个 round 的攻击分为 0,防御分未知,表现非常差,大家都认为这下要跪了。
比赛一结束已经是周一的凌晨了,队内很多打工人收拾收拾就准备去上班,我则请了一天假,上午在酒店休息了一下等待结果。
最终排名如下
居然最后还反超了 StarBugs 来到了第 3 着实是令人感到意外,似乎是他们的 broadcooom 没有防住导致的(这题防御需要写 vm 的代码,并不容易),因为攻击分相对来说比较卷,总分上体现的没有防御分那么重要。
从事后 service 的统计来看(https://scoreboard.ooo/services.html),我们的 broadcooom 防御立了大功,而 hyper-o 却没有补住,看来还有 bug 是我们没找到的。之前发现的流量应该是 HITCON 的,他们没有打 stealth port. 不过本题大家都没有补住,所有队伍的防御分都比较相近。
总结一下整场比赛下来的感受
关于主办方 OOO,我们能明显看到这几年他们在改进赛制和题目质量上的努力(虽然还是经常出包),总体来看还是在往好的方向发展的。不知道下届主办方会交给谁,有人说 PPP 比较合适,大家调侃那就变成亚洲 CTF 了。确实亚洲队伍的强势在这几年特别显著,毕竟大家的重视程度不同。不管怎么说,还是希望 DEFCON CTF 这个曾经众多 CTF 选手的初心能够一直高质量地办下去,也希望更多的国内队伍能闯入决赛吧。
下面是一些大佬们的回忆,可以从不同视角来了解这次比赛
ooows 的官方 github repo
]]>在最近的 CTF Kernel Pwn 中,使用内核的 userfaultfd 机制来进行稳定 race 频率越来越高了,而我之前在做题时大都按照 man page 中的例子和一些 API 文档来猜测其用法。在 SECCON 2020 结束后,还是想从源码层面研究一下其内部机制是怎样的,这样可以在之后的应用中少踩一些坑。
在 SECCON 2020 的 kstack 这道 kernel pwn 里,使用了 userfaultfd + setxattr 的所谓“堆喷射”技术,也就是这篇 blog 里介绍的技术,个人觉得使用 heap spray 的说法不是特别准确,在本题中这种技术就是用于在 double free 之后来完成对 UAF 对象的占位篡改,并不仅仅可以用于做堆喷。
说回正题 userfaultfd,这是 kernel 中提供的一种特殊的处理 page fault 的机制,能够让用户态程序自行处理自己的 page fault.
它的调用方式是通过一个 userfaultfd 的 syscall 新建一个 fd,然后用 ioctl
等 syscall 来调用相关的API. 该机制的初衷是为了方便虚拟机的 live migration,其功能还处在不断改进和发展中,文档和资料都不是很多。
这里我主要以 v5.9 内核源码为基础,结合2个 man page(参考文末的链接) 来进行说明。
文末的引用中有一个讲得比较好的 slides,里面有张图可以很好地说明其工作流程
左侧的 Faulting thread,mm core,userfaultfd 是属于同一个(内核)线程,右边的 uffd monitor 是属于另一(内核)线程,它们在用户态应该表现为共享地址空间的2个线程。
在开始时,faulting 线程读取了一块未分配物理页的内存,触发了page fault,此时进到内核中进行处理,内核调用了 handle_userfault
交给 userfaultfd 相关的代码进行处理,此时该线程将被挂起进入阻塞状态。同时一个待处理的消息 uffd_msg
结构通过该 fd 发送到了另一个 monitor 线程,该线程可以调用相关 API 进行处理 ( UFFDIO_COPY
或 UFFDIO_ZEROPAGE
)并告知内核唤醒 faulting 线程。
从这个例子中我们能看出这里面涉及到2个线程之间的交互,我们也不能免俗地要介绍一下具体用法,阅读 userfaultfd
man page 里给出的例子,里面大概分为几步:
由于 glibc 没有对应的 syscall wrapper,直接使用 syscall 函数分配。
1 | /* Create and enable userfaultfd object */ |
1 | /* Register the memory range of the mapping we just created for |
在一个 for 循环中,不断使用 pool 来等待这个 fd ,然后读取一个 msg,这里读取的 msg 就是 uffd_msg
结构。
1 | for (;;) { |
读一下该区域的内存即可
调用 UFFDIO_COPY
为新映射的页提供数据,并唤醒主线程,子线程自身会进入到下一轮循环中继续 poll 等待输入。
1 | /* Copy the page pointed to by 'page' into the faulting |
我最感兴趣的有几个地方,一是内核如何标记需要user fault处理的内存页的,二是当 page fault 发生时内核如何交给用户态处理,三是那个页映射是何时又是怎么恢复的。
涉及 userfaultfd 处理的主要有以下几个文件
fs/userfaultfd.c
:主要逻辑都在该文件中mm/userfaultfd.c
:一些跟页表相关的底层函数mm/memory.c
:通用的处理 page fault 的代码mm/mremap.c
/ mm/mmap.c
:处理 mremap 和 mmap 的代码,本文暂时不研究既然是一个 fd 那么就应该有实现文件操作的接口,理所当然的我们在 fs/userfaultfd.c
中找到了其实现
1 | static const struct file_operations userfaultfd_fops = { |
其中主要的控制接口就是 ioctl,实现了若干个 cmd
1 | switch(cmd) { |
当我们调用 UFFDIO_REGISTER
时,内核进入到 userfaultfd_register
函数,首先检查对应 vma 的合法性
1 | ret = validate_range(mm, &uffdio_register.range.start, |
这里要求地址得在用户态的范围内,而且要在 task 的 mm 结构中能找到,换言之,这应该是已经分配了虚拟地址的 page.
那么如果在这样的页上发生了 page fault,这个 page 应该是 已经mmap映射,但还未分配实际物理内存 的 page.
回想 man page 中的例子,在 mmap
成功后立即进行了 register 操作,没有对该区域进行过读写。
这也告诉我们,想对没有映射虚拟地址的 page 进行 register 是不行的。
在这之后,内核将对应的 vma 添加一个flag(VM_UFFD_MISSING
或 VM_UFFD_WP
)
1 | vm_flags = 0; |
代码中还包括其他对 vma 的检查处理,这里不再赘述。
从前面的图中我们知道内核处理 userfault 的核心函数是 handle_userfault
,当发生 page fault 时,在内核中的调用链如下(x86架构)
exc_page_fault
-> handle_page_fault
(v5.9中 page fault 的入口点) / page_fault
-> do_page_fault
(低版本的入口点),它们位于 arch/x86/mm/fault.c
do_user_addr_fault
handle_mm_fault
(mm/memory.c
)handle_pte_fault
do_anonymous_page
(这里看到 userfault 仅能处理 anonymous page,4.11之后还能支持 hugetlbfs 和共享内存)handle_userfault
(fs/userfaultfd.c
)在 handle_userfault
中,默认的返回是 VM_FAULT_SIGBUS
,如果该 fault 不是第二次发生 (有 FAULT_FLAG_ALLOW_RETRY
的标志位),则返回值被改为 VM_FAULT_RETRY
1 | vm_fault_t ret = VM_FAULT_SIGBUS; |
在这之后,内核将该 fault 线程挂起,将这个事件加入到 ctx->fault_pending_wqh
这个 wait queue 中,这会使得 userfaultfd_poll
和 userfaultfd_read
返回给 monitor 线程对应的消息结构,并等待处理。在处理完成后,再将线程状态设为 TASK_RUNNING
并返回。
该API用于向以分配的 page 中预先写入数据,这个写入操作位于原 fault 线程读写数据之前。
在 userfaultfd_copy
中,会调用 mcopy_atomic
(mm/userfaultfd.c
) 分配物理内存,并将用户提供的数据 copy 过去,最后调用 wake_userfault
唤醒 fault 线程。
直接调用 wake_userfault
唤醒 fault 线程。
这里主要讨论几种特殊情况,都是我在做题过程中进行的一些奇怪的尝试。
第一次page fault会返回 VM_FAULT_RETRY
, 之后就看具体内核的处理方式了。
有的版本内核 VM_FAULT_RETRY
之后会清除 FAULT_FLAG_ALLOW_RETRY
标志位,回到用户态之后再次触发 page fault,此时 handle_userfault
会返回 VM_FAULT_SIGBUS
导致进程收到 SIGBUS 信号终止。
而不巧的是我选择的目标 v5.9 版本的内核是直接在 do_user_addr_fault
中直接 goto 到之前的位置重新处理,但没有清除这一标志位,这就直接导致了一个死循环。
因为在 retry 时,该 vma 上用于 userfault 的标志位已经清除,所以将由 kernel 自行处理。
Obviously, 会产生死循环。
对于 kernel pwn 来说,userfaultfd 可以非常有效地控制 race 的顺序,通过与 setxattr syscall 的配合甚至可以完成对(几乎)任意大小的内核对象进行占位,确实是当前内核选手必须要掌握的一项技术。而由于其代码迭代比较频繁,有一些诸如 UFFDIO_REGISTER_MODE_WP
(写保护)的模式并非所有版本的内核都支持(当前的 man page 还声明尚未支持),还有对 shared memory, mmap/mremap 的处理也没有进一步讨论了。有兴趣的同学可以参考源码了解一下。
随着大家对glibc内存管理机制研究的深入,越来越多的heap master涌现出来,导致在pwn领域你不对 2.23~2.29 每个版本的glibc了若指掌都不好意思说自己玩过堆。这也使得国内很多CTF的堆题更多的是流于形式和trick比拼,内卷严重。因此,我的兴趣逐渐转移到了更加贴近真实环境的kernel和虚拟化上。于是,内核的heap成为了新的战场……
工欲善其事,必先读源码。每每有人问我如何搞好glibc的堆,我都让他们去下一份glibc的源码,打开 malloc.c
好好看一遍。尽管本着搞CTF要把知识吃透的原则,读一遍内核堆实现的源码才是修炼内功的唯一途径。不过从目前CTF的题目来看,也duck不必,一方面赛棍们在这块的研究并没有太过深入,另一方面相关资料还是很多的,找两篇blog看一下就能明白原理了(譬如本文). 至于以后CTFer会不会把内核堆玩成像glibc一样“5年堆题,3年模拟”,只有期待下一个 angelboy 的出现了。
对于内核堆来说,只需要了解分配大内存的Buddy System和分配小内存的Slab(Linux针对原始的Slab算法进行了优化,开发了新的SLUB算法,该算法是内核堆内存分配的默认算法,下文不特别说明介绍的就是SLUB算法)。其实从国内这些年出的题来看,懂一点Slab就可以走遍天下都不怕了。不过前阵子的Defcon Quals中的kernel题目就牵涉到了buddy的一些内容,这使得我有了系统梳理一下的动力。鉴于网上介绍原理的资料确实相当多,我就精简一下内容,着重讲解一下基本的性质、相关API以及在CTF中的应用。
伙伴系统,专门用来分配以页为单位的大内存,且分配的内存大小必须是2的整数次幂。这里的幂次叫做 order
,例如一页的大小是4K,order为1的块就是 2^1 * 4K = 8K
。每次分配时都寻找对应order的块,如果没有,就将order更高的块分裂为2个order低的块。释放时,如果两个order低的块是分裂出来的,就将他们合并为更高order的块。
我们用wiki中的例子就可以很好地说明了。
这个例子中,分配的最小单位是64K,初始时的最大块order=4. 依次进行下面的操作
注意这里合并的时候,被合并的邻居得是之前分裂出来的伙伴(Buddy),这也是该算法的由来.
在Linux中,使用buddy system分配的底层API主要有 get_free_pages
和 alloc_pages
,传入的参数都是order,还有一些flag位.
值得注意的是这样分配得到的虚拟地址和物理地址都是连续的,返回的地址可以使用 virts_to_phys
或者 __pa
宏转换为物理地址,实际操作也就是加上了一个偏移而已。
可以通过 /proc/buddyinfo
和 /proc/pagetypeinfo
来查看相关的情况.
这张图可以说是介绍slab的文章中出现频次最高的了,我们只要记住,kmem_cache
是类似于glibc arena的结构,每个kmem_cache
由若干个slab构成,每个slab由一个或多个连续的页组成。kmem_cache
有一个重要的性质,就是其中所有的object大小都是相同的(准确的说是分配块的大小都相同).
我们借助linux的 /proc/slabinfo
来说明,也可以使用 slabtop
工具来查看slab分配的状态。
1 | # cat /proc/slabinfo |
这个文件列出了目前所有的 kmem_cache
,第一列是每个mem_cache的名字,我们拿 kmalloc-64
来做说明
所以我们可以看出,kmalloc-64
这个mem_cache,每个slab有1个page也就是4K,每个对象是64B,所以每个slab能容纳的对象是 4K / 64B = 64
个. 如果分配了object数量超过了64个,就需要从别的slab分配,如果分配的对象超过了47808个,就需要申请新的slab,也就是向buddy system申请新的内存页.
Linux中的一些常用内核API.
1 | struct kmem_cache * kmem_cache_create (const char *name, |
创建mem_cache,需要指定name和size.
1 | void * kmem_cache_alloc (struct kmem_cache * cachep, gfp_t flags); |
在mem_cache中分配object,这里不需要指定size因为在创建时就已经指定好了.
1 | void kmem_cache_free (struct kmem_cache * cachep, void * objp); |
在mem_cache中释放object.
1 | void * kmalloc (size_t size, gfp_t flags); |
分配size大小的对象,会在 kmalloc-xxx
这些特殊的mem_cache里找到一个适合的进行分配.
如果size超过了最大的kmalloc mem_cache,比如上面那个slabinfo里最大的是 kmalloc-8192
,如果分配超过8192 bytes的话,还是会调用底层API直接向buddy申请内存.
1 | void kfree (const void * objp); |
释放对象 objp
,实际会先找到其所在的page,然后读取page结构中指向其所属slab的指针,进而放到对应的freelist(单链表)中,并将指向freelist中下一块的fd指针写到块的头部.
最为大家所熟知的利用就是改fd了,因为freelist是单链表结构,且没有检查,类似于glibc中的tcache,基本就是指哪打哪的节奏。
基于freelist LIFO的性质UAF漏洞可以用相同大小的结构占位的方式来改一些指针,这里不再赘述。
这次Defcon Quals中的keml,是在 get_free_pages
分配的堆块上产生的溢出。看似使用buddy分配的内存溢出之后覆盖不到什么对象,实则不然,当耗尽某个mem_cache后,其会向buddy申请新的内存页作为slab,这就有机会将slab的内存页放置到有漏洞堆块的后方,改到slab中的内容了。而且由于buddy分配内存连续的性质,不同mem_cache的slab完全有可能会交错在一起,给内核堆风水带来了新的可能性。有兴趣的读者可以尝试解一下该题(文末引用中有链接).
内核堆上的利用相对来说较为简洁,套路不多,这得益于内核中数量繁多的对象和slab分配器的简单实现。
相较于glibc众多 house of
的层出不穷,内核中只要控制了fd,直接去改掉 modprobe_path
就能够以root权限执行命令,简单粗暴。
不知道Linux kernel会不会考虑针对堆管理增加一些防护,也许内核堆攻防博弈的时代即将到来 :)
QEMU作为一款emulator进行模拟的主要方式是binary translation,将目标代码转换成TCG IR再转换成宿主机的代码执行,于是在中间TCG生成时就可以通过插入一些代码来完成插桩的任务。而要完成这一任务首先我们得知道如何在TCG中插入一个helper.
TCG全称是Tiny Code Generator,实际规定的操作并不多。为了能够实现比较复杂的CPU功能,除了JIT出的宿主机代码本身外,qemu自身还带有一些与相关架构关系比较紧密的函数供JIT的代码调用,这部分函数代码就是helper函数。
以QEMU 4.2.0版本为例,对x86指令进行翻译的代码位于 target/i386/translate.c
,7235行
1 | case 0x105: /* syscall */ |
这一段是对syscall
指令的翻译,其中有一条 gen_helper_syscall
函数调用,该函数会在tcg代码中插入一条call的backend-ops,目标是 helper_syscall
函数。该函数位于 target/i386/seg_helper.c
中
1 | void helper_syscall(CPUX86State *env, int next_eip_addend) |
所以当程序执行到syscall指令时,就会进入到 helper_syscall
函数中,该函数根据CPU的状态,寻找syscall的入口点,并将eip设置过去,进入内核态执行。
如果类比 PIN 的话,gen_helper_syscall
就相当于 INS_InsertCall
,是在翻译过程中使用的;而 helper_syscall
则相当于分析函数,是在运行时使用的。
举个例子,我们想为x86加入一个helper函数,首先需要修改 target/i386/helper.h
,为syscall的helper定义如下
1 | DEF_HELPER_1(sysenter, void, env) |
之后需要在某个位置(target/i386/helper.c
或target/i386/seg_helper.c
等)实现 helper_syscall
函数,而且参数需要匹配使用 DEF_HELPER_2
宏的定义。这里 DEF_HELPER_2(syscall, void, env, int)
的2表示函数有2个参数,syscall
是helper的名称,void
是返回类型,env
与int
是2个参数的类型,这与helper_syscall
的定义也是相符的。
QEMU的helper实现中,有时会直接用 helper_syscall
的形式,有时会借助 HELPER
宏,写成 void HELPER(syscall)
的形式,二者的效果是一样的。
通过上面的分析我们知道,要添加一个helper需要实现两个函数 gen_helper_xxxx
和 helper_xxxx
(xxxx是helper的名称),而仅仅在 helper.h
中添加一行的定义就声明了两个函数,这是如何做到的呢?
我们看 translate.c
的头部29-30行
1 |
在 exec/helper-proto.h
中
1 |
首先定义 DEF_HELPER_FLAGS_N
的宏,这些宏展开后就能够声明 helper_xxxx
,接下来再包含 helper.h
就完成了它的声明。在文件结束时使用 #undef
再将这些宏给取消了。
接着 exec/helper-gen.h
中
1 |
这里又将 DEF_HELPER_FLAGS_N
展开为了 gen_helper_xxxx
的定义,并且在直接实现了该函数,使用 tcg_gen_callN
来插入对helper函数的调用。下面再次包含了 helper.h
,这就完成了2个函数的定义,然后用户自己再实现 helper_xxxx
就可以了。
值得一提的是,上面分析仅仅是x86的helper函数,每个架构都有自己的 helper.h
。如果想添加所有架构通用的helper函数,可以在 tcg-runtime.h
中添加,位于 accel/tcg/tcg-runtime.h
.
另外,tcg_gen_callN
是在 tcg/tcg.c
中实现的,这个函数在开头会从一个helper的hashtable来获得相关的信息
1 | void tcg_gen_callN(void *func, TCGTemp *ret, int nargs, TCGTemp **args) |
这个hashtable是在tcg初始化的时候填的,在同一文件中
1 | static const TCGHelperInfo all_helpers[] = { |
又包含了 exec/helper-tcg.h
,不出意外地这个header跟前面同样的套路,只不过这次是展开成数组元素。
1 |
这就意味着,如果自己定义和实现了 helper_xxxx
和 gen_helper_xxxx
(没有在 helper.h
或者 tcg-runtime.h
中声明),并想用 tcg_gen_callN
来生成调用helper代码的话,就会因为在hashtable中找不到对应的helper而导致QEMU崩溃。
QEMU在tcg helper这块的设计还是挺trick的,本来C工程的原则是得避免同一个header包含多次,这里反而利用了这一点来进行多样化的声明,有点意思。
然而学会了插入helper才只是插桩之路的第一步,接下来还得深入了解TCG的实现机制和有关的函数,才能定制化自己的分析功能。
最近在CTF中QEMU逃逸题目出现的频率也越来越高了,在这里记录一下常见的攻击面和进行交互的方式。对于虚拟化逃逸来说,其实就是打hypervisor这个用户态的程序,所以在明白交互的原理之后流程就和一般的pwn差不多了。
这应该是最常见的一种形式了,在qemu中新实现一种设备并引入漏洞,guest通过与设备进行交互进行漏洞利用。
在host上使用下面的命令来查看qemu支持的设备
1 | ./qemu-system-x86_64 -device help |
也可以boot到guest之后使用lspci -nvv
来查看pci设备的具体信息。
Linux kernel与设备通信有PIO, MMIO, DMA等形式,通常是kernel会实现一套driver在内核态与device交互,再开放API供用户态程序使用。
但其实在用户态也是可以直接访问到设备的,这就意味着我们可以直接编写用户态的程序来进行交互了。
PIO可以算是最原始的一种模式了,CPU执行in
/out
指令来向I/O端口发送数据。cat /proc/ioports
能够看到目前port的一些信息和它们对应的设备。
in
/out
指令是特权指令,一般只有在ring 0时才能执行,linux内核提供了 inb
/ outb
/ inw
/ outw
/ inl
/ outl
,用于向port发送不同长度的数据。
不过在linux中可以通过 iopl
ioperm
这两个系统调用对port的权能进行设置。
1 |
|
ioperm
可以将from
之后的num
个port打开供用户态程序使用,当在用户态执行in
/out
对这些port进行读写时不会触发fault.
这个调用需要进程是root权限或者具有CAP_SYS_RAWIO
权能。
另外,还可以通过打开/dev/port
这个文件,再lseek
到对应port的offset进行读写,同样可以实现PIO.
如果没有/dev/port
文件,可以使用 mknod -m 660 /dev/port c 1 4
来创建。
CTF中的例子可以参见SECCON 2018的q-escape
另一种进行交互的方式是使用MMIO,CPU将一部分设备的寄存器映射到一段特殊的物理内存地址,当进行访存时就相当于对这些寄存器进行操作。
cat /proc/iomem
能够看到与I/O相关的一些内存地址信息。
通过kernel提供的sysfs,我们可以直接映射出设备对应的内存,具体方法是打开类似 /sys/devices/pci0000:00/0000:00:04.0/resource0
的文件,并用mmap
将其映射到进程的地址空间,就可以对其进行读写了。这里的设备号0000:00:04.0
是需要事先在/proc/iomem
中看好的。当映射完成后,就可以对这块内存进行读写操作了,内存读写会触发到qemu内设备的mmio处理函数(一般会叫xxxx_mmio_read
/xxxx_mmio_write
),传入的参数是写入的地址偏移和具体的值。
在qemu这边,需要对这块内存区域用memory_region_init_io
进行注册,传入MemoryRegionOps
结构,提供相应的读写处理函数。
另外,还可以通过打开/dev/mem
这个文件直接操作物理内存,mmap
设备对应的地址进行读写,也是相同的效果。
如果没有/dev/mem
文件,可以使用 mknod -m 660 /dev/mem c 1 1
来创建。
这里有一个我经常使用的交互模板
1 |
|
8会:)
hypercall是guest kernel向hypervisor发出的一种调用形式,广义上说像vmtools的RPCI机制使用特定I/O端口实现的也可以称之为hypercall. 还有一种方式是通过一些特权指令,例如 vmmcall
.
即使不在kvm模式(KVM的hypercall实现可以参考我之前的一篇abyss writeup),QEMU也仍然可以通过TCG实现hypercall,具体来说就是在进行binary translation时,为vmmcall
定制helper函数,完成特定的功能就可以了。
此次XCTF Final中的QTCG就是这样实现的,但这样一来我们需要直接执行vmmcall
指令,这如果在用户态执行会产生fault,所以我们就在内核态用kernel module来完成利用。
编写一个可以进行简单交互的kernel module pwn.c
1 |
|
再编写一个对应的makefile,因为kernel module与linux的内核版本是紧密相关的,根据kernel的文档,在有kernel源码的时候,可以直接使用Kbuild的Makefile来build. 当然kernel源码也需要事先build一些东西,例如 make modules_prepare
,具体可以参看文档。
1 | obj-m := pwn.o |
通过指定 obj-m
变量告诉Kbuild我们build的文件名pwn
,在执行make
之后,会生成对应的pwn.ko
.
对于当前运行的内核进行编译,makefile可以这样写(需要安装kernel headers包).
1 | obj-m := pwn.o |
完成hypervisor的漏洞利用对只做过用户态pwn的同学来说是一个比较大的考验,不仅要掌握hypervisor这边的一些实现原理,更需要对guest内部的kernel工作机制有所了解,而这其实也正是计算机系统最底层的知识。
如果在这方面基础薄弱(计原,操统没好好学)的话,还是应该补补课,算是系统安全的基本素养之一。
将虚拟化逃逸和kernel pwn放在一起来学习,也是一个不错的选择。
在我的上一篇介绍Pin的文章中使用的Pin还是3.4版本,而写本篇文章时已经是3.11版本了,这期间在使用Pin的时候也遇到了一些环境上的问题,在这里总结一下.
目前Intel Pin官网上的版本已经到3.11了,不过之前的一些版本还是可以下载的,只是找不到下载的入口。
目前Pin 2.x的最高版本是2.14,估计不会再更新了。2.x到3.x有较大的改动,这使得一些旧的在2.x上编译的项目可能不太好直接应用在3.x上。主要的变化有下面2点
理论上来说,Pin 2.x版本并不能支持4.x以上的linux kernel. 如果直接在高版本内核的系统中运行,会直接报下面的错误:
1 | E: 4.0 is not a supported linux release |
但pin居然有一个隐藏的开关 -ifeellucky
可以绕过这个检查,强制启动程序,真的是非常神奇……
加了这个开关之后也可能会出现问题,之前我在64位系统上对32位程序进行插桩时,就出现过报错的情形:
1 | A: Source/pin/vm_ia32_l/jit_region_ia32_linux.cpp: XlateSysCall: 33: Sysenter is supported on IA32 only and the expected location is inside Linux Gate |
这个错误可能是pin对于64位系统上sysenter
这种系统调用的方式不支持导致的,关于使用sysenter
进行系统调用的机制可以参考《深入理解Linux内核》这本书第十章”系统调用”的内容,之后可能会考虑总结一下。对于这种情况,也有一个workaround,就是禁用VDSO,使用native的int 0x80进行32位系统调用,方法是在内核启动时指定参数-vdso32=0
,不过这样的话系统调用的效率也会降低。
3.x与2.x还有一个比较大的改动就是其使用了自己实现的CRT,相关内容可以参考文末给出的文档链接。主要的影响有:
-l
选项,之后也是不会链接到指定的library的,目前还没有找到好的解决方案。但静态链接第三方的库是可以的。std::array
.所以如果自己的Pintool需要第三方动态库的支持的话,可以考虑用回2.14版本,或者将代码静态编译到Pintool当中。
如果手动编译3.x的Pintool,需要手动添加很多编译链接选项,不仅繁琐而且容易出错,所以推荐使用Pin自身的Makefile功能。参考一下 User Guide 中 makefile 的部分,应该很容易就能够理解了。
首先需要指定一下pin自身的目录位置 PIN_ROOT
makefile会自动去这个目录查找默认的makefile. 参考 ./source/tools/MyPinTool
目录下makefile
和makefile.rules
的配置。makefile
不需要修改,主要的配置都是在makefile.rules
中进行的。
如果是在Linux下编译pintool,主makefile会依次包含makefile.config
, makefile.unix.config
, unix.vars
, makefile.rules
, makefile.default.rules
. 其中前3个文件包含了一些flags和系统相关的定义,makefile.rules
是我们自己定义的build target和一些规则,makefile.default.rules
是默认的编译规则。
将./source/tools/MyPinTool
目录下的makefile
和makefile.rules
拷贝到自定义的目录下,新建一个自己的pintool文件test_pintool.cpp
,然后修改makefile.rules
里面的TOOL_ROOTS
变量。
1 | TOOL_ROOTS := test_pintool |
然后直接运行make
,默认的编译规则会自动寻找test_pintool.cpp
文件,并将编译好的pintool test_pintool.so
放置在obj-intel64
目录下. 如果编译32位的,就指定make TARGET=ida32
即可,pintool会被放置在obj-ia32
目录。
除了TOOL_ROOTS
还有APP_ROOTS
和TEST_ROOTS
,分别对应非Pintool的app和相关测试程序。
在makefile.rules
中的Build rules
这一节我们可以定义一些自己的编译命令,例如通过修改TOOL_CXXFLAGS
来改变编译pintool时候的选项,修改TOOL_LIBS
能够改变链接时候的选项,例如我们想链接一个静态库,就可以这样
1 | TOOL_LIBS += -lxxx |
类似地,我们可以定义自己的编译规则,在user guide中有这样一个例子
1 | # Build the intermediate object file. |
这是将多个.cpp文件编译成.o然后链接成一个pintool的例子,对于大型的项目来说很有用。OBJDIR
也是可以更改的。
Pin 3.11 User Guide
Intel Pin Tool on Linux 4.0
PinCRT Overview
简单记录一下一些GCC的特性、开关选项在CTF中的一些应用,有时候还是能给做题带来一些便利的。主要是与shellcode编写相关。
有些时候在写C程序时需要直接编写汇编来实现一些底层的功能,比如执行一些特殊的没有API的指令等等,这时候就需要用到GCC内联汇编的功能了。
内联汇编语句一般以asm
开头,可以使用volatile
关键字来禁止编译器进行优化,一个简单的内联汇编片段如下
1 | int a=10, b; |
这段汇编的作用是将变量a的值赋给b,此时b作为输出替代汇编语句中的%0
,而a作为输入替代汇编语句中的%1
。注意此时由于使用%
来表示变量,在表示寄存器时就需要使用两个%
。最后的clobbered register中%eax
是告诉GCC在这段汇编中%eax
的值被改掉了。
由上面的例子我们可以看到基本的内联汇编格式
1 | asm ( assembler template |
在汇编中默认是AT&T语法,也可以指定使用Intel语法
1 | asm(".intel_syntax noprefix;\n" |
注意实际上内联汇编是将我们自己写的汇编代码嵌入到编译器生成的汇编代码中,而GCC生成的默认是AT&T语法的汇编。当我们切换到Intel语法后,写完自己的代码,需要切换回AT&T语法,否则之后编译器生成的汇编代码就会因为语法不对导致汇编器报错。
在进行变量命名时,除了按照顺序使用%0
,%1
…之外,也可以直接使用名字,下面有一个相对复杂的例子。来自Breaking the x86 ISA这篇演讲源代码中的injector.c
1 | __asm__ __volatile__ ("\ |
这段内联汇编就直接使用了eax
, ebx
, ecx
这样的变量名称,将相关寄存器保存到inject_state
结构体的相应成员之中。
内联汇编这块的相关资料还是比较丰富的,也可以参考文末的链接进行了解。
很多时候我们需要使用shellcode来实现一些复杂的功能,例如与上层hypervisor
进行交互、发送RPC、列目录等等,此时如果纯手写汇编的话就显得有些复杂。尽管pwntools中提供了shellcraft
这样的生成模块简化了编写过程,但诸如内存状态和寄存器状态的维持还是需要手工进行。
在最近的几场CTF中,我都使用了gcc直接编译c代码生成shellcode的方式来生成,相对来说开发难度降低了不少。
一个简单的示例
1 | // sc.c |
这段c代码没有包含任何头文件,所有的功能都是自己实现的,最终能够输出Hello world!
的字样。由于内联汇编是直接在汇编代码中插入代码块,所以我们也可以直接用汇编设置label
,这里read
函数就是用c定义,而用汇编实现的,这样可以避免gcc在函数头尾加入prologue
和epilogue
,简化函数代码。
GCC的编译过程默认是会链接glibc的,即使使用-static
也会将glibc那一套都链接进elf中,这样生成的elf就相当大。可以使用-nostdlib
选项来禁止gcc链接glibc. 例如上面的代码可以这样编译:
1 | gcc -nostdlib -e entry sc.c -o sc |
-e
选项的作用是指定elf的入口点。编译完成后就能够直接生成一个仅包含上面c代码的elf文件了,运行之后就能输出相应的字符串。接下来我们要做的就是通过objcopy
将其.text
段提取出来
1 | objcopy --dump-section .text=sc.bin sc |
这时我们就可以直接使用sc.bin
作为shellcode了.
上面的代码所有的操作都只涉及了.text
段的内容,但如果我们这样写
1 | write(1, "Hello World!\n", 13); |
字符串就会被放到.rodata
段,而不是被直接放到栈上。这样编译出来的代码在寻址时可能会直接使用绝对地址去寻找相应的字符串,此时我们就需要开启pie
来进行间接寻址。
1 | gcc -nostdlib -fpie -e entry sc.c -o sc |
但这样一来我们就需要同时提取.text
,.rodata
两个段并将他们拼接在一起,还要注意.text
段的对齐问题,稍微有点复杂,所以还是直接使用栈字符串比较省事。
一般来说,全局变量是被放在.bss
和.data
段中的,而这两个段由于需要可读可写不可能和.text
和放到同一个segment,将会导致更加麻烦的寻址问题。
考虑到这一点,我们可以将全局变量直接放到.text
段中,并将.text
段设为可读可写可执行,用下面的代码作为示例
1 | asm("entry:\n" |
这里使用了__attribute__
告诉gcc将msg
变量放入.text
段中,编译时使用-Wl,--omagic
将.text
变为可写
1 | gcc -nostdlib -fpie -Wl,--omagic -e entry sc.c -o sc |
这时就可以对msg
进行读写了,这时msg
变量实际上在main
函数的前面。不过头部的entry
保证了shellcode是从0地址进入的,这样就不用关心入口点的问题了。
我们在使用IDA进行逆向时,经常需要标一些库中的特定structure,此时导入头文件是一个比较好的选择。然而一般的库头文件中总有很多的依赖问题,导致不能直接使用IDA导入。以前我会找到所有报错的地方,然后手动解决,比如删掉不需要的结构体,自己添加typedef
定义一些数据类型,将不认识的指针都改成void *
等等,还是有些繁琐。
GCC有一些选项能够输出编译过程的中间结果,例如-E
能够输出预处理之后的结果,-C
能输出编译后的汇编代码. 我们可以编写一个特别简单的C代码
1 |
然后使用,gcc -E test.c -S test.out
进行预处理,此时生成的test.out
就是已经包含了所有依赖的头文件的内容了。
不过现在生成的文件还不能直接导入ida,需要将所有以#
开头的行都去掉或者注释掉,并手动修改一些还是会报错的地方。例如去掉所有的__extension__
,以及将__signed__
改成signed
等等。不过总体来说对于复杂的头文件依赖还是比手动调整要简便多了。
Workload比较大的3个题,这次DEFCON的2天基本都耗在这个题上了,前半段的做题过程被speedrun频繁打断,到了后24个小时才得以集中精力。不过最后还是没能完成第3题,有点遗憾。
程序实现了一套类似sandbox的运行时环境,主进程LCARS
负责处理app的输出、打开文件、RPC等操作,在程序启动时根据启动参数load相应的文件并加入到自己维护的文件系统中,保存它们的fd. 对于不同文件有不同的权限,例如.sys
文件和.key
文件都是权限为0,普通文件则是权限为3.在app打开文件时,主进程会检查它的特权级是否满足打开该文件的要求,如果不满足则返回错误,如果满足则会通过sendmsg
系统调用传送该文件的fd,并把文件指针重置到头部。
主进程在内存中mmap
了一块共享内存0x40000000-0x50000000
,每个app占用0x10000000
大小。主进程将sys
的镜像mmap
到0x10000000
,同时mmap 0x20000000
作为数据区,0xEFFFC000
作为stack. 然后通过0x40000000 + (appid << 24)
找到属于这个app的共享内存,将其remap到0x30000000
,并将其他app的共享内存设为只读。做完这些之后,主进程将为app设置特权级与seccomp filter,随后跳转到0x10000000
处执行。故sys
文件就是从0开始的代码,但基址是0x10000000
.
在app运行时,可以通过fd 0,1与主进程交互,发送的request中可以指定offset
和length
两个字段,offset
表示buffer在共享内存中的偏移。app将需要传递的内容copy到0x30000000
,主进程在获得参数buffer时就去对应0x40000000 + (appid << 24)
的位置查找就可以了。通过request还可以实现app之间的RPC,获得buffer内容同样是通过读取共享内存完成的。
由于sys没有一个特定的格式,直接选择ida64的binary file打开,然后手动设定基址,并添加数据段。之后设置Options -> Compiler,选择GNU C++,就可以在代码段上按C键分析了。
主进程首先启动的进程是init.sys
,它启动了2个app echo.sys
和crypto.sys
,这两个sys都是通过RPC向外提供服务的。随后init.sys
进入到一个类似命令行的循环中,可以使用download
和run
两个命令。download
能够写任意内容到文件,run
可以启动某个程序。
如果run
的程序不是sys,则init.sys
会启动loader.sys
并加载特定格式的papp文件。根据loader.sys
中的代码,文件格式类似ELF中的program header,可以指定若干个segment以及它们加载的地址和权限。比较特殊的是,在指定段的权限prot
时不仅有rwx位,还可以指定该段具有签名验证和加密,具有这种特性的段,loader.sys
会使用RPC调用crypto.sys
中的RSA签名验证代码和AES解密代码对该段数据签名进行验证及解密。而如果papp文件中存在没有验证的代码段,则该app运行时特权级将被降为3. 在所有的segment都load完之后,loader.sys
发消息给主进程调整特权级,并设置seccomp filter,随后跳转到最后一个代码段开始的位置执行,此时app就开始执行我们编写的shellcode了。
逆向crypto.sys
后发现,进行签名验证的2个publickey被硬编码在binary中了,但只有公钥是构造不了签名的,所以得想别的办法。
直接写shellcode有点繁琐,我就直接采取用c编写,编译成地址无关代码然后导出.text段的方式,然后在脚本中再将.text段封装成papp文件。之前在进行内核利用时为了缩减elf的大小也使用过不链接glibc,所有系统调用和底层函数都自己实现,然后用gcc -nostdlib -e main
进行编译,由于loader是从头开始执行的所以main函数要放在最前面。之后再用objcopy
将.text段的原始数据提取出来。其实如果能够更了解gcc的一些特殊写法和开关选项应该不用这么麻烦,还是对编译这一块不是太熟悉。
在调试时,由于启动sys的过程中会mmap直接映射文件,在attach时可以首先列出所有的LCARS
进程,然后逐一确认/proc/<pid>/maps
里面的Image,有选择地attach. 但是loader.sys
因为存活时间短,只能选择在其中patch出int 3
指令,并在主进程中设置fork-mode为child断下来。
远程有一个flag1.papp
文件,应该是调用了crypto.sys
中的一些功能进行flag的加密,并输出到终端。由于进行RPC调用时,需要将数据copy到0x30000000
的共享内存中,而一个app退出后,再次复用该段共享内存时并没有对数据进行清零。故我们首先运行flag1.papp
随后运行我们自己上传的papp文件,直接输出0x30000000
内存对应的内容,在其中就能够看到flag.
参考利用
远程有一个flag22.txt
文件,该文件在我们特权级为3的app中是读不到的,所以我们需要想办法干扰loader.sys
,抢在loader降权之前接管程序运行。这个版本的loader实现中没有校验mmap
的地址,导致我们能够将一些已经存在的page再次mmap
,然后写入我们构造的数据。原本我想通过破坏0x10000000
这块的几个page来直接劫持控制流,但后来避免不了崩溃只得作罢。
最后我选择了mmap
共享内存0x300000000
中的一个page和crypto.sys
对应的只读内存中的一个page. 这样就将原本的shared的page给变成了private,意味着失去了和其他app通信的能力变成了我们能够控制的数据。通过合理构造这2个page中的数据,能够伪造RPC的返回包,并通过签名校验和解密的过程,且能够将权限维持在1. 当以这种状态执行代码时,就能够读取到flag22.txt
文件了。
不过在完成签名代码段加载之后,为了能够正常发送request,需要把数据段头部一个表示当前共享内存top的变量(0x20000004
)改到private page外面,否则之后自己发送的request也收不到响应了。
参考利用
这题放的时间有点太晚了,比赛结束前2小时30分钟才放出。打开一看,在mmap
之前添加了一个mprotect
的尝试,如果mprotect
不报错的话说明这个page之前已经存在,loader就直接退出了。想了2个小时,没能找到很好的方法,坐等其他writeup.
作者放出了challenge的源码和利用: https://github.com/o-o-overflow/dc2019q-LCARS000
在loader.sys
中如果load papp的过程发生了错误,则会unmap
之前记录的所有segment,但在这其中存在一个OOB Read. 只要将一个segment恰好放到data段的后面(0x20004000
的位置)并构造数据就能够munmap
任意内存。接下来的利用思路就和我利用LCARS022
的类似了,munmap
之后再通过load segment将内存mmap
回来,将shared memory变为private. 由于报错之后loader需要接收RPC才能进行下一次load过程,故首先需要一个LCARS022
中已经利用成功的app向loader发送RPC多次load。
实际上我的LCARS022
做法并不是intended way,在2中需要通过AES-CBC模式的IV来改掉已经签名的page当中的前16个字节,这样至少我们能够控制代码的前16个字节,再通过向栈的后面map一个segment来进行ROP. 这样在3中仍然可以用这种方法来进行利用,但这种方法的特权级不能读到flag333.txt
,只是用来向loader发送RPC使用。
国际强队p4首次举办CTF,一天时间确实有些紧张,而且只有一道内核pwn题。不同于传统内核pwn使用设备驱动来模拟内核漏洞的方式,该题实现了另一种可执行格式的装载和运行,利用方式也略有不同。
在kernel module中可以注册新的可执行文件解析器,具体结构如下
1 | static struct linux_binfmt elf_format = { |
以上是ELF文件的格式结构,同时在模块初始化时,需要调用register_binfmt(&elf_format)
对相应文件格式进行注册。这样当内核在execve一个文件时,就能够调用其中的load_binary
函数来进行装载了。在调用时会传入一个linux_binprm结构,内含一个小的128字节buffer,便于模块对其头部进行解析。若需要之后的数据,则需要从其中的file结构体指针来读取。
p4fmt.ko
实现了对p4格式的可执行文件的解析,文件需要以P4
字节开头。Header中存储了版本、入口点、类型等信息。后面由若干个segment构成,每个segment能够指定其加载的内存地址、偏移和权限。大体来说与ELF的segment装载过程很类似,但没有ELF的动态装载过程,最后直接从入口点开始执行。
文件头部指定了segment段表在文件中的offset,而解析时没有对其进行检查,导致在寻找段表时能够越过128字节的buffer读取。在linux_binprm
结构中,在buffer后面存在一个cred
指针。
1 | 0xffff8d78c5805048:0x00000001010034500x0000000000000090 <------- buf |
在找到段表之后,紧接着会调用vm_mmap
设置相应的page,并使用printk
来打出调用时的参数。那么当我们把offset设置到cred
指针附近时,就能够通过打印出参数来读到cred
的指针了。
在设置segment权限时,如果addr & 8 == 1
,则表示此segment不是从文件中load进来的而是直接进行全0映射。那么在vm_mmap
调用结束后,模块调用__clear_user
来将用户态的page清零。
但我在实验之后发现,__clear_user
不仅能够将用户态的page清零,甚至能够将内核态的数据清零。而且其调用时的地址和长度不用对齐也能成功。那么我们通过构造segment表中的恶意表项,给出内核空间的地址,尽管vm_mmap
会失败(这里也没有检查返回值),之后的__clear_user
还是会正常执行。从而我们拥有了几乎任意地址写0的能力。
在有了上面两者之后,我们就可以思考利用过程了。简单来说,首先泄露出cred
的地址,然后将cred
中的uid
等数据改为0,在模块解析的最后,调用install_exec_creds
为新进程安装cred
,实际应该就是将linux_binprm
中的cred
结构拷贝过来。这样在最后start_thread
并回到用户态之后,新进程就获得了root权限。
但考虑到leak时,段表实际处于我们不可控的区域,故整个过程需要分2步进行。在leak出某个cred
结构的地址后,动态产生另一个p4可执行文件,目的是覆盖其中的uid
等数据,并寄希望于解析时linux_binrpm
中的cred
刚好指向之前leak的地址。经过一番尝试,我发现还是有可能在多次执行之后撞到同一个地址的。
之后的工作,就是让第二个p4文件加载2个segment,一个用来改写cred
,另一个正常的段来执行shellcode起shell即可。
本题主要的工作量还是在于逆出p4文件的格式并能够自由构造,其中leak的问题还是比较容易发现。但之后要做改写还是需要对这些函数的机制有一定的了解,否则就只能一遍遍地进行尝试。我在很长一段时间都在调整vm_mmap
的参数,希望能够将文件数据映射到内核空间。最后在测试了__clear_user
的功能之后才有了一定的思路。
Linux Kernel - 可执行程序的加载过程
binfmt_cgc.c
Playing with binary formats
Linux Kernel 5.0 - binfmts.h
很多近期的一些比较好的题目被我收集到了 https://github.com/BrieflyX/ctf-pwns ,希望能够尽量收集Pwn这个领域各个方向上比较有意思,或者利用思路清奇的、有代表性的题目。有些题目是我自己做的,有些题目是没做出来看Writeup的,对于题目的思路基本就是一两句话,比写一篇Blog要轻松多了。
之后还是应该多总结一些思路上的东西,或者介绍自己在基础工具上的一些经验,也希望自己能够勤快一点吧。
]]>又是一道从userspace pwn到hypervisor的题目,虽然漏洞较为容易,但在利用和调试方面还是需要对虚拟化和kernel有一定的了解。在做题的过程中,也可以增进对KVM API,OS Kernel的内存管理和中断处理,以及x86-64的分页机制的理解,还是相当有趣的。
之前在TWCTF中,曾经出现过类似的题目EscapeMe. 受它的影响,本次Hitcon也出现了模式几乎一样的题目,玩了1天多感觉很有意思,在大佬的指导下体验了一回VM Escape.
赛后作者也放出了相关源码和利用,可以拿来复现一下
https://github.com/david942j/ctf-writeups/tree/master/hitcon-2018/abyss
整个题目由3个binary,hypervisor.elf
,kernel.bin
与user.elf
,还有3个flag组成。
hypervisor.elf
是一个利用KVM API来做虚拟化的程序,它会加载一个小型的内核kernel.bin
,这个kernel就只实现了内存管理和中断处理的功能,提供了loader启动和libc加载需要的一些常见syscall,然后解析ELF启动一个用户态程序。这里直接加载ld.so.2
来装载用户态程序user.elf
.
user.elf
就是一个标准的x86-64 ELF文件,也可以直接在host上启动。kernel.bin
在处理syscall时,将一些与IO有关的例如read/write等通过 I/O Port (CPU的in/out指令) 交给hypervisor来处理。例如open这个syscall,kernel在做检查之后,直接通过hypercall传给hypervisor处理,然后hypervisor会在host上打开一个文件,并将其fd做一个映射返回给kernel. 所以实际上VM内做的open是可以打开host的文件的。
本题的3个flag都位于host上,flag1在pwn掉user.elf
之后可以通过shellcode来读,flag2需要pwn掉内核,第3个flag由于不知道文件名,需要pwn掉hypervisor.
在使用KVM来做虚拟化时,需要给VM分配VCPU和memory,通过ioctl的KVM_SET_USER_MEMORY_REGION
request来给VM插上一块物理内存
1 | void *mem = mmap(0, |
这样一来hypervisor的mem这块位置就被作为VM的物理内存来使用了
当VCPU和memory都设置好之后,就可以使用ioctl的KVM_RUN
request来启动VM,当遇到中断或者异常时,ioctl就会返回,这时可以通过检查exit_reason
来得知中断或者异常的类型并做相应处理,例如I/O的请求,就可以这时进行处理。
user.elf
是一个传统的用户态pwnable,漏洞也比较容易发现,就是swap的时候没有检查可以直接改掉stack_pointer。需要注意的是程序内是没有NX的,所以可以直接修改GOT表来跳到bss的shellcode上,之后常规open flag/read/write即可拿到第1个flag.
kernel逆起来要稍稍复杂一点,但本身功能不多,结合与hypervisor之间约定的I/O Port相关的信息,可以找到它的系统调用表,然后逐个分析syscall即可。hypervisor给kernel的物理内存有0x2000000大小,kernel被加载到0的位置,0-0x200000为内核地址空间,高地址0x200000-0x2000000为userspace.
第2个flag的名字叫flag2,但是kernel在做open syscall的时候,对打开的参数做了一个白名单
1 | int sys_open(const char *path) { |
想要打开flag2,就只有exploit kernel了,在处理read syscall时,可以发现其没有检查kmalloc返回0的情况
1 | int64_t sys_read(int fildes, void *buf, uint64_t nbyte) { |
这里作者使用的方式是将内存耗尽,首先mmap一块0x1000000的内存,之后再read 0x1000000 bytes,这样kmalloc就会因为空间不足而返回0,这样就直接从0开始read,可以直接改掉内核代码,执行内核shellcode了。在hypervisor处理完read再次进到VM时,实际是从in/out那里开始执行,一直覆盖到那里就可以了。之后执行内核shellcode,利用hypercall open flag2/read/write就可以拿到flag2.
现在我们已经可以调用任意hypercall了,在hypervisor里有一个0x8008 port可以处理ioctl请求,但整个kernel并没有使用这个port,也不提供ioctl这个syscall. 看一下ioctl hypercall的处理就会发现,没有做任何检查。
1 | /* I'm sorry.. this is a backdoor.. */ |
这就意味着我们可以在host上以任意参数来调用一个ioctl,回想之前KVM的memory分配,我们其实就可以直接用KVM_SET_USER_MEMORY_REGION
来将hypervisor其他的可写内存给插到VM里面。首先需要读一下/proc/self/maps
文件leak hypervisor的地址,之后构造一个memory region.
1 | struct kvm_userspace_memory_region region = { |
这样就可以把hypervisor的栈直接映射到VM里0x2000000的位置,也就是说在VM里操作这块的物理内存就相当于操作hypervisor的stack. 但现在我们还不能直接在kernel中访问这块地址,因为它没有页表项。看一下x86-64的页表结构
图片来源:https://www.cs.uaf.edu/2012/fall/cs301/lecture/11_05_mmap.html
在64位的long mode下,所有的访存都需要经过页表,而hypervisor和kernel并没有为高于0x2000000设置页映射,所以我们需要手动为它加上一个页表项。kernel在刚启动时对page table做了下面的操作
1 | /* Maps |
在PML4的第1项上放了1个PDP,在PDP中初始化了16个page,这些Page都是带有PDE64_PS
标志位的,这代表着它们不是下一级PD的地址,而是1个0x200000(2MB)的page的直接映射。因为PML4的第1项对应的Virtual Address的39-47位为1,实际上就是一组0x8000000000 ~ 0x8002000000到0 ~ 0x2000000的映射。
我们可以如法炮制,在PDP的末尾加上1项,将0x8002000000 ~ 0x8002200000映射到0x2000000 ~ 0x2200000,这样就可以通过0x8002000000来访问物理地址0x2000000,也就是hypervisor栈的底部了。
之后就是在这段内存中搜索KVM_RUN
这个ioctl调用的返回地址,并把ROP链布置上去,再用hlt
触发中断就可以了。
在以前的CTF比赛中,大多使用Ubuntu系列来进行题目的部署,大家也比较习惯用LD_PRELOAD来加载远程libc。而近期的CTF比赛当中,经常出现一些非Ubuntu发行版的libc,例如Debian, CentOS, Arch等等。直接在Ubuntu下就不能用LD_PRELOAD来加载了。
由于不同libc发行版和版本号之间可能ABI不同,不同的loader(ld.so
)和libc(libc.so.6
)如果混用可能无法正常加载。就算同样是Ubuntu系统,不同libc版本之间也不一定能够使用LD_PRELOAD
来进行加载。一般来说,我会使用下载对应版本的ld或者直接使用docker来解决libc加载的问题。
可以直接尝试运行libc,./libc.so.6
,可以得到libc相关的信息
1 | GNU C Library (Ubuntu GLIBC 2.23-0ubuntu4) stable release version 2.23, by Roland McGrath et al. |
也可以直接strings libc并查找GNU C Library
来寻找
可以直接google寻找对应版本的libc相关的rpm, deb包等等,然后不需要安装直接解包,提取其中的ld-2.x.so,其他的可以不管。由于ld本身是一个自举的library,可以直接启动并加载对应的libc
1 | LD_PRELOAD=./libc.so.6 ./ld-2.28.so ./chal |
在这样启动的情况下,ld将被作为一个PIE的程序先被系统的loader加载到对应位置上,而chal则相当于作为一个库加载到地址空间中,实际的地址空间分布将会和直接加载chal有区别。
docker本身出现的目的之一就是解决应用依赖相关的问题,由于container实际上与host共用同一内核,而且在container中的进程虽然处于自己的namespace中,但在host上依然能够看到对应的进程,这就意味着我们可以使用container启动challenge,并在host上用gdb attach. 这样既解决了库依赖的问题,又不需要在container内部再装工具。
以Hack.lu 2018为例,Pwnable大多使用了Arch进行部署,libc版本很高(2.28),导致Ubuntu无法加载。先pull一个archlinux的镜像。
1 | docker pull base/archlinux |
如果没有其他依赖的话,可以直接以当前用户启动
1 | sudo docker run -i --rm \ |
这样在host上可以看到一个已经启动的chal进程,这时就可以与其交互了,当程序退出之后,container会自动销毁.
我在之前的blog中介绍了Pwntools的一些调试相关的用法,但现在Pwntools升级之后,实际不需要再用pidof再去获得pid,而是可以直接通过传一个process对象或者进程名的方式进行attach.
1 | p = process('./chal') |
可以将启动docker的脚本保存为一个shell script,然后利用进程名进行attach,同时指定executable file
1 | p = process('./launch.sh', shell=True) |
这样就和平常的调试体验非常相近了
但如果实际发行版的内核版本和host不一样,而题目利用方式又恰好和内核相关的话,docker就无能为力了,这种就需要借助虚拟化+gdbserver来复现远程环境了。
]]>在协会的一次关于堆利用的分享,希望能够总结一些关于heap exploit的普适规律和目前比较好用的利用技巧
很久很久之前写过一篇关于glibc堆分配的分析(Glibc Heap简介),但是因为近几年堆的利用方式一直在演变,所以也没有做过多的总结。
最近的heap challenge大多着眼于源码希望能够利用特定的一小段处理逻辑来实现相应的功能,而随着大家对malloc
的实现逐渐理解得越来越透彻,想挖掘出全新的比较好用的利用方法也变得越来越困难。再加上glibc最近针对unsorted_bin
和各种hook
的patch,使得之后可能heap exploit的形势将趋于平稳。故在协会分享了一次近期出现的一些方法,希望能对大家有一些帮助,have fun with heap!
Seccon较为复杂的一题,一道限制很多但是充满巧合的Heap利用
整个程序逻辑稍微有些复杂,总体来说是一个糖果购买系统,管理员用户可以增加Order,此时会为相应的Order分配空间;而普通用户可以购买相应的Order,当一个Order被购买完时这块空间将被释放。而管理员用户也可以删除account以及更改account的密码,本题的利用过程就围绕这几个功能展开。
程序的所有输入都采用read的方式进行,故在结尾都没有补0的操作,使得leak的过程相对容易。而程序中的漏洞出现在管理员删除account之后并没有将指向记录用户信息的结构体的指针清空,而是将其做了一个减16的奇怪操作,造成了一个UAF。之后在修改密码时,则根据P+32的位置来判断是否使用,若不为0,则可以向P+24
位置写入8个字节,这样结合之前P-16
的操作,实际修改的位置正好是chunk的bk指针。
由于Order增加之后其description的内容可以完全控制,且chunk的大小是0x90——恰好与一个account结构的大小相同,如此一来,通过description占位就可以通过P+32位置的检查,从而利用UAF修改free chunk的bk指针。修改bk的利用方法,除了Unsorted bin attack以外,还有利用House of Lore这种能够连续取出2块small bin chunk的方法,需要在堆上构造2个fake chunk,完成原先的small bin chunk => fake chunk 1 => fake chunk 2的链,伪造出2个bk和2个fd的指针。之后通过2次malloc对应大小的chunk即可取到fake chunk 1。
因为程序没有开启PIE,在House of Lore制造出Overlap Chunk之后,就可以利用程序bss上的堆指针做经典的Unlink攻击了,这里巧合的是Unlink攻击的最终结果是将堆指针P改为&P-24
的位置,而修改密码的功能又是能控制P+24
的8个字节,恰好能够改掉P自身,之后直接修改atoi
的GOT即可完成利用。
1 | #!/usr/bin/env python |
Intel Pin是Intel推出的一款二进制程序的插桩分析工具,目前已经到3.4版本。虽然已经推出很久了不过进行开发更多的还是要参考其用户手册与API文档。最近也在看这方面的用法,正好稍微梳理一下常用的一些函数和功能。
这里就以64位的Linux为例来说明,可以在官网上下载3.4版本的Pin,下载地址。3.x之后Pin似乎实现了一个自己的CRT,所以之前的一些基于pin开发的工具在应用于3.x的时候可能会遇到一些困难,不过这个还没有详细研究。
在这里我们不去深入讨论pin这个工具具体的实现细节和架构,只是介绍一点基本的使用方法。
在3.4的Manual中有很多的例子,基本涵盖了各个模块的基本用法,可以首先尝试例子程序。
1 | cd source/tools/ManualExamples |
要构建单独的例子程序,可以
1 | cd source/tools/ManualExamples |
例如inscount0.cpp
最终会生成inscount0.so
这个库,这个so即成为pintool,pin的主程序可以利用这个pintool中的代码来对程序进行插桩分析,运行
1 | ../../../pin -t obj-intel64/inscount0.so -o inscount0.log -- /bin/ls |
则可以对/bin/ls
这个程序使用inscount0.so这个pintool进行分析,最后输出结果,pin的使用方式为
1 | pin [OPTION] [-t <tool> [<toolargs>]] -- <command line> |
-t
之后接pintool的so文件,之后接传递给pintool的参数,在--
之后接需要进行分析的程序以及它的参数。
插桩(Instrumentation)就是在程序运行时在程序自身代码中插入一定分析代码的过程,在Manual提到从概念上来说插桩的流程包含两个部分:
我们可以看一下inscount0.cpp
这个程序的内容
1 |
|
这个程序给出了一般pintool的基本框架,在main函数中首先调用PIN_Init
初始化,之后就可以使用INS_AddInstrumentFunction
注册一个插桩函数,在原始程序的每条指令被执行前,都会进入Instruction
这个函数中,其第2个参数为一个额外传递给Instruction的参数,即对应VOID *v
这个参数,这里没有使用。而Instruction接受的第一个参数为INS
结构,用来表示一条指令。
最后又注册了一个程序退出时的函数Fini
,接着就可以使用PIN_StartProgram
启动程序了。
可以看到,上面inscount0.cpp
这个pintool插桩的对象就是所有指令。pintool在编写中将比较多的使用回调函数的机制,譬如在每条指令之前回调Instruction
函数。而在Instruction
函数的内部又使用INS_InsertCall
注册了一个函数docount
,意为在指令执行之前插入一个对docount
函数的调用。注意INS_InsertCall
是一个变参函数,前3个参数分别为指令,插入的时机(这里IPOINT_BEFORE
表示之前)以及函数指针(转为AFUNPTR
类型),在之后就可以指定传给函数的参数,并以IARG_END
结尾,这里没有指定参数,直接调用。而docount
的作用即是将一个全局变量加1,以达到统计执行指令条数的目的。
故此处插桩的分析代码即是将指令数加1.
我们可以在inscount0
的基础上,慢慢扩展出更加复杂的插桩分析程序
最简单的情况是直接针对所有指令插桩,INS模块中提供了很多API来判断当前指令的类型
1 | INS_IsMemoryRead (INS ins) |
一般看到API的名字就可以明白其作用了,如果有不明白则可以去查API的手册,或者还有种更加直接、具体的方法
1 | if (INS_Opcode(ins) == XED_ICLASS_MOV && |
上面的代码来自safecopy.cpp
,直接通过Opcode来识别mov
指令,并且是一条内存读指令,并且指令的第一个操作数是寄存器,并且指令的第二个操作数是内存。通过组合这些API就可以非常精确地筛选出想要插桩的指令了。
inscount0
中的分析代码写的非常简略,再之后还有一个例子itrace
1 | // This function is called before every instruction is executed |
在这里传递给printip
的是一个IARG_INST_PTR
参数,实际对应的类型是VOID *
,指示了当前指令的位置,而printip
则是把它输出出来,所以itrace
的作用即是输出所有指令的地址。
实际来说Instrumentation arguments中给出了很多可以传递给回调函数的参数,包括当前指令读取的有效内存地址、相关寄存器的值等等,能够对程序的运行状态有很全面的描述,便于回调函数的进一步分析。
想要获得当前某个寄存器的值,可以传递...IARG_REG_VALUE, REG_RAX...
参数,实际对应的类型是ADDRINT
,将寄存器当前的值传给回调函数。或者可以通过INS_OperandReg
函数首先提取出指令中的寄存器操作数,然后再用IARG_REG_VALUE
传递给回调函数。
想要修改寄存器的值,可以传递...IARG_REG_REFERENCE, REG_RAX...
这种参数,实际对应的类型是PIN_REGISTER *
指针,指向一个表示寄存器值的union类型,在64位中,可以使用reg->qword[0]
来访问RAX
,reg->dword[0]
来访问EAX
,以达到修改寄存器值的目的。
关于内存数据的获取和写入,可以参考safecopy
,其中使用到了PIN_SafeCopy
函数
1 | //======================================================= |
safecopy
实际模拟了mov
指令内存读的过程,将寄存器和指令操作的内存地址传递给分析函数DoLoad
,并在最后用IARG_RETURN_REGS
指定将分析函数的返回值写入到指令的操作寄存器中,实际指令的语义没有改变。
而在DoLoad
函数中,实际调用了PIN_SafeCopy(&value, addr, sizeof(ADDRINT));
将对应地址的内容模拟装载并返回。由此就可以看出在程序实际运行时pintool和原始程序位于同一地址空间,因而PIN_SafeCopy
既可以从内存中读取数据,亦可以写入数据。
有时我们并不需要在指令级的插桩,pin也可以实现基于Basic Block,Routine或Image的插桩函数,以例子中的malloctrace
来说
1 | ... |
使用IMG_AddInstrumentFunction
来注册一个在Image载入时插桩的函数,随后在Image
里面使用RTN_FindByName
来找到模块里的malloc和free两个符号,注意在pintool开头除了PIN_Init
之外还要用PIN_InitSymbols
来初始化symbol manager。在找到相应的函数之后,可以使用RTN_InsertCall
来插入分析代码Arg1Before
,并将此时函数的参数传递给分析函数。最后这个pintool完成的作用就是追踪malloc/free的调用,并输出它们的参数与返回值。
使用Pin工具需要首先理解二进制程序插桩的过程和整体思路,之后编写pintool就是套用例子就可以了,如果有需要的功能可以直接查手册或者自己去尝试。Pin还有很多功能没有研究,之后可能还会进一步了解一下。
Intel Pin Home
Pin 3.4 User Guide
API Reference
本次Hack.lu CTF到了比赛前一天才确定时间并开放注册,看来主办方也是很紧张啊,这次的题目也不多,主办方甚至拿了一道啤酒题来凑数。题目质量倒是差强人意,至少并不坑。做了几道Pwn,记录一下。
位于Heap上的Format String, 实际上跟heap没有特别大的关系,就是我们不能将地址输入到栈上了,在这种情况下需要利用栈上已有的指针来进行修改。一般来说,这种程序都会给出一个loop来多次进行printf,不过这个题偏偏没有,只有一次。那么就只能自己想办法去进行loop了。
在printf的时候栈上的布局大概是
1 | 0000| 0x7fffffffdfd0 --> 0x7fffffffdff0 --> 0x7fffffffe020 --> 0x555555554990 (<__libc_csu_init>: push r15) |
可以利用的一个就是顶部的rbp链,通过改掉第2个rbp的低位字节,让main
函数返回到_start
重新执行,由于一开始程序给出了一个字节的地址,我们可以计算出需要修改的低位字节。在这之前我们就可以完成leak libc和程序基址的过程。接下来每次都可以利用rbp让程序回到_start
,这样就形成了一个loop。这时候进一步的修改将使用另一条链就是栈上的环境变量,因为每次跳回_start
之后栈上都会有指向环境变量的指针,故可以稳定利用2个指针向栈上输入数据。比较麻烦的是每次回来栈会向下0x90个bytes,这样printf对应的参数要动态调整。
名字起的很剽悍,起初还以为是某种新的利用姿势,后来发现并不是。程序拥有5次调用malloc
的能力,但是不能free
自己创建的块,除此之外,程序会将用户的输入用8字节的key进行异或加密。在处理Read
输出数据的时候,指定了printf
的一个新的控制符%B
,实际上是检查了精度info->prec
是否为64,并且是否带有alt flag
也就是#
。最终的结果就是如果输入#.64
的format则会用一个类似base64的函数处理之后输出,如果直接输入回车则就是用%s
来输出。
关键在于这个#.64
的处理中会先malloc
一块空间之后再free
掉,这实际上就给了一个能够造出free chunk的能力。程序的漏洞是在创建时候输入sz
为0造成的整数下溢,进一步导致在堆上的任意长度溢出,而且这个溢出的数据不会被异或加密。
这样一来,我们可以使用Read
功能,事先造出fastbin的free chunk,然后在下面分配出其他块之后再占位回去,就可以使用任意长度的溢出来控制下一个块中的sz
数据,修改成-1,这样在edit时就可以在任意偏移输入任意长度的数据了,如果知道了heap和libc的地址,就可以做libc中的任意地址写了。
至于leak的方法,基本还是先靠heap上任意地址写的能力,先leak出key,之后通过malloc_conslidate
得到libc和heap的地址,在利用Read
功能去读就可以了。
很有意思的一个题,利用字符串解析的不同来完成payload的构造,看flag应该是从一个IRC bot改的。
程序的功能是从一堆输入中解析出相应的字段并拼在一起,漏洞很明显,在一开始输入时候就有的strcat
,以及之后执行extract_table_entry
和sprintf
都有溢出的问题。但是程序中有一个自己实现的固定值的stack cookie,而且其中带有0字节,由于输入使用的strcat
会被0字节截断,导致不能直接溢出构造rop。具体来说,整个函数的stack大概是这个样子:
其中s是存放最终解析的buffer,而e则是extract_table_entry
的目标buffer,buf是一开始payload输入的位置,程序在开始时输入了3次0x7ff字节的数据并用strcat
连接,意味着这个时候就已经溢出了。为了完成利用我们至少要将程序中给的system
地址和binsh
的地址放到栈上,并且将cookie修复。这个程序虽然是32位的程序但是特意将代码高位地址设成了0字节,这样需要修复的0字节就有3处。由于/bin/sh
的地址是最远的,可以在第一次strcat
时就布置好。
接下来的过程用到了extract_table_entry
函数的一个性质就是遇到0字节不会停止,遇到<
才会停止,这样我们可以控制一下其解析的src和dst让它们相等,这样得到的结果就是从e这个buffer开始,遇到的第一个’<’将会被变成0.那么如果第一次解析ISP:
时的buffer布局是这样
最后cookie的最高位就可以被改成0,而由于第一次使用的是sprintf(s, "%s; ", e);
,在sprintf之后一个0字节会被放到e这个buffer靠前的位置
同时控制一下让s的尾部恰好为City:
这样下次解析时还会出现同样的效果,这次在返回地址的最高位上<
将会被变成0,而e前部的0有效地防止了之后sprintf对后面数据的破坏,最终完成利用。
参考代码
1 | #!/usr/bin/env python |
Tokyo Western,因为他们参加了WCTF,于是就有了这个题……
程序非常简单,每次输入一个size之后malloc一个buffer,然后read数据进来,往buffer尾部写一个0,之后再write出来,像鹦鹉一样,所以叫做parrot
。总的来说,就是下面一个循环
1 | while(1) { |
要说漏洞,就是这个size
可以被完全控制,当size
过大时buf会返回NULL
,这样在下面其实就是向size-1
的位置写了个0,相当于一个任意地址写0.
首先做一个libc的leak,先malloc出2个不同大小的fastbin chunk,之后触发一个malloc_consolidate
,这样上面一个fastbin chunk在被投入unsorted bin时就会留下其指针。在之后利用write读出来就可以leak libc了。
之后就是通过这个任意地址写0进行破坏了,在想了好几个目标之后,我想到了@angelboy在WCTF上教的位置……
1 | _IO_FILE_plus |
在stdin的_IO_FILE
结构中,_IO_buf_base
和_IO_buf_end
指示了这个FILE使用buffer的位置,由于程序做了setbuf
的操作,这个buffer就位于结构体内部_shortbuf
的位置。如果将一个0写到_IO_buf_base
的最低位,就可以将buffer往前扩展。在scanf
输入时,输入的数据会覆盖掉_IO_buf_base
指针自身,我们可以把它修改成__free_hook
的地址。这样在本身buffer里面的字符消耗完之后,就可以进一步去修改__free_hook
了。
之后通过输入一些字符来让buffer全都消耗掉,再写入system
的地址就可以了。
利用本身写的不是很稳定,可能是在输入的处理上有些问题。
参考脚本
1 |
|
2017年的BCTF,又匆匆出了2个heap的问题,都是些之前出现过的套路了。结果出了unintended有点遗憾,不过还好问题不大。
去年Alictf中Starcraft一题的加强版,不过这次的漏洞仅仅是vector对象的UAF而不是上次的任意Double Free了。所以当drink操作完成后,可以取回之前的vector对象并任意伪造。
Leak的方法比较明显,输入color的时候错误就能够造成一个未初始化的pointer,那么我们构造一个fastbin chunk的链就可以leak出heap的地址了;关于libc地址的话由于程序的size限制不能直接得到non-fastbin chunk,可以通过不断加大vector的大小使其重分配
以得到一个指向main_arena
中bin的指针,而那边的内容又是一个地址,即可完成leak libc。
接下来就是利用了,可以通过vector的特性完成任意地址写,在drink操作之后,覆盖vector对象中的3个指针start
、end
和capacity
,每次vector做push_back
操作时,会将新分配出来的元素写到end
的位置,同时将end
加8。利用这个特性,我们可以将自己分配出的地址写到任意位置。
攻击的目标非常多样,自从上次house of orange
之后,大家普遍喜欢去_IO_2_1_stdout_
搞事情。
1 | 0x7ffff7a50620 <_IO_2_1_stdout_>: 0x00000000fbad2887 0x00007ffff7a506a3 |
在这个结构尾部是一个类似虚表的jumptable(0x00007ffff7a4e6e0
),在调用其中函数时会将结构自身作为参数传入。紧接着的就是程序要用到的stdin
、stdout
和stderr
指针。我们可以先控制这个虚表指针,让其输出的时候调用gets
,这样就能够再一次输入控制整个结构,此时就能将/bin/sh
写到结构头部,再一次控制虚表,让其调用system
就可以了。
程序逻辑真的非常简单,重点就是在对2个指针的操作了。在输入函数中有着一个十分明显的null-byte off-by-one,然后就是程序没有显式的输出chunk内容的函数,需要另外寻找方法leak。
程序中唯一能够动态提供信息的地方就是通过随机数生成队友信息以及Round
这个变量,所以通过一个unsorted_bin attack
将unsorted_bin
地址写到这2个地方就可以了。本来我没想通过Round
来进行leak,而是希望通过队友的信息来分别计算出这4个bytes的值,所以没将Round
设成unsigned
导致简化了leak的过程,不过这也问题不大。
程序仅仅使用了realloc
函数,不过其实它可以完成很多的功能;例如,输入空行则相当于free
操作,而输入和之前达到同样size
的payload则相当于edit
操作,不会触发heap分配的代码。
现在问题就变成了如何利用这个null-byte off-by-one来构造一个unsorted_bin attack
,一般来说,heap中通过对size
的破坏来完成利用,多是需要构造overlapped chunk,这次也不例外。其实整个过程也并不复杂,核心思想即是在破坏下一块prev_inuse
的标志位的同时,同样伪造一个prev_size
给这个chunk,使其在找上一块的时候能够找到一个合法的chunk进行合并,就能够通过合并操作吃掉一块,从而产生overlapped chunk.具体来说,大概长这个样子
1 | +----------------+ |
chunk B触发了null-byte off-by-one,使得chunk C的prev_inuse
被置为0,同时其prev_size
被置为0xb0,此时free这个chunk C,其会找到上面合法的chunk A进行合并。而此时我们还留存着一个指向chunk B的指针,再次malloc一个big chunk就可以形成overlapped chunk了。
接下来就是在chunk B的位置构造一个non-fastbin chunk,并在下面放上2个0x20的inuse chunk,接着free chunk B。编辑我们的big chunk,覆盖unsorted chunk
的bk完成任意内存写,就可以做到leak了。最后,通过不断地edit构造一个fastbin attack,改写__realloc_hook
到system
就可以了。
下面的脚本供参考
1 | #!/usr/bin/env python |