译者:飞龙
MIT 6.858 计算机系统安全笔记 2014 秋季
2014 年由Nickolai Zeldovich 教授和James Mickens 教授教授授课的 6.858 讲座笔记。这些讲座笔记略有修改,与 6.858 课程网站上发布的内容略有不同。
第1讲:介绍:什么是安全,意义何在,没有完美的安全,策略,威胁模型,假设,机制,缓冲区溢出
第2讲:控制劫持攻击:缓冲区溢出,栈金丝雀,边界检查,电子围栏,胖指针,影子数据结构,Jones & Kelly,松松边界检查
第3讲:更多松松边界和返回导向编程:边界检查的成本,不可执行内存,地址空间布局随机化(ASLR),返回导向编程(ROP),堆栈读取,盲目 ROP,小工具
第4讲:OKWS:特权分离,Linux 自主访问控制(DAC),UIDs,GIDs,setuid/setgid,文件描述符,进程,Apache web 服务器,chroot 监狱,远程过程调用(RPC)
第5讲:渗透测试 嘉宾讲座 由 Paul Youn,iSEC Partners
第6讲:Capsicum:混淆副手问题,环境权限,能力,沙盒,自主访问控制(DAC),强制访问控制(MAC),Capsicum
第7讲:Native Client (NaCl):沙盒化 x86 本机代码,软件故障隔离,可靠的反汇编,x86 分段
第8讲:Web 安全,第一部分:现代网络浏览器,同源策略,框架,DOM 节点,cookies,跨站请求伪造(CSRF)攻击,DNS 重绑定攻击,浏览器插件
第9讲:Web 安全,第二部分:跨站脚本(XSS)攻击,XSS 防御,SQL 注入攻击,Django,会话管理,cookies,HTML5 本地存储,HTTP 协议的歧义,隐蔽通道
第10讲:符号执行 嘉宾讲座 由 Armando Solar-Lezama 教授,MIT CSAIL
第11讲:Ur/Web 嘉宾讲座 由 Adam Chlipala 教授,MIT,CSAIL
第12讲:TCP/IP 安全:威胁模型,序列号和攻击,连接劫持攻击,SYN 洪水攻击,带宽放大攻击,路由
第13讲:Kerberos:Kerberos 架构和信任模型,票证,认证者,票证授予服务器,更改密码,复制,网络攻击,前向保密性
第14讲:ForceHTTPS:证书,HTTPS,在线证书状态协议(OCSP),ForceHTTPS
第15讲:医疗软件 嘉宾讲座 由 Kevin Fu 教授,密歇根大学
第16讲:时序攻击:侧信道攻击,RSA 加密,RSA 实现,模指数运算,赛里斯剩余定理(CRT),重复平方,蒙哥马利表示,卡拉茨巴乘法,RSA 蒙蔽,其他时序攻击
第17讲:用户认证:你拥有什么,你知道什么,你是谁,密码,挑战-响应,可用性,部署性,安全性,生物特征,多因素认证(MFA),万事达卡的 CAP 读卡器
第18讲:私密浏览:私密浏览模式,本地和网络攻击者,虚拟机级隐私,操作系统级隐私,浏览器实现了什么,浏览器扩展
第19讲:Tor 客座讲座,由 Nick Mathewson 主讲,Tor 项目
- 2012 年的 6.858 课程笔记关于匿名通信:洋葱路由,Tor 设计,Tor 电路,Tor 流,Tor 隐藏服务,阻止 Tor,用餐密码学家网络(DC-nets)
第20讲:手机安全:Android 应用程序,活动,服务,内容提供者,广播接收器,意图,权限,标签,参考监视器,广播意图
第21讲:信息流跟踪:TaintDroid,Android 数据泄漏,信息流控制,污点跟踪,污点标志,隐式流,x86 污点跟踪,TightLip
第22讲:麻省理工学院信息服务与技术部 客座讲座,由 Mark Silis 和 David LaPorte 主讲,麻省理工学院信息服务与技术部
第23讲:安全经济学:网络攻击的经济学,垃圾邮件价值链,广告,点击支持,实现,CAPTCHA,僵尸网络,支付协议,道德
2015 年的新笔记
- 第8讲:英特尔软件保护扩展(SGX):隔离,Iago 攻击,飞地,证明,Haven
论文
我们阅读的论文列表(papers/):
松松垮垮的边界检查
盲目黑客
OKWS
困惑的代理(或者为什么可能发明了能力)
辣椒 (capabilities)
本地客户端(沙箱化 x86 代码)
OWASP 前十名,最关键的 Web 应用程序安全风险
KLEE(符号执行)
Ur/Web(面向 Web 的函数式编程)
回顾“TCP/IP 协议套件中的安全问题”
凯伯罗斯:用于开放网络系统的认证服务
ForceHTTPs
值得信赖的医疗设备软件
远程时序攻击是可行的
替代密码的探索
私密浏览模式
Tor:第二代洋葱路由器
了解 Android 安全
TaintDroid:一种用于智能手机实时隐私监控的信息流跟踪系统
点击轨迹:垃圾邮件价值链的端到端分析
“新” 论文
其他论文
- 赛里斯防火墙安全政策
介绍
**注意:**这些讲座笔记略有修改,来源于 2014 年 6.858 课程网站上发布的内容课程网站。
什么是安全性?
在对手存在的情况下实现某个目标。
许多系统连接到互联网,而互联网上存在对手。
因此,许多系统的设计可能需要考虑安全性。
即,在有对手的情况下系统是否能正常工作?
高层次的安全性思考计划:
**政策:**您想要实现的目标。
例如,只有艾丽丝应该读取文件
F
。*常见目标:*保密性,完整性,可用性。
**威胁模型:**对攻击者可能做什么的假设。
例如可以猜测密码,但无法物理抓取文件服务器。
最好假设攻击者可以做某事。
**机制:**系统提供的旋钮,帮助维护政策。
- 例如,用户帐户,密码,文件权限,加密。
**最终目标:**在威胁模型内部,对手无法违反政策。
- 请注意,目标与机制无关。
为什么安全性很难?这是一个负面目标。
对比:检查是否实现了积极目标很容易,例如,艾丽丝实际上可以读取文件
F
。更难的是检查没有可能的方式让艾丽丝读取文件F
。- 你甚至如何开始列举艾丽丝可能读取文件的所有可能方式?艾丽丝可以利用多少层次的漏洞来获取文件
F
的访问权限。
- 你甚至如何开始列举艾丽丝可能读取文件的所有可能方式?艾丽丝可以利用多少层次的漏洞来获取文件
需要保证政策,假设威胁模型。
很难想象攻击者可能如何入侵的所有可能方式。
现实的威胁模型是开放式的(几乎是负面模型)。
最弱的环节很重要。
迭代过程:设计,根据需要更新威胁模型等。
如果我们无法实现完美的安全性,那有什么意义呢?
在这门课程中,我们将推动每个系统的边界,看看它何时会崩溃。
每个系统可能都会有一些导致妥协的破坏点。
这并不一定意味着系统没有用:这取决于上下文。
重要的是了解系统能做什么,以及系统不能做什么。
实际上,必须管理安全风险与收益。
更安全的系统意味着某些妥协的风险(或后果)更小。
不安全的系统可能需要手动审计以检查攻击等。
攻击成本越高,将有更多的对手被阻止。
更好的安全性通常使新功能变得实用和安全。
假设你想在系统上运行某些应用程序。
大公司有时会禁止用户在其台式机上安装未经批准的软件,部分原因是出于安全考虑。
浏览器中的 Javascript 是隔离的,这使得运行新代码/应用程序而无需手动检查/批准变得可以接受(或虚拟机,或本地客户端,或更好的操作系统隔离机制)。
同样,VPN 使得减轻允许员工从互联网的任何地方连接到公司网络的风险变得实际可行。
出现问题的原因之一:政策问题
例子: 萨拉·佩林的电子邮件账户。
雅虎电子邮件账户有用户名、密码和安全问题。
用户可以通过提供用户名和密码登录。
如果用户忘记密码,可以通过回答安全问题来重置。
安全问题有时比密码更容易猜到。
一些对手猜到了萨拉·佩林的高中、生日等信息。
政策总结为:可以使用密码或安全问题登录。
- (无法强制执行“只有用户忘记密码,然后…”)
-
Gmail 密码重置:向备用电子邮件地址发送验证链接。
谷歌贴心地打印了备用电子邮件地址的一部分。
马特·霍南的备用地址是他的苹果
@me.com
账户。
苹果密码重置:需要账单地址,信用卡的最后 4 位数字。
- 地址可能很容易获取,但如何获取用户信用卡号的 4 位数字?
亚马逊:可以向账户添加信用卡,无需密码。
亚马逊密码重置:提供用户的任意一张信用卡号。
- 哈哈。
亚马逊:不会打印信用卡号… 但会打印最后 4 位数字!
例子: Twitter 的
@N
账户劫持。- 对于合法用户来说,有时很难证明他们拥有一个账户!
如何解决?
仔细思考政策声明的含义。
一些政策检查工具可以帮助,但需要一种指定不良内容的方法。
在分布式系统中很困难:不知道每个人在做什么。
出了什么问题 #2:威胁模型/假设问题
例子: 未考虑人为因素。
钓鱼攻击。
用户收到一封要求续订电子邮件账户、转账或…的电子邮件。
技术支持接到一个声音很像用户的电话要求重置密码。
例子: 计算假设随时间变化。
麻省理工学院的 Kerberos 系统使用 56 位 DES 密钥,自 20 世纪 80 年代中期以来。
当时,似乎可以假设对手无法检查所有 2⁵⁶ 个密钥。
不再合理:现在大约需要 100 美元。
- 几年前,6.858 期末项目表明可以在一天内获取任何密钥。
例子: 所有 SSL 证书 CA 都是完全受信任的。
要连接到启用 SSL 的网站,Web 浏览器会验证证书。
证书是服务器主机名和加密密钥的组合,
- 由某些受信任的证书颁发机构(CA)签名。
浏览器信任的证书颁发机构(CA)长列表(数百个)。
如果任何 CA 受到损害,对手可以拦截 SSL 连接。
- 为任何服务器主机名创建一个“伪造”证书。
2011 年,两个 CA 受到损害,为许多域(谷歌、雅虎、Tor 等)签发了伪造证书,显然在伊朗使用。
2012 年,一家 CA 无意中签发了一个适用于任何域的根证书。
*例子:*假设你的硬件是可信的。
如果 NSA 是你的对手,结果可能并不是一个好的假设。
*例子:*假设密码学中有良好的随机性。
需要高质量的随机性来生成无法被猜测的密钥。
问题:嵌入式设备、虚拟机可能没有太多的随机性。
结果,许多密钥相似或容易被猜测攻击。
*例子:*颠覆军事操作系统安全。
在 80 年代,军方鼓励研究安全操作系统。
操作系统被破坏的一个意想不到的方式:
- 对手获取了开发系统的访问权限,修改了操作系统代码。
*例子:*颠覆防火墙。
对手可以连接到防火墙后面的不安全无线网络。
对手可以欺骗防火墙后面的用户来禁用防火墙。
可能只需点击链接
http://firewall/?action=disable
就足够了。或者也许在 CNN.com 购买广告,指向那个 URL(有效)?
*例子:*断开与互联网连接的机器安全吗?
- Stuxnet 蠕虫通过 USB 驱动器上的特制文件传播。
如何解决?
更明确的威胁模型,以了解可能存在的弱点。
更简单、更通用的威胁模型。
更好的设计可能会消除/减少对某些假设的依赖。
例如,不依赖完全信任 CA 的替代信任模型。
例如,不容易受到钓鱼攻击的身份验证机制。
问题出在哪里 #3:机制问题–漏洞
安全机制中的漏洞(例如,操作系统内核)会导致漏洞。
如果应用程序正在执行安全性,应用程序级别的错误会导致漏洞。
-
人们经常选择弱密码;通常可以在几次尝试(1K-1M)后猜中。
大多数服务,包括苹果的 iCloud,会对登录尝试进行速率限制。
苹果的 iCloud 服务有许多 API。
一个 API(“查找我的 iPhone”服务)忘记实现速率限制。
对手可以多次尝试猜测密码。
- 可能和他们发送数据包的速度一样快:
>> M/day.
- 可能和他们发送数据包的速度一样快:
-
花旗银行允许信用卡用户在线访问他们的账户。
登录页面要求输入用户名和密码。
如果用户名和密码正确,将重定向到账户信息页面。
账户信息页面的 URL 包含一些数字。
结果这些数字与用户的账号相关。
更糟糕的是,服务器没有检查您是否已登录到该帐户。
对手尝试不同的数字,获取不同人的账户信息。
可能是错误的威胁模型:与现实世界不匹配?
如果对手通过浏览器浏览网站,系统是安全的。
如果对手自己合成新的 URL,系统就不安全了。
很难说开发人员是否有错误的威胁模型,或者有错误的机制。
例子: 安卓的 Java
SecureRandom
弱点导致比特币被盗。拥有所有者私钥的任何人都可以花费比特币。
许多安卓上的比特币钱包应用使用了 Java 的
SecureRandom
API。结果系统有时忘记了给 PRNG 种子!
结果,一些比特币密钥很容易被猜到。
对手搜索可猜测的密钥,花费任何相应的比特币。
例子: 沙箱中的漏洞(NaCl,Javascript,Java 运行时)。
- 允许对手逃离隔离,执行他们本不应执行的操作。
例子: Moxie 的 SSL 证书名称检查漏洞
- 空字节与长度编码。
例子: 缓冲区溢出(见下文)。
案例研究:缓冲区溢出
考虑一个网络服务器。
通常情况下,网络服务器的代码负责安全性。
例如,检查可以访问哪些 URL,检查 SSL 客户端证书,…
因此,服务器代码中的漏洞可能导致安全妥协。
威胁模型是什么,策略是什么?
假设对手可以连接到网络服务器,提供任何输入。
策略有点模糊:只执行程序员意图的操作?
例如,不希望对手窃取数据,绕过检查,安装后门。
考虑来自某个网络服务器的简化示例代码:
webserver.c:
int read_req(void) {
char buf[128];
int i;
gets(buf);
i = atoi(buf);
return i;
}
编译器在内存布局方面生成了什么?
x86 栈:
栈向下增长。
%esp
指向栈上最后(最底部)有效的内容。%ebp
指向调用者的%esp
值。
read_req() stack layout:
+------------------+
entry %ebp ----> | .. prev frame .. |
| |
| |
+------------------+
entry %esp ----> | return address |
+------------------+
new %ebp ------> | saved %ebp |
+------------------+
| buf[127] |
| ... |
| buf[0] |
+------------------+
| i |
new %esp ------> +------------------+
| ... |
+------------------+
调用者的代码(比如,
main()
):- 调用
read_req()
- 调用
read_req()
的汇编代码:
push %ebp
mov %esp -> %ebp
sub 168, %esp # stack vars, etc
...
mov %ebp -> %esp
pop %ebp
ret
对手如何利用这段代码?
提供长输入,覆盖超出缓冲区的栈上数据。
有趣的数据:返回地址,被
ret
使用。可以将返回地址设置为缓冲区本身,在其中包含一些代码。
对手如何知道缓冲区的地址?
如果一台机器的内存是另一台的两倍会发生什么?
幸运的是对手,虚拟内存使事情更加确定。
对于给定的操作系统和程序,地址通常是相同的。
如果栈向上增长,而不是向下,会发生什么?
- 查看
gets()
的栈帧。
- 查看
一旦对手执行代码,他们可以做什么?
使用进程的任何权限。
经常利用溢出来更容易地进入系统。
- 最初在 Unix 上,运行 shell
/bin/sh
(因此称为“shell 代码”)。
- 最初在 Unix 上,运行 shell
如果进程以
root
或Administrator
运行,可以做任何事情。即使不是,仍然可以发送垃圾邮件,读取文件(Web 服务器,数据库),…
可以攻击防火墙后面的其他机器。
为什么程序员会写出这样的代码?
旧代码,未暴露在互联网上。
程序员没有考虑安全性。
许多标准函数曾经是不安全的(
strcpy
,gets
,sprintf
)。即使是安全版本也有陷阱(
strncpy
不会在末尾加上空字符)。
更一般地说,任何内存错误都可能转化为漏洞。
在释放后继续使用内存(释放后使用)。
如果写入,覆盖新的数据结构,例如函数指针。
如果读取,可能会调用一个已损坏的函数指针。
两次释放相同的内存(双重释放)。
- 可能会导致
malloc()
之后再次返回相同的内存。
- 可能会导致
将栈指针递减到栈的末尾之外,进入其他内存。
一个字节的错误写入可能导致受损。
甚至可能不需要覆盖返回地址或函数指针。
可以足以读取敏感数据,如加密密钥。
可以通过改变一些位来满足需求(例如
int isLoggedIn
,int isRoot
)。
如何避免机制问题?
减少安全关键代码的数量。
不要依赖整个应用程序来执行安全性。
将在实验 2 中进行。
避免安全关键代码中的错误。
例如,不要使用
gets()
,而是使用fgets()
可以限制缓冲区长度。使用常见、经过充分测试的安全机制(“机制的经济性”)。
审计这些常见的安全机制(有很多动力这样做)。
避免开发可能存在错误的新的一次性机制。
良好的机制支持多种用途、策略(更有动力进行审计)。
常见机制的示例:
操作系统级别的访问控制(但是,通常可以更好)。
网络防火墙(但是,通常可以更好)。
加密,加密协议。
缓冲区溢出,松散的边界
注意: 这些讲座笔记略有修改,来自 2014 年 6.858 课程网站。
缓冲区溢出攻击回顾
在上一讲中,我们看了执行缓冲区溢出攻击的基础知识。该攻击利用了几个观察结果:
系统软件通常用 C 编写(操作系统、文件系统、数据库、编译器、网络服务器、命令外壳和控制台实用程序)
C 本质上是高级汇编语言,所以…
暴露原始指针到内存
不对数组执行边界检查(因为硬件不这样做,而 C 希望让你尽可能接近硬件)
攻击还利用了关于 x86 代码如何工作的架构知识:
栈增长的方向
栈变量的布局(尤其是数组和函数的返回地址)
read_req.c:
void read_req() {
char buf[128];
int i;
gets(buf);
//. . . do stuff w/buf . . .
}
编译器在内存布局方面生成了什么?x86 栈看起来像这样:
%esp points to the last (bottom-most) valid thing on
the stack.
%ebp points to the caller's %esp value.
+------------------+
entry %ebp ----> | .. prev frame .. |
| | |
| | | stack grows down
+------------------+ |
entry %esp ----> | return address | v
+------------------+
new %ebp ------> | saved %ebp |
+------------------+
| buf[127] |
| ... |
| buf[0] |
+------------------+
new %esp ------> | i |
+------------------+
对手如何利用这段代码?
提供长输入,覆盖缓冲区后的栈数据。
关键观察 1: 攻击者可以覆盖返回地址,使程序跳转到攻击者选择的位置!
关键观察 2: 攻击者可以将返回地址设置为缓冲区本身,在其中包含一些 x86 代码!
攻击者在执行代码后可以做什么?
利用进程的任何权限!如果进程以 root 或管理员身份运行,它可以在系统上做任何想做的事情。即使进程不以 root 身份运行,它也可以发送垃圾邮件、读取文件,有趣的是,攻击或破坏防火墙后面的其他机器。
嗯,但为什么操作系统没有注意到缓冲区已经溢出?
就操作系统而言,没有发生任何奇怪的事情!请记住,粗略地说,操作系统只在 Web 服务器进行 IO 或 IPC 时才被调用。除此之外,操作系统基本上只是坐下来让程序执行,依靠硬件页表防止进程篡改彼此的内存。然而,页表保护无法防止进程“针对自身”发起的缓冲区溢出,因为溢出的缓冲区、返回地址和所有相关内容都在进程的有效地址空间内。
在本讲座的后面,我们将讨论操作系统可以采取的措施使缓冲区溢出更加困难。
修复缓冲区溢出
方法 #1: 避免 C 代码中的错误。
难以或不可能实现。
程序员应仔细检查缓冲区、字符串、数组等的大小。特别是,程序员应使用考虑到缓冲区大小的标准库函数(
strncpy()
而不是strcpy()
,fgets()
而不是gets()
等)。现代版本的
gcc
和 Visual Studio 在程序使用不安全函数(如 gets())时会发出警告。一般来说,你不应该忽略编译器警告。 将警告视为错误!好处: 首先避免问题!
坏处: 很难确保代码是无错误的,特别是如果代码库很大。此外,应用程序本身可能定义不使用
fgets()
或strcpy()
作为基本操作的缓冲区操作函数。
方法 2: 构建工具来帮助程序员找到错误。
- 例如,我们可以使用静态分析在编译之前找到源代码中的问题。想象一下,如果你有这样一个函数:
foo.c:
void foo(int *p) {
int offset;
int *z = p + offset;
if(offset > 7){
bar(offset);
}
}
通过静态分析控制流,我们可以知道 offset 在未初始化的情况下被使用。
if
语句还限制了我们可能传播到 bar 的偏移量。我们将在后续讲座中更多地讨论静态分析。
提供随机输入的“模糊器”可以有效地发现错误。请注意,模糊化可以与静态分析结合以最大化代码覆盖率!
坏处: 很难证明完全没有错误,尤其是对于像 C 这样的不安全代码。
好处: 即使是部分分析也是有用的,因为程序应该变得更少有错误。例如,松散的边界检查可能无法捕捉所有内存错误,但它可以检测到许多重要类型。
方法 3: 使用内存安全语言(JavaScript,C#,Python)。
好处: 通过不向程序员暴露原始内存地址,并通过自动处理垃圾回收来防止内存损坏错误。
坏处: 低级运行时代码确实使用原始内存地址。因此,运行时核心仍然需要正确。例如,堆喷射攻击:
坏处: 仍然有很多使用不安全语言(FORTRAN 和 COBOL)的遗留代码。
坏处: 也许你需要访问低级硬件功能(例如,你正在编写设备驱动程序)
坏处: 性能比调优良好的 C 应用程序差?
过去是一个更大的问题,但硬件和高级语言变得更好了。
JIT 编译万岁!
asm.js的性能接近本机 C++性能的 2 倍!
使用谨慎的编码来避免关键路径中的垃圾回收抖动。
也许你是一个不懂得如何选择合适工具的坏人/ 语言沙文主义者。如果你的任务是 I/O 绑定的,原始计算速度就不那么重要了。另外,不要成为那个用 C 语言编写文本处理程序的笨蛋。
上述 3 种方法都是有效且广泛使用的,但在实践中缓冲区溢出仍然是一个问题。
大量/复杂的用 C 语言编写的遗留代码非常普遍。
即使是用 C/C++编写的新代码也可能存在内存错误。
尽管存在有缺陷的代码,我们如何减轻缓冲区溢出?
在“传统”缓冲区溢出中发生了两件事:
对手控制执行(程序计数器)。
对手执行一些恶意代码。
这两个步骤存在哪些困难?
需要覆盖一个代码指针(稍后被调用)。常见目标是使用堆栈上的缓冲区的返回地址。在实践中,任何内存错误都可能起作用。函数指针,C++ vtables,异常处理程序等。
需要一些有趣的代码在进程的内存中。这通常比#1 更容易,因为:
在缓冲区中放置代码很容易,因此
进程中已经包含了许多可能被利用的代码。
然而,攻击者需要将这段代码放在可预测的位置,以便攻击者可以将代码指针设置为指向恶意代码!
缓解方法 1:金丝雀(例如,StackGuard,gcc 的 SSP)
理念:覆盖代码指针是可以接受的,只要我们在调用之前捕捉到它。
较早的一个系统:StackGuard
在进入时在堆栈上放置一个金丝雀,在返回前检查金丝雀值。
通常需要源代码;编译器插入金丝雀检查。
Q: 堆栈图中的金丝雀在哪里? A: 金丝雀必须放在堆栈上返回地址的“前面”,这样任何溢出重写返回地址也将重写金丝雀。
堆栈布局:
| |
+------------------+
entry %esp ----> | return address | ^
+------------------+ |
new %ebp ------> | saved %ebp | |
+------------------+ |
| CANARY | | Overflow goes
+------------------+ | this way.
| buf[127] | |
| ... | |
| buf[0] | |
+------------------+
| |
Q: 假设编译器总是将金丝雀设为 4 个字节的
'a'
字符。这有什么问题吗?A: 对手可以在缓冲区溢出中包含适当的金丝雀值!
因此,金丝雀必须要么难以猜测,要么可以容易猜测但仍然能够抵御缓冲区溢出。以下是这些方法的示例。
“终结符金丝雀”:四个字节(0,CR,LF,-1)
理念:许多 C 函数将这些字符视为终结符(例如,
gets()
,sprintf()
)。因此,如果金丝雀与这些终结符之一匹配,那么进一步的写入将不会发生。在程序初始化时生成随机金丝雀:今天更常见(但是,你需要良好的随机性!)。
堆栈金丝雀不会捕捉到哪些类型的漏洞?
在金丝雀之前覆盖函数指针变量。
攻击者可以覆盖数据指针,然后利用它进行任意内存写入。
数据指针示例:
int *ptr = ...;
char buf[128];
gets(buf); // Buffer is overflowed, and overwrites ptr.
*ptr = 5; // Writes to an attacker-controlled address!
// Canaries can't stop this kind of thing.
堆对象溢出(函数指针,C++ vtables)。
malloc
/free
溢出
malloc 示例:
int main(int argc, char **argv) {
char *p, *q;
p = malloc(1024);
q = malloc(1024);
if(argc >= 2)
strcpy(p, argv[1]);
free(q);
free(p);
return 0;
}
假设属于
p
和q
的两个内存块在内存中是相邻/附近的。假设
malloc
和free
表示内存块如下:
malloc 内存块:
+----------------+
| |
| App data |
| | Allocated memory block
+----------------+
| size |
+----------------+
+----------------+
| size |
+----------------+
| ...empty... |
+----------------+
| bkwd ptr |
+----------------+
| fwd ptr | Free memory block
+----------------+
| size |
+----------------+
因此,在
p
的缓冲区溢出将覆盖q
内存块中的大小值!为什么这是一个问题?当
free()
合并两个相邻的空闲块时,需要操作bkwd
和fwd
指针……并且指针计算使用大小来确定空闲内存块结构的位置!
free()
内部:
p = get_free_block_struct(size);
bck = p->bk;
fwd = p->fd;
fwd->bk = bck; // Writes memory!
bck->fd = fwd; // Writes memory!
空闲内存块表示为 C
struct
;通过破坏大小值,攻击者可以强制free()
在位于攻击者控制的内存中的伪造struct
上操作,并具有攻击者控制的值用于前向和后向指针。如果攻击者知道
free()
如何更新指针,他可以使用该更新代码将任意值写入任意位置。例如,攻击者可以覆盖返回地址。实际细节更加复杂;如果你对血腥细节感兴趣,请访问这里
高层次的观点是,栈金丝雀不会阻止这种攻击,因为攻击者正在“越过”金丝雀并直接写入返回地址!
缓解方法 2: 边界检查
总体目标: 通过检查指针是否在范围内来防止指针误用。
挑战: 在 C 语言中,很难区分有效指针和无效指针。例如,假设一个程序分配了一个字符数组…
char x[1024];
… 以及该数组中的某个位置的指针,例如,
char *y = &x[107];
增加
y
以访问后续元素是否可以?如果
x
代表一个字符串缓冲区,也许是。如果
x
代表一个网络消息,也许不。如果程序使用联合体,情况会变得更加复杂。
联合体示例:
union u{
int i;
struct s{
int j;
int k;
};
};
int *ptr = &(u.s.k); // Does this point to valid data?
问题 是,在 C 语言中,指针不会编码关于该指针的预期使用语义的信息。
因此,很多工具不会尝试猜测这些语义。相反,这些工具的目标并不像“完全正确”的指针语义那样高远:这些工具只是强制执行堆对象和栈对象的内存边界。在高层次上,这是目标:
- 对于从
p
派生出的指针p'
,p'
只能被解引用以访问属于p
的有效内存区域。
- 对于从
强制执行内存边界是一个比强制“完全正确”指针语义更弱的目标。程序仍然可以通过恶意方式践踏其内存而自掘坟墓(例如,在联合体示例中,应用程序可能会写入指针,尽管未定义)。
然而,边界检查仍然很有用,因为它可以防止任意内存覆写。程序只能践踏其内存,如果该内存实际上已分配!这在 C 语言世界中被认为是进步。
边界检查的一个缺点是通常需要对编译器进行更改,并且必须使用新编译器重新编译程序。如果只能访问二进制文件,则这是一个问题。
有哪些实现边界检查的方法?
边界检查方法 #1: 电子围栏
这是一个旧方法,其优点在于简单。
思路: 将每个堆对象与一个守卫页对齐,并使用页表确保对守卫页的访问导致故障。
电子围栏:
+---------+
| Guard |
| | ^
+---------+ | Overflows cause a page exception
| Heap | |
| obj | |
+---------+
这是一种方便的调试技术,因为堆溢出会立即导致崩溃,而不是悄无声息地破坏堆并在未来某个不确定的时间导致失败。
重要优势:无需源代码即可运行:无需更改编译器或重新编译程序!
- 你确实需要重新链接它们,以便它们使用实现电子围栏的新版本的
malloc
。
- 你确实需要重新链接它们,以便它们使用实现电子围栏的新版本的
主要缺点:巨大的开销!每页只有一个对象,并且您有一个未用于“真实”数据的虚拟页面的开销。
摘要:电子围栏可以作为调试技术很有用,并且可以防止堆对象的一些缓冲区溢出。然而,电子围栏无法保护堆栈,并且内存开销太高,无法在生产系统中使用。
**边界检查方法#2:**胖指针
- **想法:**修改指针表示以包含边界信息。现在,指针包括关于生存在该内存区域中的对象的边界信息。
示例:
Regular 32-bit pointer
+-----------------+
| 4-byte address |
+-----------------+
Fat pointer (96 bits)
+-----------------+----------------+---------------------+
| 4-byte obj_base | 4-byte obj_end | 4-byte curr_address |
+-----------------+----------------+---------------------+
- 您需要修改编译器并重新编译程序以使用胖指针。编译器生成的代码会在它解引用地址超出自己的
base ... end
范围的指针时中止程序。
示例:
int *ptr = malloc(sizeof(int) * 2);
while(1){
*ptr = 42; <----------|
ptr++; |
} |
______________________________|
|
This line checks the current address of the pointer and
ensures that it's in-bounds. Thus, this line will fail
during the third iteration of the loop.
**问题#1:**检查所有指针解引用可能很昂贵。C 社区讨厌昂贵的东西,因为 C 就是关于速度速度速度。
**问题#2:**胖指针与许多现有软件不兼容。
你不能将胖指针传递给未修改的库。
你不能在固定大小的数据结构中使用胖指针。例如,
sizeof(that_struct)
将会改变!对胖指针的更新不是原子的,因为它们跨越多个字。一些程序假设指针写入是原子的。
边界检查方法#3:影子数据结构
**想法:**使用影子数据结构来跟踪边界信息(Jones and Kelly,Baggy bounds)。
对于每个分配的对象,存储对象的大小。例如:
记录传递给 malloc 的值:
char *p = malloc(mem_size);
对于静态变量,值由编译器确定:
char p[256];
对于每个指针,我们需要对两个操作进行干预:
指针算术:
char *q = p + 256;
指针解引用:
char ch = *q;
Q: 为什么我们需要对解引用进行干预?不能只进行算术吗?
A: 无效指针并不总是一个错误!例如,数组最后一个元素之外的一个元素的指针可能被用作循环中的停止测试。应用程序还可以执行一些愚蠢的操作,如:
模拟从 1 开始的数组
计算 p+(a-b)为(p+a)-b
生成稍后检查有效性的 OOB 指针
因此,仅仅创建无效指针不应该导致程序失败。
Q: 为什么我们需要对算术进行干预?不能只进行解引用吗?
A: 干预算术是允许我们跟踪指针的来源并设置 OOB 位的原因。没有 OOB,我们将无法确定派生指针何时超出其基本对象的边界。
挑战 1: 我们如何找到常规指针(即在边界内的指针)的边界信息?
天真: 使用哈希表或区间树将地址映射到边界。
好: 空间高效(仅存储正在使用的指针的信息,而不是所有可能的地址)。
不好: 查找慢(每次查找多次内存访问)。
天真: 使用数组存储每个内存地址的边界信息。
好: 快速!
不好: 内存开销很高。
挑战 2: 我们如何强制越界指针解引用失败?
天真: 对每个指针解引用进行检测。
好: 哦,它有效。
不好: 昂贵。我们必须为每次解引用执行额外的代码!
Baggy bounds 方法:5 个技巧
Trick 1: 将每个分配向上舍入为 2 的幂,并将分配的起始对齐到该 2 的幂。
Trick 2: 将每个范围限制表示为
log_2(alloc_size)
。- 对于 32 位指针,只需要 5 位来表示可能的范围:我们只能分配 32 种不同大小的对象:
2¹ = 2 字节,2² = 4 字节...,2³¹ 字节或 2³² 字节
,并且我们存储分配大小的以 2 为底的对数,这是一个介于 1 和 32 之间的数字,因此我们只需要 5 位来表示它。
- 对于 32 位指针,只需要 5 位来表示可能的范围:我们只能分配 32 种不同大小的对象:
Trick 3: 在线性数组中存储限制信息:每个条目一个字节的快速查找。此外,我们可以使用虚拟内存按需分配数组!
Trick 4: 以插槽粒度(例如,16 字节)分配内存:更少的数组条目。
这意味着最小分配大小为 16 字节
…而且,由于指针将对齐到其分配大小边界,这意味着指针的最后 4 位都是零
示例:
slot_size = 16
p = malloc(16); --> table[p/slot_size] = 4;
p = malloc(32); --> table[p/slot_size] = 5;
\-> table[(p/slot_size) + 1] = 5;
Trick 4 (续):
- 现在,给定一个已知的好指针
p
,和一个派生指针p'
,我们可以通过检查这两个指针的地址位中是否有相同的前缀,并且它们只在它们的e
个最低有效位上有所不同,其中e
等于分配大小的对数,来测试p'
是否有效。
- 现在,给定一个已知的好指针
示例:
C code
------
p' = p + i;
Bounds check
------------
size = 1 << table[p >> log_of_slot_size];
base = p & ~(size - 1);
(p' >= base) && ((p' - base) < size)
Optimized bounds check
----------------------
(p^p') >> table[p >> log_of_slot_size] == 0
- Trick 5: 使用虚拟内存系统来防止越界解引用:在 OOB 指针中设置最高有效位,然后将地址空间上半部分的页面标记为不可访问。这样,我们就不必对指针解引用进行检测以防止错误的内存访问!
示例代码(假设slot_size=16
)
char *p = malloc(44); //Note that the nearest power of 2 (i.e.,
//64 bytes) are allocated. So, there are
//64/(slot_size) = 4 bounds table entries
//that are set to log_2(64) = 6.
char *q = p + 60; //This access is ok: It's past p's object
//size of 44, but still within the baggy
//bounds of 64.
char *r = q + 16; //r is now at an offset of 60+16=76 from
//p. This means that r is (76-64)=12 bytes
//beyond the end of p. This is more than
//half a slot away, so baggy bounds will
//raise an error.
char *s = q + 8; //s is now at an offset of 60+8=68 from p.
//So, s is only 4 bytes beyond the baggy
//bounds, which is les than half a slot
//away. No error is raised, but the OOB
//high-order bit is set in s, so that s
//cannot be dereferenced.
char *t = s - 32; //t is now back inside the bounds, so
//the OOB bit is cleared.
对于 OOB 指针,高位被设置(如果 OOB 在半个插槽内)。- 通常,操作系统内核位于上半部分,通过分页硬件保护自身。- 问: 为什么越界是半个插槽?
那么作业问题的答案是什么?
char *p = malloc(256);
char *q = p + 256;
char ch = *q; // Does this raise an exception?
// Hint: How big is the baggy bound for p?
Baggy bounds 论文勘误
Baggy bounds 论文中的一些错误:
图 3,显式边界检查应该生成如下大小:
size = 1 << table[p >> log_of_slot_size]
图 3,优化的边界检查应该是:
(p^p') >> table[p >> log_of_slot_size] == 0
图 5 和 18,指针算术代码应该是以下之一:
char *p = &buf[i];
char *p = buf + i;
Baggy bounds(续)
注意: 这些讲座笔记是从 2014 年 6.858 课程网站上发布的笔记中稍作修改而来。
示例代码:(假设slot_size = 16
)
char *p = malloc(44); // Note that the nearest power of 2 (i.e.,
// 64 bytes) are allocated. So, there are
// 64/(slot_size) = 4 bounds table entries
// that are set to log_2(64) = 6.
char *q = p + 60; // This access is ok: It's past p's object
// size of 44, but still within the baggy
// bounds of 64.
char *r = q + 16; // ERROR: r is now at an offset of 60+16=76
// from p. This means that r is (76-64)=12
// beyond the end of p. This is more than
// half a slot away, so baggy bounds will
// raise an error.
char *s = q + 8; // s is now at an offset of 60+8=68 from p.
// So, s is only 4 bytes beyond the baggy
// bounds, which is less than half a slot
// away. No error is raised, but the OOB
// high-order bit is set in s, so that s
// cannot be derefernced.
char *t = s - 32; // t is now back inside the bounds, so
// the OOB bit is cleared.
对于越界指针,高位被设置(如果在半个插槽内越界)。
通常,操作系统内核位于上半部分,通过分页硬件保护自身。
Q: 为什么要为越界分配半个插槽?
那么作业问题的答案是什么?
char *p = malloc(255);
char *q = p + 256;
char ch = *q; // Does this raise an exception?
// Hint: How big is the baggy bound for p?
宽松边界检查是否必须检测每个内存地址计算和访问?
- 不,静态分析可以证明某些地址始终是安全的。但是,某些地址计算是“不安全”的,因为无法静态确定其值的边界。这些不安全的变量需要检查。
处理函数调用参数有点棘手,因为 x86 调用约定是固定的,即硬件期望栈上的某些内容放在特定位置。
但是,我们可以将不安全的参数复制到一个单独的区域,并确保复制的参数对齐和受保护。
Q: 我们是否必须在函数返回时用复制的值覆盖原始参数?
A: 不,因为在 C 语言中一切都是按值传递的!
宽松边界检查如何确保与现有库的二进制兼容性?
特别是,宽松边界代码如何与由未经检测的代码分配的内存指针交互?
解决方案: 边界表中的每个条目都初始化为值 31,这意味着相应的指针具有 2³¹ 的内存边界(这是所有可寻址内存)。
在经过检测的代码中进行内存分配时,边界条目如前所述设置,并在释放内存时重置为 31。
分配给未经检测的代码的内存永远不会改变边界表条目的默认值 31;因此,当经过检测的代码与这些指针交互时,边界错误永远不会发生
例子:
Contiguous range of
memory used for the
heap
+-------------------+
| |
| |
| Heap allocated by |
| uninstrumented |---+
| code | \ Bounds table
| | \
+-------------------+ \ +-----------+
| | +->| |
| | | Always 31 |
| Heap allocated by | | |
| instrumented code | +-----------+
| | | Set using |
| |--------->| baggy bnds|
+-------------------+ +-----------+
这一切意味着什么?
无法检测在未经检测的代码中生成的越界指针。
无法检测传递给库的越界指针何时再次进入边界内。
Q: 为什么?
A: 因为未经检测的代码中没有指针检查可以清除高位越界位!
Q: 为什么要检测
strcpy()
和memcpy()
?A: 否则,这些函数就是未经检测的代码,并且会遇到我们刚刚讨论过的相同问题。例如,现成的
strcpy()
不能确保目标有足够的空间来存储源!
宽松位如何利用 64 位地址空间?
可以摆脱存储边界信息的表,并将其放入指针中。
Regular pointer
+---------------+-------+------------------------+
| zero | size | supported addr space |
+---------------+-------+------------------------+
21 5 38
OOB pointer
+--------+------+-------+------------------------+
| offset | size | zero | supported addr space |
+--------+------+-------+------------------------+
13 5 8 38
这类似于一个胖指针,但具有以下优点……
标记指针与常规指针大小相同
对它们的写入是原子的
……以便不破坏程序员的期望,并且数据布局保持不变。
还要注意,使用标记指针,我们现在可以跟踪远离基本指针多得多的越界指针。这是因为现在我们可以使用偏移量标记指针,指示它们距离基本指针有多远。在 32 位世界中,如果没有额外的数据结构,我们无法跟踪越界偏移量!
在 baggy bounds 系统中仍然可以发动缓冲区溢出攻击吗?
是的,因为这个世界充满了悲伤。
可能会利用未经检测的库中的漏洞。
可能会利用时间漏洞(使用后释放)。
混合缓冲区和代码指针
例子:
struct {
char buf[256];
void (*f) (void);
} my_type;
请注意*f
不是分配的类型,因此在调用期间与其解引用相关联的边界检查不存在。因此,如果s.buf
溢出(例如,由未经检测的库中的错误引起),并且s.f
被损坏,那么对f
的调用不会导致边界错误!
重新排列 f 和 buf 会有帮助吗?
可能会破坏依赖结构布局的应用程序。
如果这是一个(
struct my_type
)数组,可能不会有帮助。
一般来说,边界检查的成本是什么?
边界信息的空间开销(胖指针或 baggy bounds 表)。
Baggy bounds 还会为 buddy 分配器使用的额外填充内存增加空间开销(尽管所有流行的动态内存分配算法都会有一定程度的开销)。
指针算术和解引用的 CPU 开销。
虚警!
未使用的越界指针。
临时超出边界指针超过
slot_size/2
。指针到整数的转换和反向转换。
将越界指针传递给未经检查的代码(高地址位被设置,因此如果未经检查的代码使用该指针进行算术运算,可能会导致混乱)。
需要大量编译器支持。
因此,baggy bounds 检查是一种减轻有缺陷代码中缓冲区溢出的方法。
实现边界检查的更多方法
方法 4:非可执行内存(AMD 的 NX 位,Windows DEP,W^X 等)
现代硬件允许为内存指定读取、写入和执行权限。(R、W 权限很久以前就有了;执行权限是最近才有的。)
可以将堆栈标记为不可执行,这样对手就无法运行他们的代码。
更一般地,一些系统执行“W^X”,意味着所有内存要么可写,要么可执行,但不能同时。 (当然,既不可写也不可执行也是可以的。)
优势: 可能无需进行任何应用程序更改即可运行。
优势: 硬件一直在监视你,不像操作系统。
缺点: 动态生成代码更困难(尤其是使用 W^X)。
像 Java 运行时、Javascript 引擎这样的 JIT 会即时生成 x86 代码。
可以通过先写入,然后更改为可执行来解决问题。
方法 5:随机化内存地址(ASLR,堆栈随机化等)
观察:许多攻击在 shellcode 中使用硬编码地址![攻击者抓取一个二进制文件并使用 gdb 来找出东西的位置。]
因此,我们可以使攻击者难以猜测有效的代码指针。
堆栈随机化:将堆栈移动到随机位置,并/或在堆栈变量之间放置填充。这使得攻击者更难确定:
当前帧的返回地址位于何处
攻击者的 shellcode 缓冲区将位于何处
随机化整个地址空间(地址空间布局随机化):随机化堆栈、堆、DLL 的位置等。
依赖于很多代码是可重定位的这一事实。
动态加载器可以为每个库、程序选择随机地址。
对手不知道 system()等函数的地址。
这仍然可以被利用吗?
对手可能猜测随机性。特别是在 32 位机器上,没有太多的随机位(例如,1 位属于内核/用户模式划分,12 位不能被随机化,因为内存映射页面需要与页面边界对齐等)。
例如,攻击者可能进行缓冲区溢出并尝试用
usleep(16)
的地址覆盖返回地址,然后查看连接是否在 16 秒后挂起,或者是否崩溃(在这种情况下,服务器会使用相同的 ASLR 偏移量 fork 一个新的 ASLR 进程)。usleep()
可能在 2¹⁶ 或 2²⁸ 个地方之一。更多细节。ASLR 在 64 位机器上更实用(很容易有 32 位的随机性)。
对手可能提取随机性。
程序可能生成包含指针的堆栈跟踪或错误消息。
如果对手可以运行一些代码,他们可能能够提取真实地址(JIT 编译的代码?)。
Flash 的字典(哈希表)中的可爱地址泄漏:
让受害者访问您的 Flash 启用页面(例如,购买广告)。
哈希表在内部计算键的哈希值。
整数的哈希值是整数本身。
对象的哈希值是其内存地址。
遍历哈希表是从最低哈希键到最高哈希键进行的。
因此,攻击者创建一个字典,插入一个包含 shellcode 的字符串对象,然后向字典中插入一堆数字。
通过遍历字典,攻击者可以确定字符串对象位于何处,看看对象引用落在哪些整数之间!
现在,用 shellcode 地址覆盖代码指针并绕过 ASLR!
对手可能不关心确切要跳转到哪里。
- 例如:“堆喷洒”:填充内存以便随机跳转是可以的!
对手可能利用一些未随机化的代码(如果存在这样的代码)。
随机化的一些其他有趣用途:
系统调用随机化(每个进程有自己的系统调用号码)。
指令集随机化,以便攻击者不能轻易确定特定程序实例的"shellcode"是什么样子。
- 例子: 想象一下,处理器有一个特殊的寄存器来保存“解码密钥”。每个特定应用程序的安装都与一个随机密钥相关联。应用程序中的每条机器指令都与该密钥进行异或运算。当操作系统启动进程时,它设置解码密钥寄存器,处理器使用此密钥解码指令后再执行它们。
实际上使用了哪些缓冲区溢出防御措施?
gcc 和 MSVC 默认启用栈保护。
Linux 和 Windows 默认包含 ASLR 和 NX。
由于:
性能开销
需要重新编译程序
误报:安全工具中的常见主题:误报阻止了工具的采用!通常,一些遗漏但没有误报比零遗漏但有误报更好。
返回导向编程(ROP)
ASLR 和 DEP 是非常强大的防御技术。
DEP 防止攻击者执行自己选择的栈代码。
ASLR 可以防止攻击者确定 shellcode 或返回地址的位置。
但是,如果攻击者能够找到位于已知位置的具有已知功能的预先存在的代码呢?那么,攻击者可以调用该代码来做坏事。
当然,预先存在的代码并不是故意恶意,因为它是应用程序的正常部分。
但是,攻击者可以传递意外的参数给该代码,或者跳转到代码的中间并仅执行该代码的所需部分。
这种攻击称为返回导向编程,或ROP。为了理解 ROP 的工作原理,让我们看一个具有安全漏洞的简单 C 程序。示例改编自此处。
void run_shell(){
system("/bin/bash");
}
void process_msg(){
char buf[128];
gets(buf);
}
假设系统不使用 ASLR 或栈保护,但使用了 DEP。process_msg()
存在明显的缓冲区溢出,但攻击者无法利用此溢出在buf
中执行 shellcode,因为 DEP 使栈不可执行。然而,run_shell()
函数看起来很诱人… 攻击者如何执行它?
攻击者反汇编程序并找出
run_shell()
的起始地址在哪里。攻击者发起缓冲区溢出,并用
run_shell()
的地址覆盖process_msg()
的返回地址。砰!攻击者现在可以访问以应用程序权限运行的 shell。
例子:
+------------------+
entry %ebp ----> | .. prev frame .. |
| |
| |
+------------------+
entry %esp ----> | return address | ^ <--Gets overwritten
+------------------+ | with address of
new %ebp ------> | saved %ebp | | run_shell()
+------------------+ |
| buf[127] | |
| ... | |
| buf[0] | |
new %esp ------> +------------------+
这是我们已经看过的缓冲区溢出的直接扩展。但是我们如何向我们要跳转到的函数传递参数呢?
char *bash_path = "/bin/bash";
void run_cmd(){
system("/something/boring");
}
void process_msg(){
char buf[128];
gets(buf);
}
在这种情况下,我们要传递的参数已经位于程序代码中。程序中还存在一个对system()
的调用,但该调用并未传递我们想要的参数。
我们知道system()
必须与我们的程序链接。因此,使用我们可靠的朋友 gdb,我们可以找到system()
函数的位置以及 bash_path 的位置。
要使用bash_path
参数调用system()
,我们必须设置堆栈,以便在跳转到它时,system()
期望堆栈上有这些内容:
| ... |
+------------------+
| argument | The system() argument.
+------------------+
%esp ----> | return addr | Where system() should
+------------------+ ret after it has
finished.
因此,缓冲区溢出需要设置一个看起来像这样的堆栈:
+------------------+
entry %ebp ----> | .. prev frame .. |
| |
| |
| * - - - - - - - | ^
| | | Address of bash_path
+ * - - - - - - - | |
| | | Junk return addr for system()
+------------------+ |
entry %esp ----> | return address | | Address of system()
+------------------+ |
new %ebp ------> | saved %ebp | | Junk
+------------------+ |
| buf[127] | |
| ... | | Junk
| buf[0] | |
new %esp ------> +------------------+ |
本质上,我们所做的是为system()
调用设置了一个虚假的调用帧!换句话说,我们模拟了编译器如果真的想要设置一个对system()
的调用会做什么。
如果字符串"/bin/bash"
不在程序中怎么办?
我们可以将该字符串包含在缓冲区溢出中,然后使
system()
的参数指向该字符串。| h\0 | ^ | * - - - - - - - | | | /bas | | | * - - - - - - - | | | /bin | | <--------------------+ | * - - - - - - - | | | | | | Address of bash_path--+ + * - - - - - - - | | | | | Junk return addr from system() +------------------+ | entry %esp ----> | return address | | Address of system() +------------------+ | new %ebp ------> | saved %ebp | | Junk +------------------+ | | buf[127] | | | ... | | Junk | buf[0] | | new %esp ------> +------------------+ |
请注意,在这些示例中,我一直假设攻击者使用了来自system()
的无用返回地址。然而,攻击者也可以将其设置为有用的内容。
实际上,通过将其设置为有用的内容,攻击者可以链接调用在一起!
**目标:**我们想要多次调用system("/bin/bash")
。假设我们找到了三个地址:
system()
的地址字符串"/bin/bash"的地址
这些 x86 操作码的地址:
pop %eax //Pops the top-of-stack and puts it in %eax ret //Pops the top-of-stack and puts it in %eip
这些操作码是“小工具”的一个示例。小工具是预先存在的指令序列,可以串联在一起创建一个利用。请注意,有一些用户友好的工具可以帮助您从现有二进制文件中提取小工具(例如,msfelfscan)。
| | ^
+ * - - - - - - - + |
| | | Address of bash_path -+ Fake calling
+ * - - - - - - - + | | frame for
(4) | | | Address of pop/ret * + system()
+ * - - - - - - - + |
(3) | | | Address of system()
+ * - - - - - - - + |
(2) | | | Address of bash_path -+ Fake calling
+ * - - - - - - - + | | frame for
(1) | | | Address of pop/ret * + system()
+------------------+ |
entry %esp ----> | return address | | Address of system()
+------------------+ |
new %ebp ------> | saved %ebp | | Junk
+------------------+ |
| buf[127] | |
| ... | | Junk
new %esp ------> | buf[0] | |
+------------------+ |
那么,这是如何工作的呢?记住,返回指令弹出栈顶并将其放入%eip。
溢出的函数通过发出
ret
来终止。ret
弹出栈顶(system()
的地址)并将%eip
设置为它。system()
开始执行,%esp
现在在(1),并指向pop/ret
小工具。system()
执行完毕并调用ret
。%esp
从(1)->(2),因为ret
指令弹出栈顶并将其分配给%eip
。%eip
现在是pop/ret
小工具的开始。pop/ret
小工具中的 pop 指令从栈中丢弃bash_path
变量。%esp
现在在(3)。我们仍然在pop/ret
小工具中!pop/ret
小工具中的ret
指令弹出栈顶并将其放入%eip
。现在我们再次在system()
中,并且%esp
在(4)。
等等。
基本上,我们创建了一种新类型的机器,它由堆栈指针驱动,而不是常规指令指针!随着堆栈指针沿着堆栈移动,它执行的小工具的代码来自预先存在的程序代码,数据来自缓冲区溢出创建的堆栈数据。
这种攻击规避了 DEP 保护–我们没有生成任何新代码,只是调用了现有的代码!
栈读取:打败金丝雀
假设:
远程服务器存在缓冲区溢出漏洞。
服务器崩溃并重新启动,如果金丝雀值设置为不正确的值。
当服务器重新启动时,canary 不会重新随机化,ASLR 也不会重新随机化,例如,因为服务器使用 Linux 的 PIE 机制,并且使用
fork()
来创建新的工作进程而不是execve()
。
因此,要确定一个 8 字节的 canary 值:
char canary[8];
for(int i = 1; i <= 8; i++){ //For each canary byte . . .
for(char c = 0; c < 256; c++){ //. . . guess the value.
canary[i-1] = c;
server_crashed = try_i_byte_overflow(i, canary);
if(!server_crashed){
//We've discovered i-th byte of the
//the canary!
break;
}
}
}
此时我们已经有了 canary,但请记住,该攻击假设服务器在崩溃后使用相同的 canary。
猜测一个字节的正确值平均需要 128 次猜测,因此在 32 位系统上,我们只需要 4*128=512
次猜测来确定 canary(在 64 位系统上,我们需要 8*128=1024
次)。
比在 canary 上进行暴力破解攻击要快得多(在具有 16/28 位 ASLR 随机性的 32/64 位系统上,预期猜测次数为
2¹⁵
或2²⁷
)。暴力破解攻击可以使用我们之前讨论过的
usleep(16)
探测。Canary 读取可以扩展到读取缓冲区溢出可以覆盖的任意值!
因此,我们已经讨论了如果服务器在重新生成时不更改 canaries,我们如何能够击败随机化的 canaries。我们还展示了如何使用 gdb 和 gadgets 来执行程序中预先存在的函数,使用攻击者控制的参数。但是如果服务器使用 ASLR 呢?这将阻止您使用离线分析来找到预先存在的函数的位置?
这就是今天讲座论文讨论的内容。该论文假设我们使用的是 64 位机器,所以从现在开始,在本讲座中我们也将假设如此。在这次讨论中,主要的变化是函数参数现在是通过寄存器传递而不是通过栈传递。
盲目返回导向编程
步骤 1:找到一个 stop gadget
stop gadget 是指指向会挂起程序但不会崩溃的代码的返回地址。
一旦攻击者能够击败 canaries,他就可以覆盖溢出函数的返回地址并开始猜测 stop gadget 的位置。如果客户端网络连接突然关闭,猜测的地址不是 stop gadget。如果连接保持打开,那么该 gadget 就是 stop gadget。
步骤 2:找到弹出栈中条目的 gadgets
一旦你有了一个 stop gadget,你可以用它来找到其他将栈中条目弹出并存入寄存器的 gadgets。
定位栈弹出 gadgets 的三个构建块:
probe: 潜在的栈弹出 gadget 的地址
stop: stop gadget 的地址
crash: 非可执行代码的地址(0x0)
示例: 找到一个弹出栈中一个元素的 gadget。
sleep(10)
^ ^
+--- pop rax / \
| ret / \
| \--->[stop] 0x5.... 0x5....
| [trap] 0x0 0x0 <-----------------+
+----------[probe] 0x4...8 0x4...c -->xor rax, rax | Crash!
ret |
\__________|
当你这样做很多次后,你将拥有一系列弹出栈中一个元素然后返回的 gadgets。然而,你不会知道这些 gadgets 将弹出的值存储在哪个 寄存器 中。
你需要知道哪些寄存器用于存储数据,以便您可以发出系统调用。每个系统调用都期望其参数在一组特定的寄存器中。
请注意,我们也不知道
syscall()
库函数的位置。
步骤 3:找到 syscall() 并确定 pop gadgets 使用哪些寄存器
pause()
是一个不带参数的系统调用(因此忽略寄存器中的所有内容)。要找到
pause()
,攻击者在栈上链接所有的"pop x; ret"
小工具,将pause()
的系统调用号作为每个小工具的"参数"推送进去。在链的底部,攻击者放置了syscall()
的猜测地址。| | ^ + * - - - - - - - + | | | | Guessed addr of syscall() + * - - - - - - - + | | | | ... + * - - - - - - - + | | | | Sys call # for pause + * - - - - - - - + | | | | Address of pop rsi; ret //Gadget 2 + * - - - - - - - + | | | | Sys call # for pause +------------------+ | entry %esp ----> | return address | | Address of pop rdi; ret //Gadget 1 +------------------+ | new %ebp ------> | saved %ebp | | Junk +------------------+ | | buf[127] | | | ... | | Junk new %esp ------> | buf[0] | | +------------------+ |
因此,在这个链的末端,弹出小工具已经将pause()
的系统调用号放入了一堆寄存器中,希望包括rax
,这是syscall()
查找系统调用号的寄存器。
一旦这个超级小工具引发了暂停,我们就知道已确定了syscall()
的位置。现在我们需要确定哪个小工具将栈顶弹出到rax
中。攻击者可以通过逐步尝试一个小工具并查看是否可以调用pause()
来弄清楚这一点。
要识别任意的"pop x; ret"
小工具,可以使用与您试图找到的x
寄存器相关的其他系统调用的技巧。
因此,这个阶段的结果是知道"pop x; ret"
小工具,syscall()
的位置。
第 4 步:调用 write()
现在我们想要在服务器与攻击者客户端之间的网络套接字上调用写入调用。我们需要以下小工具:
pop rdi; ret (socket)
pop rsi; ret (buffer)
pop rdx; ret (length)
pop rax; ret (write syscall number)
syscall
我们必须猜测套接字的值,但这在实践中相当容易,因为 Linux 将进程限制为同时打开 1024 个文件描述符,并且新文件描述符必须是可用的最低文件描述符(因此猜测一个小文件描述符在实践中效果很好)。
要测试我们是否猜对了文件描述符,只需尝试写入并查看是否收到任何内容!
一旦我们有了套接字号码,我们发出一个写入请求,发送的数据是指向程序的.text
段的指针!这使得攻击者可以读取程序的代码(虽然已随机化,但现在完全为攻击者所知!)。现在攻击者可以直接找到更强大的小工具,并利用这些小工具打开一个 shell。
防御 BROP 攻击
每次崩溃后重新随机化 canaries 和地址空间!
使用
exec()
代替fork()
来创建进程,因为fork()
会将父进程的地址空间复制给子进程。有趣的是,Windows 不容易受到 BROP 攻击的影响,因为 Windows 没有
fork()
的等效功能。
崩溃后休眠?
- 现在 BROP 攻击是一种拒绝服务攻击!
边界检查?
- 高达 2 倍的性能开销…
有关 ROP 和 x86 调用约定的更多信息
OKWS
注意: 这些讲座笔记略有修改,来自 2014 年 6.858 课程网站上发布的笔记。
今天的讲座:如何在 Unix 上构建一个安全的 Web 服务器。我们实验室 Web 服务器 zookws 的设计灵感来自于 OKWS。
特权分离
大型安全理念
将系统分割成各自具有特权的模块
- 想法: 如果一个模块被破坏,那么其他模块就不会被破坏
经常使用:
虚拟机(例如,在自己的虚拟机中运行网站)
SSH(分离 sshd、agent)
挑战:
模块需要共享
需要操作系统支持
需要仔细使用操作系统正确设置事物
性能
OKWS
特权分离的有趣案例研究
服务之间有很多共享
- 严格的分区不起作用
大量的代码
在 OKcupid 之外并不广泛使用
许多网站都有他们的特权分离计划
但没有描述他们计划的论文
背景:Unix 中的安全和保护
典型的主体:用户 ID、组 ID(32 位整数)。
每个进程都有一个用户 ID(uid)和一组组 ID(gid + grouplist)。
出于大多是历史原因,一个进程有一个 gid +额外的组列表。
超级用户主体(root)由
uid=0
表示,绕过大多数检查。
Unix 中的对象+操作是什么,操作系统如何进行访问控制?
文件、目录。
文件操作:读、写、执行、更改权限,…
目录操作:查找、创建、删除、重命名、更改权限,…
每个 inode 都有一个所有者用户和组。
每个 inode 对于用户、组、其他人都有读、写、执行权限。
通常表示为写入基数 8(八进制)的位向量;
- 八进制很好用,因为每个数字是 3 位(读、写、执行)。
谁可以更改文件的权限?
- 只有用户所有者(进程 UID)。
对文件进行硬链接:需要对文件有写权限。
可能的理由:配额。
可能的理由:防止将
/etc/passwd
硬链接到具有全局可写/var/mail
的/var/mail/root
。
目录的执行意味着能够查找名称(但不能 ls)。
检查进程打开文件
/etc/passwd
:必须能够在
/
中查找'etc'
,在/etc
中查找'passwd'
。必须能够打开
/etc/passwd
(读或读写)。
假设你想要文件对 group1 和 group2 的交集可读。
- 在 Unix 中是否可能实现这一点?
文件描述符。
文件打开时执行的文件访问控制检查。
一旦进程有一个打开的文件描述符,就可以继续访问。
进程可以传递文件描述符(通过 Unix 域套接字)。
进程。
你可以对一个进程做什么?
- 调试(ptrace),发送信号,等待退出并获取状态,
调试,发送信号:必须具有相同的 UID(几乎)。
- 各种例外,在实践中这变得棘手。
等待/获取退出状态:必须是该进程的父进程。
内存。
一个进程通常不能命名另一个进程的内存。
例外:调试机制。
例外:内存映射文件。
网络。
操作。
绑定到一个端口。
连接到某个地址。
读/写连接。
发送/接收原始数据包。
规则:
只有 root(UID 0)可以绑定到低于 1024 的端口;
(例如,任意用户不能在端口 80 上运行 Web 服务器。)
只有 root 可以发送/接收原始数据包。
任何进程都可以连接到任何地址。
只能读取/写入进程具有文件描述符的连接上的数据。
此外,防火墙施加自己的检查,与进程无关。
进程的主体是如何设置的?
系统调用:
setuid()
、setgid()
、setgroups()
。只有 root(UID 0)可以调用这些系统调用(粗略估计)。
用户 ID、组 ID 列表从哪里获取?
在典型的 Unix 系统上,登录程序以 root(UID 0)身份运行。
检查提供的用户密码是否与
/etc/shadow
中的匹配。根据
/etc/passwd
找到用户的 UID。根据
/etc/group
找到用户的组。在运行用户的 shell 之前调用
setuid()
、setgid()
、setgroups()
。
在切换到非 root 用户后如何重新获得权限?
可以使用文件描述符传递(但必须编写专门的代码)
内核机制:setuid/setgid 二进制文件。
当执行二进制文件时,将进程 UID 或 GID 设置为二进制文件所有者。
通过文件权限中的特殊位指定。
例如,
su
/sudo
二进制文件通常是 setuid root。即使您的 shell 不是 root,也可以运行
"su otheruser"
。su
进程会检查密码,如果正确则以otheruser
身份运行 shell。Unix 上有许多这样的程序,因为通常需要 root 权限。
为什么 setuid 二进制文件在安全方面可能是个坏主意?
对手(二进制调用者)操纵进程的许多方法。
在 Unix 中,执行的进程会继承环境变量、文件描述符等。
setuid 程序可能使用的库不够谨慎
历史上存在许多漏洞(例如传递
$LD_PRELOAD
,…)
如何防止恶意程序利用 setuid-root 二进制文件?
内核机制:chroot
通过路径名打开文件时更改
/
的含义。不能在 chroot 树之外命名文件(例如 setuid 二进制文件)。
例如,OKWS 使用 chroot 将程序限制在
/var/okws/run
中,…内核还确保
/../
不允许从 chroot 中逃脱。为什么只允许 root 使用 chroot?
setuid 二进制文件(如
su
)可能会混淆/etc/passwd
的内容。许多内核实现(无意中?)允许递归调用
chroot()
以从 chroot 监狱中逃脱,因此 chroot 对于以 root 身份运行的进程来说不是一种有效的安全机制。
为什么 chroot 没有被修复以限制根进程在该目录中?
- Root 可以写入内核内存,加载内核模块,访问磁盘扇区,…
背景:传统的 Web 服务器架构(Apache)
Apache 运行
N
个相同的进程,处理 HTTP 请求。所有进程都以用户
'www'
身份运行。应用程序代码(例如 PHP)通常在每个
N
个 Apache 进程中运行。任何对操作系统状态(文件、进程等)的访问都由
www
的 UID 执行。存储:SQL 数据库,通常一个连接具有对整个数据库的完全访问权限。
- 数据库主体是整个应用程序。
问题:如果任何组件被攻破,对手将获得所有数据。
Web 应用可能会发生哪种攻击?
无意中的数据泄露(获取页面源代码,隐藏文件,…)
远程代码执行(例如,Apache 中的缓冲区溢出)
有 bug 的应用程序代码(难以编写安全的 PHP 代码),例如 SQL 注入
对 Web 浏览器的攻击(跨站脚本攻击)
回到 OKWS:他们的应用/动机是什么?
约会网站:担心数据保密性。
不太担心对手闯入并发送垃圾邮件。
大量的服务器端代码执行:匹配,配置文件更新,…
必须在用户之间共享(例如匹配)-- 不能只是分区。
对整体计划的良好总结:
- “最容易受攻击的方面对攻击者最无用”
为什么这么难?
Unix 使降低权限变得棘手(chroot,UID,…)
应用程序需要以复杂的方式共享状态。
Unix 和 SQL 数据库没有细粒度的共享控制机制。
OKWS 如何分割 Web 服务器?
论文中的图 1。
这个 Web 服务器中的请求是如何流动的?
okd -> oklogd
-> pubd
-> svc -> dbproxy
-> oklogd
这种设计如何映射到物理机器?
可能有许多前端机器(
okld
,okd
,pubd
,oklogd
,svc
)几台 DB 机器(
dbproxy
,DB)
这些组件如何互动?
okld
为每个服务设置socketpair
s(双向管道)。一个用于控制 RPC 请求的套接字对(例如,“获取新的日志套接字对”)。
用于日志记录的一个套接字对(
okld
首先通过 RPC 从oklogd
获取它)。对于 HTTP 服务:一个用于转发 HTTP 连接的套接字对。
对于
okd
:HTTP 服务的套接字对的服务器端 FD(HTTP+RPC)。
okd
监听一个单独的套接字以接收控制请求(repub,relaunch)。在图 1 中似乎是端口 11277,但在 OKWS 代码中是 Unix 域套接字。
对于repub,
okd
与pubd
通信以生成新模板,- 然后通过 RPC 控制通道将生成的模板发送给每个服务。
服务通过 TCP 与 DB 代理通信(通过端口号连接)。
OKWS 如何在图 1 中的组件之间强制隔离?
每个服务作为单独的 UID 和 GID 运行。
chroot 用于将每个进程限制在单独的目录中(几乎)。
组件通过管道(或者说 Unix 域套接字对)进行通信。
用于传递 HTTP 连接的文件描述符传递。
okld
的目的是什么?为什么
okld
不同于okd
?为什么
okld
需要以 root 身份运行?(端口 80,chroot/setuid。)okld
启动服务需要什么?创建套接字对
获取新的
oklogd
套接字fork
,setuid/setgid
,exec
服务将控制套接字传递给
okd
oklogd
的目的是什么?pubd
的目的是什么?为什么我们需要数据库代理?
确保每个服务在受损时无法获取其他数据。
DB 代理协议由应用程序开发人员定义,取决于应用程序的要求。
一个可能常见的代理类型是模板化的 SQL 查询。
代理强制执行整体查询结构(选择、更新),
- 但允许客户端填写查询参数。
20 字节令牌是从哪里来的?
- 作为服务的参数传递。
谁检查令牌?
- DB 代理有令牌列表(和允许的查询?)。
谁生成令牌?
- 不清楚;系统管理员手动?
令牌泄露会怎样?
- 受损组件可能会发出查询。
表 1:为什么所有服务和
okld
都在同一个 chroot 中?这是一个问题吗?
我们如何决定?
- 那里有哪些可读写文件?
可读性:包含服务代码的共享库。
可写:每个服务都可以写入自己的
/cores/<uid>
。配置文件在哪里?
/etc/okws_config
,由okld
保存在内存中。oklogd
和pubd
有单独的 chroots,因为它们具有重要状态:oklogd
的 chroot 包含日志文件,希望确保它没有被修改。pubd
的 chroot 包含模板,希望避免泄露它们(?)。
为什么 OKWS 需要为每个服务单独的 GID?
需要执行二进制文件,但文件所有权允许 chmod。
解决方案:二进制文件由 root 所有,服务是组所有者,模式 0410。
为什么是 0410(用户读取,组执行),而不是 0510(用户读取和执行)?
为什么不按用户处理?
每个用户是否严格更好?
用户 X 服务?
对于 okcupid 来说,每个服务的隔离可能是有道理的。
- (即,也许他们需要在用户之间进行大量共享?)
每个用户隔离需要为每个用户分配 UID,使
okld
变得复杂。- 并降低性能(尽管对于某些用例可能仍然可以接受)。
OKWS 是否实现了其目标?
OKWS 解决了典型 Web 攻击列表中的哪些攻击,以及如何解决?
除了 XSS 之外的大多数问题都已解决。
通过使用专门的模板例程,XSS 在某种程度上得到解决。
每个组件被损坏的影响是什么,以及“攻击面”是什么?
okld
:对 Web 服务器机器的根访问权限,但也许没有对数据库的访问权限。- 攻击面:很小(除了 svc 退出之外没有用户输入)。
okd
:拦截/修改所有用户 HTTP 请求/响应,窃取密码。- 攻击面:解析 HTTP 请求的第一行;控制请求。
pubd
:损坏模板,利用可能利用某些服务中的错误?- 攻击面:从 okd 获取模板的请求。
oklogd
:损坏/忽略/删除/伪造日志条目。- 攻击面:来自 okd、okld、svcs 的日志消息。
service
:向用户发送垃圾,访问 svc 的数据(模块化 dbproxy)。- 攻击面:来自用户的 HTTP 请求(+来自 okd 的控制消息)。
dbproxy
:访问/更改其所连接的数据库中的所有用户数据。攻击面:来自授权服务的请求。
- 未经授权服务的请求(易于丢弃)。
一旦单个服务被损坏,操作系统内核就成为攻击面的一部分。
- Linux 内核漏洞很少见,但每年仍然会出现几次。
OKWS 假设开发人员在设计层面做正确的事情(也许在实现层面不是):
将 Web 应用程序拆分为单独的服务(而不是全部放在一个服务中)。
为 DB 代理定义精确的协议(否则任何服务都可以获取任何数据)。
性能?
似乎比大多数替代方案更好。
在负载下性能更好(因此在一定程度上抵抗 DoS 攻击)
OKWS 与 Apache 相比如何?
总体而言,更好的设计。
okld
以 root 身份运行,与 Apache 中没有任何东西相比,但可能不重要。两者都没有很好的解决客户端漏洞(XSS 等)
对手如何试图破坏类似 OKWS 系统?
利用 C++代码中的缓冲区溢出或其他漏洞。
在某个
dbproxy
中找到 SQL 注入攻击。在服务代码中找到逻辑错误。
发现跨站脚本漏洞。
OKWS 有多成功?
论文中描述的问题仍然相当普遍。
okcupid.com 仍在运行 OKWS,但似乎没有被其他网站使用。
C++可能不是编写 Web 应用程序的好选择。
对于许多 Web 应用程序,获得 C++性能可能并不关键。
设计应该适用于其他语言(Python 等)。
实际上,6.858 实验室中的
zookws
受 OKWS 启发,运行 Python 代码。
对于典型的 Web 应用程序,DB 代理的想法并没有起飞。
但是 DB 代理对于限制服务可以访问的数据至关重要。
为什么?
- 需要开发人员定义这些 API:额外的工作,会妨碍。
很难提前精确定义允许的 DB 查询。
- (尽管如果很困难,可能是安全策略模糊的标志。)
Apache 的特权分离工作(尽管仍然难以使用)。
Unix 使非根用户难以操作用户 ID。
性能是一个问题(为每个请求运行一个单独的进程)。
scripts.mit.edu
有类似的设计,以不同的 UID 运行脚本。主要担心将用户相互隔离。
偏执的 Web 应用程序开发人员可以为每个组件创建单独的锁。
敏感系统在更粗粒度上进行分区。
信用卡处理公司将信用卡数据与其他所有数据分开。
使用虚拟机或物理机器隔离来分割应用程序、数据库等。
你如何将现代 Web 应用程序框架与 OKWS 集成?
需要帮助 okd 找出如何将请求路由到服务。
需要实现 DB 代理,或其变体,以保护数据。
取决于应用代码对静态分析的适应性。
或者需要要求程序员为服务注释可以运行的查询。
需要确保应用代码可以在单独的进程中运行(可能没问题)。
参考资料
能力和其他保护机制
注意: 这些讲座笔记是从 2014 年 6.858 课程网站 上发布的笔记稍作修改而来。
混淆的副手问题
"混淆的副手"的作者遇到了什么问题?
他们的系统有一个 Fortran 编译器,
/sysx/fort
(Unix 文件名语法)他们希望 Fortran 编译器记录使用统计信息,但在哪里?
创建了一个特殊的统计文件,
/sysx/stat
。给了
/sysx/fort
“家庭文件许可证”(类似于关于/sysx 的 setuid)
出了什么问题?
用户可以调用编译器,要求将输出写入
/sysx/stat
。- 例如
/sysx/fort
/my/code.f -o/sysx/stat
- 例如
编译器打开提供的路径名,并成功,因为它的许可证。
用户本身不能写入那个
/sysx/stat
文件。
为什么
/sysx/fort
只是编译器中的一个错误?原则上,可以通过在各个地方添加检查来解决这个问题。
问题:需要在几乎所有打开文件的地方添加检查。
完全正确的代码一旦成为 setuid 二进制文件的一部分就会变得有 bug。
那么什么是“混淆的副手”?
编译器代表两个主体运行:
用户主体(用于打开用户的文件)
编译器主体(用于打开编译器的文件)
不清楚在任何给定时间应该使用主体权限。
我们能在 Unix 中解决这个混淆的副手问题吗?
假设 gcc 想要在
/etc/gcc.stats
中保留统计信息可以有一个特殊的 setuid 程序,只能写入该文件
- 不太方便:不能像打开其他文件那样简单地打开文件。
如果我们让 gcc 成为某个非根用户(统计文件所有者)的 setuid,会怎样?
- 难以访问用户的原始文件。
如果 gcc 是 setuid-root?(坏主意,但让我们弄清楚为什么…)
大量潜在的缓冲区溢出可能导致 root 访问权限。
需要在 gcc 可能打开文件的每个地方进行检测。
当 gcc 打开文件时,我们应该执行什么检查?
如果是“内部”文件(例如
/etc/gcc.stats
),也许不需要检查。如果是用户提供的文件,需要确保用户可以访问它。
可以查看相关文件的权限。
还需要检查导致该文件的目录的权限。
潜在问题:竞争条件。
如果文件在我们检查和使用之间发生更改会怎么样?
常见的漏洞:攻击者用符号链接替换合法文件
符号链接可能指向,比如
/etc/gcc.stats
,或/etc/passwd
,或…被称为“检查时间到使用时间”的错误(TOCTTOU)。
对这个问题有几种可能的思考方式:
环境权限: 进程自动使用的权限是问题所在。任何权限都不应该自动使用。对象的名称也应该是访问它的权限。
复杂的权限检查: 特权应用程序难以复制。通过简化的检查,特权应用程序可能能够正确检查另一个用户是否应该访问某个对象。
什么是环境权限的例子?
Unix 用户 ID,组 ID。
防火墙(IP 地址与访问权限)
HTTP cookies(例如,访问 http://gmail.com 这样的 URL)
通过能力给对象命名有什么帮助?
传递文件描述符而不是传递文件名。
除非调用者被授权打开该文件,否则无法传递有效的 FD。
我们能否使用文件描述符解决通过 setuid gcc 设置的问题?
类似:可以使编译器仅通过 FD 传递接受文件。
或者,可以创建一个 setuid 辅助程序,打开
/etc/gcc.stats
文件,将一个打开的文件描述符传递回我们的编译器进程。然后,可以继续像处理任何其他文件一样使用这个打开的文件。
如何确保只有 gcc 可以运行这个辅助程序?
使 gcc 设置为某个特殊组的 setgid。
使辅助程序仅对该特殊组可执行。
确保该组没有其他授予的特权。
Capsicum 作者试图通过能力解决什么问题?
在各种应用程序中降低不可信代码的特权。
总体计划:
将应用程序分解为较小的组件。
减少最容易受攻击的组件的特权。
仔细设计接口,以便一个组件无法危害另一个组件。
为什么这么困难?
在传统的 Unix 系统中难以降低代码的特权(“沙盒”)。
难以为沙盒化代码提供有限的访问权限(对文件、网络等)。
什么样的应用程序可能会使用沙盒化?
OKWS
处理网络输入的程序:
- 将输入处理代码放入沙盒中。
复杂操作数据的程序:(gzip,Chromium,媒体编解码器,浏览器插件,…)
- 将复杂(且可能有错误)的部分放入沙盒中。
从互联网下载的任意程序怎么样?
稍微不同的问题:需要隔离未修改的应用程序代码。
一个选择:程序员编写他们的应用程序以在沙盒中运行。
在某些情况下有效:Javascript,Java,Native Client,…
需要在沙盒代码上制定一个环境标准。
另一个选择:对现有代码施加新的安全策略。
可能需要保留程序员正在使用的所有 API。
需要对现有 API 施加检查,在那种情况下。
不清楚访问文件、网络等的策略应该是什么。
希望避免被欺骗误用特权的应用程序?
假设两个 Unix 用户,Alice 和 Bob,正在某个项目上工作。
两者都在某个组
G
中,并且项目dir
允许该组访问。假设 Alice 从项目目录向某人发送一个文件。
风险:Bob 可能用符号链接替换文件为 Alice 的私人文件。
Alice 的进程将隐式使用 Alice 的环境特权来打开。
可以将这看作对单个文件操作进行沙盒化。
有哪些沙盒化计划(机制)存在(优势,限制)?
操作系统通常提供某种安全机制(“原语”)。
- 例如,在 Unix 中的用户/组 ID,正如我们在上一堂课中看到的。
今天,我们将研究操作系统级别的安全原语/机制。
当您关心保护操作系统管理的资源时通常是一个很好的选择。
例如,文件,进程,粗粒度内存,网络接口等。
许多操作系统级别的沙箱机制在进程级别工作。
适用于可以作为一个单元进行隔离的整个进程。
可能需要重新设计应用程序以创建用于隔离的进程。
其他技术可以提供更细粒度的隔离(例如,在 proc 中的线程)。
语言级别的隔离(例如,Javascript)。
二进制仪器化(例如,Native Client)。
为什么我们需要这些其他的沙箱技术?
更容易控制对非操作系统/更细粒度对象的访问。
或者也许可以以与操作系统无关的方式进行沙箱化。操作系统级别的隔离通常与更细粒度的隔离结合使用。
更细粒度的隔离通常很难做到正确(Javascript,NaCl)。例如,Native Client 同时使用了细粒度沙箱和操作系统级别的沙箱。
将在后续讲座中更详细地讨论这些问题。
计划 0:虚拟化所有内容(例如,VMs)。
在虚拟化环境中运行不可信代码。
许多示例:x86 qemu,FreeBSD jails,Linux LXC,…
几乎是一种不同类别的机制:严格隔离。
优势:VM 内部的沙箱代码几乎与外部没有交互。
优势:可以沙箱未经修改的代码,不期望被隔离。
优势:一些 VM 可以由任意用户启动(例如,qemu)。
优势:通常与其他隔离技术可组合,提供额外层次。
缺点:难以允许一些共享:没有共享进程,管道,文件。
缺点:虚拟化所有内容通常会使 VM 相对较重。
- 每个沙箱都会带来非常重要的 CPU/内存开销。
计划 1:自主访问控制(DAC)。
每个对象都有一组权限(访问控制列表)。
例如,Unix 文件,Windows 对象。
“自主”意味着应用程序在对象上设置权限(例如,
chmod
)。
每个程序都以某些主体的权限运行。
- 例如,Unix 用户/组 ID,Windows SIDs。
当程序访问对象时,检查程序的权限以决定。
“环境特权”:每次访问都隐式使用的权限。
Name Process privileges
| |
V V
Object -> Permissions -> Allow?
如何在 DAC 系统上(例如,Unix)沙箱化程序?
必须分配一个新的主体(用户 ID):
- 否则,现有主体的权限将被隐式使用!
防止进程读取/写入其他文件:
在整个文件系统上更改权限?繁琐,不切实际,需要 root 权限。
即使如此,新程序也可以创建重要的可全球写入文件。
替代方案:
chroot
(同样,必须是 root)。
允许进程读/写某个文件:
如果可能的话,适当设置文件的权限。
将文件链接/移动到沙箱的
chroot
目录中?
防止进程访问网络:
Unix 中没有真正的答案。
可能配置防火墙?但不是真正针对进程的。
允许进程访问特定的网络连接:
- 如上所述,在 Unix 中没有很好的计划。
控制沙盒可以杀死 / 调试 / 等的进程:
可以在相同的 UID 下运行,但可能特权太多。
该 UID 也可能具有其他特权…
**问题:**在大多数 DAC 系统上,只有 root 可以创建新的主体。
- 例如,Unix,Windows。
**问题:**一些对象可能没有明确可配置的访问控制列表。
- Unix:进程,网络,…
**问题:**文件上的权限可能与沙盒所需的策略不匹配。
- 可以通过使用
chroot
对文件进行某种程度的解决,但很麻烦。
- 可以通过使用
**相关问题:**使用子特权执行某些操作。
回想一下 Alice 通过电子邮件将文件发送到共享组目录的示例。
- “混淆副手问题”:程序是多个主体的“副手”。
*一个解决方案:*检查组权限是否允许访问(手动,容易出错)。
*替代方案:*明确为每个操作指定特权。
权限可以帮助:能力(例如,fd)结合了对象 + 特权。
一些 Unix 功能与纯能力设计不兼容(按名称创建符号链接)。
计划 2:强制访问控制(MAC)。
在 DAC 中,安全策略由应用程序自身设置(chmod 等)。
MAC 试图帮助用户/管理员为应用程序指定策略。
*“强制”*意味着应用程序无法更改此策略。
传统的 MAC 系统试图强制执行军事机密级别。
*示例:*确保绝密程序无法泄露机密信息。
Name Operation + caller process
| |
V V
Object --------> Allow?
^
|
Policy -----------+
*注意:*许多系统在其中具有 DAC + MAC 的方面。
例如,Unix 用户 ID 是“DAC”,但可以争论防火墙是“MAC”。
并不重要–了解设计空间中的极端点是很好的。
Windows 强制完整性控制(MIC)/ FreeBSD 中的 LOMAC。
为每个进程跟踪“完整性级别”。
文件与其关联的最低完整性级别。
进程无法写入高于其完整性级别的文件。
- Windows Vista 中的 Internet Explorer 以低完整性运行,无法覆盖系统文件。
FreeBSD LOMAC 还跟踪进程读取的数据。
(类似于许多基于信息流的系统。)
当进程读取低完整性数据时,它也变得低完整性。
传递性,防止对手间接篡改文件。
对于沙盒化不是立即有用:只有固定数量的级别。
SElinux
*想法:*系统管理员指定系统范围的安全策略。
策略文件指定是否应允许或拒绝每个操作。
为了帮助决定是否允许/拒绝,文件标记为“类型”。
- (另一个整数值,与 inode 中的 uid、gid 等一起存储。)
Mac OS X 沙盒(“Seatbelt”)和 Linux
seccomp_filter
。应用程序为是否允许/拒绝每个系统调用指定策略。
- (在 MacOSX 的机制中用 LISP 编写,或者在 Linux 中用 BPF 编写。)
根据参数确定系统调用的安全影响可能很困难。
路径名指的是什么?符号链接,硬链接,竞争条件,…
(尽管 MacOSX 的沙盒提供了更多信息)
优势: 任何用户都可以对任意代码片段进行沙盒化!
限制: 程序员必须分别编写策略和应用代码。
限制: 有些操作只能以粗粒度进行过滤。
- 例如,在 MacOSX 的过滤语言中的 POSIX
shm
,根据 Capsicum 论文。
- 例如,在 MacOSX 的过滤语言中的 POSIX
限制:策略语言可能使用起来很尴尬,无状态等。
- 例如,如果应用程序应该与某个服务器建立精确的一个连接?
注意:
seccomp_filter
与常规/旧版seccomp
有很大不同,而 Capsicum 论文讨论的是常规/旧版seccomp
。
将策略与应用代码分离是否是个好主意?
取决于总体目标。
如果用户/管理员想查看或更改策略,可能会很有用。
如果应用程序开发人员需要同时维护代码和策略,这将成为问题。
对应用程序开发人员来说,可能有助于澄清策略。
较少集中的“MAC”系统(Seatbelt、
seccomp
)提供了一种折衷方案。
待办事项: 还要看看《赛里斯墙安全策略》
计划 3:能力(Capsicum)。
不同的访问控制计划:能力。
如果进程有某个对象的句柄(“能力”),就可以访问它。
能力 --> 对象
没有特权、访问控制列表、策略等的单独问题。
例如:Unix 上的文件描述符是文件的能力。
程序无法制造未经合法获取的文件描述符。
- 为什么不? 操作系统创建和管理文件描述符。应用程序无法伪造文件描述符。它必须通过漏洞写入操作系统内存。
一旦文件打开,就可以访问它;检查发生在打开时。
可以将打开的文件传递给其他进程。
- 文件描述符也有助于解决“检查时间与使用时间”(TOCTTOU)漏洞。
能力通常是短暂的:不是磁盘上的 inode 的一部分。
- 启动程序的任何内容都需要每次重新创建能力。
全局命名空间
为什么这些人如此着迷于消除全局命名空间?
全局命名空间需要一些访问控制策略(例如,环境权限)。
难以控制沙盒对全局命名空间中对象的访问。
内核更改
只是为了再次确认:为什么我们需要内核更改?
我们能否将所有内容都实现在一个库中(并通过 LD_PRELOAD 加载)?
需要操作系统在进入能力模式后拒绝应用程序访问全局命名空间
将更多内容表示为文件描述符:进程(pdfork)。
- 一般来说是个好主意。
能力模式: 一旦进程进入 cap 模式,就无法离开(包括所有子进程)。
在能力模式下,只能使用文件描述符 – 没有全局命名空间。
不能通过完整路径名打开文件:不需要像 OKWS 中的
chroot
。仍然可以通过相对路径名打开文件,给定目录的 fd(
openat
)。
不能在路径名或符号链接中使用“…”:为什么?
原则上,“…” 可能没问题,只要“…” 不走得太远。
难以正确执行。
假设设计:
禁止在根能力中查找“…”。
路径名中的非“…”组件不得比“…”多,忽略“.”。
假设一个进程对
/foo
拥有能力C1
。在单个进程中的竞争条件,有 2 个线程:
竞争条件示例:
T1: mkdir(C1, "a/b/c")
T1: C2 = openat(C1, "a")
T1: C3 = openat(C2, "b/c/../..") # should return a cap for /foo/a
Let openat() run until it's about to look up the first ".."
T2: renameat(C1, "a/b/c", C1, "d")
T1: Look up the first "..", which goes to "/foo"
Look up the second "..", which goes to "/"
…
Unix 权限仍然适用吗?
是的 – 仅因为你对目录有一个 cap,就不能访问目录中的所有文件。
但意图是沙盒不应依赖 Unix 权限。
对于文件描述符,添加一个存储允许操作的包装对象。
内核在哪里检查能力?
内核中的一个函数查找 fd 号码 – 修改它以检查能力。
还修改了查找路径名的
namei
函数。良好实践: 寻找窄接口,否则容易忽略检查。
libcapsicum
应用程序开发人员为什么需要这个库?
最大的功能:在沙盒中启动新进程。
fd 列表
主要是将大量文件描述符传递给子进程的便捷方式。
通过字符串命名文件描述符,而不是硬编码的 fd 号码。
cap_enter()
vslch_start()
使用
exec
而不是cap_enter
进行沙盒化的优势是什么?内存中的残留数据:例如 OpenSSL/OpenSSH 中的私钥。
应用程序忘记关闭的残留文件描述符。
论文中的图 7:
tcpdump
在stdin
、stdout
、stderr
上具有特权。论文中的图 10:
dhclient
具有原始套接字,syslogd
管道,租约文件。
优点: 任何进程都可以创建一个新的沙盒。
- (即使沙盒也可以创建沙盒。)
优点: 对资源访问的细粒度控制(如果它们映射到 FD)。
- 文件、网络套接字、进程。
缺点: 对持久文件访问跟踪的故事较弱。
缺点: 禁止全局命名空间,需要以不同方式编写代码。
替代性的能力设计:纯能力为基础的操作系统(KeyKOS 等)。
内核只提供消息传递服务。
消息传递通道(非常类似文件描述符)是能力。
每个应用程序都必须以能力样式编写。
Capsicum 声称更加务实:一些应用程序无需更改。
Linux 能力:解决不同的问题。
尝试将根的特权划分为更细粒度的特权。
由各种能力表示:
CAP_KILL, CAP_SETUID
,CAP_SYS_CHROOT
, …进程可以以特定能力运行,而不是以 root 的所有特权。
在应用程序中使用 Capsicum
计划: 确保沙盒化进程不使用路径名或其他全局 NS。
对于可能需要访问的每个目录,提前打开 FD。
要打开文件,请使用从这些目录 FD 开始的
openat()
。- … 打开大量文件的程序可能会很麻烦。
tcpdump
2 行版本:在打开所有 FD 后只需
cap_enter()
。使用
procstat
查看生成的能力。8 行版本:还限制
stdin
/stdout
/stderr
。为什么?避免读取
stderr
日志,更改终端设置,…
dhclient
- 已经进行了特权分离,使用 Capsicum 来加强沙盒(2 行)。
gzip
分叉/执行沙盒化的子进程,通过管道使用 RPC 向其提供数据。
非平凡的更改,主要是为了为 RPC 编组/解组数据:409 行代码。
*有趣的错误:*一开始忘记传播压缩级别。
Chromium
在其他平台上已经进行了特权分离(但在 FreeBSD 上没有)。
~100 行代码用于为沙盒化进程包装文件描述符。
OKWS
- 家庭作业问题有哪些不同的答案?
Capsicum 是否实现了其目标?
使用起来有多难/容易?
在应用程序中使用 Capsicum 几乎总是需要应用程序更改。
(许多应用程序倾向于通过路径名打开文件等。)
一个例外:Unix 管道应用程序(过滤器)只操作 FD。
对通过 FD 处理数据的流式应用程序更容易。
其他隔离需要类似的更改(例如,
dhclient
,Chromium)。对于现有应用程序,延迟初始化似乎是一个问题。
- 没有通用解决方案——要么更改代码,要么早期初始化。
建议的计划:沙盒化并查看哪些地方出问题。
- 可能会有微妙之处:
gzip
压缩级别错误。
- 可能会有微妙之处:
它提供了哪些安全保证?
提供给应用程序开发人员的保证:沙盒只能在打开的 FD 上操作。
结果取决于应用程序开发人员如何划分应用程序、FD。
用户/管理员无法从 Capsicum 获得任何直接保证。
保证假设 FreeBSD 内核没有错误(大量代码),并且 Capsicum 开发人员捕获了所有通过 FD 而非资源访问的方式。
性能开销是多少?(CPU,内存)
访问文件描述符的轻微开销。
使用
fork
/exec
设置沙盒需要花费O(1msec)
,非平凡的。特权分离可能需要 RPC / 消息传递,可能会引起注意。
采用情况?
有哪些应用程序不适合 Capsicum?
需要控制对非内核管理对象的访问的应用程序。
例如:X 服务器状态,DBus,在 Web 浏览器中的 HTTP 来源等。
例如:需要确保 DB 文件格式正确的数据库服务器。
Capsicum 将管道视为用户级服务器(例如,X 服务器)的一个能力。
需要从沙盒连接到特定的 TCP/UDP 地址/端口的应用程序。
Capsicum 通过仅允许对现有打开的 FD 进行操作来工作。
需要其他机制来控制可以打开哪些 FD。
可能的解决方案:辅助程序可以在能力模式之外运行,根据策略为沙盒化程序打开 TCP/UDP 套接字。
参考
赛里斯防火墙安全政策