UCB-CS161-计算机安全-2025-笔记-全-

UCB CS161 计算机安全 2025 笔记(全)

001:课程概述 📚

在本节课中,我们将要学习CS 161计算机安全课程的总体框架、学习目标以及课程结构。课程分为两部分:第一部分是课程介绍,第二部分将探讨一些安全原则。


课程学习目标 🎯

上一节我们介绍了课程的基本安排,本节中我们来看看通过这门课程你将学到什么。

你将会学习如何以对抗性的视角思考计算机系统。这可能与你之前的学习经历不同。例如,在之前的编程课程中,你的目标是让代码正常运行。只要代码能工作,就是好的。

但在本课程中,我们将思考代码在面对攻击者时的表现。攻击者是指那些故意试图破坏你代码的人。我们将学习如何评估威胁,并判断哪些威胁是重要的,哪些不是。我们将思考如何构建能够抵御攻击者的安全计算机系统。同时,我们也会探讨当前技术尚无法做到的事情。

这有望让你成为一个更明智的消费者。当你未来尝试购买不同的安全工具时,能够做出更好的判断。最终,课程将展示多年来人们犯下的一些安全错误,希望你们能避免重蹈覆辙。


课程结构 📂

了解了学习目标后,接下来我们详细看看课程是如何组织的。

课程包含四个主要单元。首先是今天将要进行的“安全简介”,我们将讨论安全思维背后的一些哲学理念。

然后进入第一个单元:内存安全。这部分内容严重依赖于CS 62和C语言知识。我们将思考C语言软件的不安全性,以及如何防御针对这些弱点的攻击。这部分将持续大约三到四周。

之后,我们将进入密码学单元。这是数学爱好者最喜欢的部分。我们将探讨如何在攻击者试图读取或篡改数据的情况下安全地传输信息。这部分同样将持续三到四周,之后会有一场期中考试。

期中考试后,我们将学习Web安全,思考针对网络的攻击。接着是网络安全,研究针对互联网的攻击。

如果课程时间允许,最后我们会介绍一些随机的专题内容。以上就是课程的基本设置。


课程的额外价值 💡

在介绍了核心单元后,本节我们来看看这门课程能带来的额外技能。

我认为161课程的酷炫之处在于,你不仅能学到安全知识,即使你未来不从事安全领域的工作,而是进行其他软件开发,你也能从中学到许多可以在现实世界中使用的实用工具。

例如,在讨论内存安全时,我们将大量使用GDB(GNU调试器)来调试C代码。这有望让你成为一个比现在更出色的调试者。

当我们讨论Web安全时,在讲解如何攻破它之前,我们会快速概述Web的工作原理。这会让你对CS 169(软件工程)课程有一个初步了解。

当我们讨论网络安全时,你会得到一个关于网络工作原理的快速概览。这为你未来选修CS 168(计算机网络)课程做了铺垫。

这就是我认为161课程的魅力所在:即使你从不从事安全工作,也能带走这些宝贵的技能。


总结 📝

本节课中我们一起学习了CS 161计算机安全课程的概述。我们明确了课程的学习目标,即培养对抗性思维、评估威胁和构建安全系统。我们梳理了课程的四单元结构:内存安全、密码学、Web安全和网络安全。最后,我们还了解了学习本课程所能获得的、适用于更广泛软件开发领域的额外实用技能,如使用GDB调试、理解Web和网络基础等。课程的第一部分介绍到此结束。

002:课程管理与须知 📋

在本节课中,我们将了解本课程的管理规定、重要日期、支持资源以及一些课堂惯例。这些信息将帮助你顺利开始并完成课程学习。


课程规定与常见问题

我不会逐条朗读所有管理规定,因为网站上已有详细的“政策”页面和“常见问题”页面供你自行查阅。因此,我建议你抽时间仔细阅读这些页面。以下是从这些页面中提取的几个快速提醒事项。

如果你目前尚未被加入课程,请给我们几天时间,我们可能会自动添加你,无需你发邮件询问。

如果你有并修课程的待处理申请,或者你刚刚完成注册,也适用上述情况。


讨论课与办公时间

讨论课和办公时间将于下周开始,而非本周。我们不记录出勤,你可以参加任意你感兴趣的小组。


考试日期与安排

考试日期已公布在网站上。补考日期同样在网站上,它安排在正式考试之后,并且仅限线下参加。

如果你有文件证明的考试冲突(例如另一场考试),可以申请安排。请注意,睡过头不属于文件证明的冲突。


压力管理与支持

我们承认这门课程有时会有些繁重,尤其是在讲解项目2的中期阶段。我们想强调,你的身心健康远比这门课程重要。

请不要为了这门课程连续熬夜。我们随时准备提供帮助。网站上有一个申请延期的表格链接。

如果你在残障学生项目注册,请将你的住宿安排信函发送给我们,以便我们为你提供协助。我们在此为你提供支持。


课程团队介绍

以下是我们的全体工作人员。他们非常出色,基本上负责整个课程的运行。没有他们,我无法完成这项工作。你可以看到他们,去打个招呼吧。


C语言复习课安排

本周五将举行一场C语言复习课。我相当确定是这个时间,如果时间有误,我们会在Ed上通知你,但我有90%的把握这是正确的时间。如果你在这个时间前往指定地点,会有人为你详细讲解C语言基础知识。如果你觉得自己的C语言基础不太牢固,可以去参加。


关于课堂蓝色幻灯片

最后一点关于讲座的说明:如果你看到屏幕像现在这样闪烁蓝色,这通常意味着我们将讲述一个故事,为接下来要展示的内容做铺垫。

当我们讲述这些故事时,我并不关心你是否记住了故事的具体细节(例如,我不会考你“1985年的莫里斯蠕虫是什么”)。我关心的是故事带来的特定启示或要点。因此,每当你看到这些蓝色幻灯片,就意味着:我不关心确切的故事内容,但我关心你从中获得的启示。现在你明白了。


总结

本节课我们一起了解了课程的基本运行规则、重要日期、可用的学术与健康支持资源,以及课堂中蓝色幻灯片的特殊含义。请务必查阅课程网站获取完整信息。接下来,我们将正式进入课程的技术内容学习。

003:什么是安全?🔒

在本节课中,我们将要学习“安全”在计算机科学中的核心定义,理解其重要性,并初步了解一系列指导安全设计的基本原则。

什么是安全?

安全是指在存在攻击者的情况下,系统仍能保持我们期望的特定属性。正如之前提到的,仅仅让代码正常运行已经不够了。代码必须在面对那些蓄意破坏它的人时,依然能够正常工作。我们将看到,即使攻击者试图破坏,我们也希望强制执行各种不同的安全属性。

为什么安全很重要?

安全的重要性不言而喻,它几乎无处不在,与安全、隐私、商业、组织乃至政治都息息相关。

以下是几个体现安全重要性的新闻头条实例:

  • 一辆被黑客入侵的汽车。
  • 一个可能被远程操控的心脏起搏器。
  • 可能被黑客攻击的飞机。

这些例子表明,安全具有物理层面的影响,如果处理不当,确实可能造成人身伤害。因此,正确地实现安全至关重要。

此外,隐私保护同样重要。大量公司遭遇黑客攻击,导致社会安全号码、银行信息等敏感数据被盗,这些都是我们必须警惕的。国家安全和政治领域也是如此,国家间可能出于政治原因相互攻击,这也是我们需要防范的。

什么可以被攻击?

基本上,任何包含计算机的设备都需要保护。如今,计算机被嵌入到几乎所有事物中。因此,我们需要保护的对象非常多。

以下是需要保护的对象示例:

  • 我们的电脑和手机。
  • 我们的鱼缸和冰箱。

几乎所有嵌入计算机的设备都需要我们考虑安全问题。我们不希望有人通过入侵鱼缸来闯入赌场,这听起来像电影情节,但也许真的会发生。

安全设计原则

既然我们了解了什么是安全以及它的重要性,接下来我将介绍大约10到11条安全原则。这些原则并非高度技术性的,今天不会深入技术细节。它们是一系列贯穿本课程始终的思考方式,可以说是本课程的总体主题。

随着课程深入,我们会看到许多涉及密码学数学或编程的具体例子,这些原则将反复出现。因此,在整个学习过程中,将这些原则牢记在心是非常有益的。

以下是这些原则的列表,我们将通过小故事来阐释每条原则:

  1. 最小权限原则
  2. 权限分离原则
  3. 纵深防御原则
  4. 失效安全原则
  5. 心理可接受性原则
  6. 完全仲裁原则
  7. 开放设计原则
  8. 最小公共机制原则
  9. 机制经济性原则
  10. 记录所有安全事件

本节课中,我们一起学习了安全的定义,认识到其在物理世界和数字世界中的极端重要性,并初步接触了指导安全系统设计的十大核心原则。在后续课程中,我们将结合具体案例,深入理解并应用这些原则。

004:了解你的威胁模型 🐻

在本节课中,我们将要学习计算机安全中的一个核心概念:威胁模型。我们将通过一个故事来理解为什么需要分析攻击者,并介绍“可信计算基”这一重要概念。

故事:两只徒步者和一只熊

我们来看一个故事。故事里有两位徒步者,他们在森林里露营。一天,一只熊突然出现,非常吓人。

两位徒步者立刻跳了起来,他们心想:“熊会吃掉我们。”

其中一位徒步者迅速起身,系好鞋带,开始逃跑。而另一位徒步者则显得很悠闲,他先伸了个懒腰,看了看手机,还吃了点东西。

第一位徒步者喊道:“你在干什么?有熊!它会吃掉我们。你为什么这么悠闲?”

第二位徒步者回答说:“我不需要跑赢熊,我只需要跑赢你就行了。”

这个故事想告诉我们什么?它告诉我们,当我们面对一个攻击者(比如这只可怕的大熊)时,也许我们不需要彻底击败它,而只需要让它去攻击别人就够了。

故事背后的安全原理

这个故事试图教导我们,当我们考虑攻击者时,必须建立一个模型来分析:攻击者是谁、他们能做什么、以及他们不能做什么。我们必须推理攻击者的特征,以便了解我们面对的是什么,以及如何防御他们。

例如,对于攻击者,我们可能需要思考他们攻击我们的原因。就像这只熊,它为什么想攻击我们?在现实生活中,人们攻击他人的原因多种多样。

以下是人们可能发起攻击的一些原因:

  • 他们可能想偷我们的钱。
  • 可能有政治原因促使他们攻击我们。
  • 他们可能只是为了报复。
  • 他们可能只是觉得好玩。

思考人们攻击我们的原因是有帮助的,因为也许我们可以通过消除他们攻击的动机来让他们放弃。

思考你自己的威胁模型

现在,让我们想想你自己。你是一个使用电脑的人,可能会有人想攻击你和你的电脑。

以下是针对个人的一些攻击动机示例:

  • 如果你在网上玩游戏,有人对你非常生气,他们可能会为了报复而试图入侵你。
  • 如果你是一名顶级秘密间谍,政府可能想窃取你的情报。
  • 如果你最近分手了,而你的前任对你不太满意,他们可能会试图拿走你的东西或入侵你。

因此,作为一个使用电脑的人,你也需要考虑你的威胁模型:哪些攻击者可能想攻击你?例如,我不是顶级秘密间谍,所以政府可能不会试图窃取我的私人信息。但如果你非常富有,也许就会有人为了钱而攻击你。在现实生活中,思考你自己的威胁模型是很有帮助的。

本课程中的攻击者假设

这听起来有点哲学。让我给你举一些本课程中会看到的攻击者例子。

在本课程中,我们将考虑那些知道自己在做什么的攻击者。我们不能简单地假设所有攻击者都很笨,然后就不管他们了。当我们思考攻击时,必须考虑那些聪明且愿意付出努力攻击者。

我们必须假设攻击者了解我们的系统。这一点我们会在后续的安全原则中深入探讨。例如,你必须假设攻击者知道你使用什么软件、什么版本。

还有一个有趣的点:攻击者可能会走运。这是什么意思?也许你设计了一个防御,攻击只有百万分之一的概率成功。你可能会想,谁会这么走运,能碰上这百万分之一的机会?但如果攻击者出现并尝试了一百万次呢?那么他们很可能就会“走运”成功。因此,我们不能依赖攻击者不走运,他们实际上可能会走运。

以上都是我们在构建威胁模型时可能需要假设的情况。随着课程的深入,在展示不同攻击时,我们心里要始终清楚我们面对的是哪种类型的攻击者。这就是威胁模型。

可信计算基

在思考威胁模型时,我们还会考虑一个叫做“可信计算基”的东西。这基本上是一个专业术语,意思是:当我构建一个系统时,系统的某些部分是安全敏感的,而其他部分则不是。

例如,如果我构建一个大型网站应用,可能有很多东西并不是真正的安全敏感。比如提供猫咪视频的代码,这可能不是超级安全敏感的。但是,让用户登录并输入密码的代码,我们必须保护它免受攻击者的侵害。

因此,当我构建系统时,思考系统的哪些部分提供了所有人都依赖的安全保障是很有用的。例如,让人们登录的代码。我们称之为“可信计算基”。确保我们的可信计算基是正确的、无法被绕过或绕过,并且没有人能破坏它,这一点非常重要。

一般来说,保持可信计算基的规模较小是有益的。因为如果代码量小,就更容易审计并确保其安全性。而如果我有成千上万行的安全关键代码,那么我很难确切知道它有多安全,因为有太多的代码需要编写、调试和跟踪。因此,拥有一个简单小巧的可信计算基是很有用的。

这算是一个哲学观点,但值得我们牢记。

总结

本节课中,我们一起学习了计算机安全的基础——威胁模型。我们通过“徒步者与熊”的故事理解了分析攻击者动机和能力的重要性。我们还介绍了“可信计算基”的概念,即系统中必须被重点保护的核心安全部分,并强调了保持其小巧简洁的好处。记住,了解你的对手是构建有效防御的第一步。

005:考虑人的因素

在本节课中,我们将探讨一个在构建安全系统时至关重要的方面:人的因素。我们将看到,无论一个系统在技术上多么安全,如果它难以被普通人理解和使用,其安全性就可能被削弱。

理解用户行为

上一节我们讨论了安全的基本概念,本节中我们来看看用户在实际操作中的行为模式。安全系统最终是由人来使用的,因此我们必须理解他们的习惯和动机。

设想一个场景:你正在浏览网页,屏幕上弹出一个对话框,内容如下:

当您向互联网发送信息时,其他人可能看到该信息。是否继续?

对于大多数用户而言,他们的思考过程可能并非逐字阅读,而是类似于:

  1. 看到了一个弹窗。
  2. 点击“是”让它消失。
  3. 勾选“不再显示”复选框,以免未来被打扰。

用户的核心目标往往是继续浏览(例如观看视频),而非仔细评估安全风险。如果安全提示过于频繁或难以理解,用户就会倾向于绕过它。

技术术语的障碍

接下来,我们看看另一个常见的例子。当遇到类似下面的错误信息时,用户会如何反应?

无法验证受信任站点的身份。此错误的可能原因:您的浏览器无法识别颁发站点证书的证书颁发机构……(后续为技术性描述)。

对于普通用户,甚至许多技术人员,阅读此类信息时的真实感受是:

  1. 看到大量难以理解的“技术黑话”。
  2. 不知道应该点击哪个选项。
  3. 唯一明确的目标是:如何让这个窗口消失,以便继续上网

请想象一位不熟悉互联网的老年用户,他们将完全无法应对这样的信息。这凸显了设计可读、易懂的安全提示的重要性。

核心原则:考虑人的因素

从以上例子中,我们可以得出一个核心结论:我们必须考虑人的因素

我们的安全系统是为人类建造和使用的。因此,设计时必须考虑真实用户的使用模式和行为逻辑。

以下是几个关键点:

  • 易用性与安全性的平衡:用户喜欢简单易用的系统。如果一个安全机制(如确认弹窗)导致操作繁琐,用户可能会直接禁用该机制,从而使系统失去保护。公式可以表示为:实际安全性 = 技术安全性 × 用户采纳率。如果用户采纳率为零,实际安全性即为零。
  • 故意或意外的削弱:用户可能为了便利而故意关闭安全功能,也可能因为不理解提示而意外地削弱安全。
  • 社会工程学攻击:攻击者常常利用人的信任心理进行“社会工程学”攻击,诱骗用户执行危险操作。因此,最终的安全防线在于人本身。

对开发者的启示

这个原则不仅适用于终端用户,也适用于我们开发者自身。我们也是人,也会犯错。

因此,选择开发工具至关重要:

  • 使用类似C语言这样的工具时,它通常不会自动捕获许多常见错误,这增加了引入安全漏洞的风险。
  • 我们应优先选用能帮助我们发现错误的、“防呆”的工具和编程语言。

简而言之,我们希望使用的工具和设计的系统都应该是易于正确使用,难于错误使用的。如果系统难以使用,用户就不会正确使用它。

优秀的设计案例:安全密钥

最后,我们来看一个正面例子——安全密钥(Security Key)。它是一个用于增强登录安全性的物理设备。

你可能会好奇它的外形设计。它为什么设计成一块特定形状的塑料?它本可以是任何形状。

实际上,它被刻意设计成钥匙的形状。这是因为,在人类共有的认知里,“钥匙”象征着重要性和需要被妥善保管。当用户拿到一个钥匙形状的物体时,会本能地意识到需要保护它。

这是一个绝佳的“考虑人的因素”的设计案例。程序员通过利用人们已有的心智模型,让安全设备的用途和使用方法变得不言而喻,从而降低了用户的学习成本和使用错误。

总结

本节课中,我们一起学习了在安全设计中“考虑人的因素”这一核心原则。我们了解到:

  1. 用户倾向于选择便利而非安全,因此系统必须易于正确使用。
  2. 晦涩难懂的技术提示会迫使用户盲目操作,从而绕过安全机制。
  3. 开发者应选用能辅助避免错误的工具。
  4. 优秀的设计应贴合用户直觉,利用已有的认知模型(如将安全密钥设计为钥匙形状)来引导正确行为。

记住,最坚固的技术防线,也可能被一个困惑的用户点击而瓦解。因此,将人的因素置于设计中心,是构建真正有效安全系统的关键。

006:安全即经济学 🛡️💰

在本节课中,我们将通过一个购买保险箱的故事,来理解安全的核心本质——经济学。我们将看到,更高的安全级别通常意味着更高的成本,并且安全决策本质上是一种成本效益分析。

前往保险箱商店 🏪

上一节我们讨论了安全的基本概念,本节中我们来看看一个具体的例子。假设我们现在要去一家保险箱商店,准备购买一个物理保险箱。

商店提供了不同型号的保险箱供我们选择。

以下是可供选择的型号列表:

  • TL 15:售价 $3000。如果攻击者使用常见工具,此保险箱能抵抗攻击 15 分钟。
  • TL 30:售价 $4500。此型号能抵抗攻击者 30 分钟。
  • TR TL 30:售价 $10,000。如果攻击者使用切割火炬,需要 30 分钟才能侵入。
  • TX TL 60:售价 $50,000。即使攻击者使用常见工具、切割火炬和炸丨药,此保险箱也能抵抗他们 60 分钟。

安全需要付出代价 💸

你注意到这些保险箱价格标签的变化规律了吗?它们随着安全级别的提升而显著上涨。这个故事试图向我们展示一个核心道理:想要获得更多安全,就必须为之付费。安全不是免费的。

当我们去保险箱商店时,我们学到了一个教训:安全即经济学。这其中涉及大量的成本效益分析。理想情况下,当我们考虑攻击时,也必须同时考虑:

  • 防御攻击的成本是多少?
  • 从攻击中恢复的成本是多少?

总的来说,更高的安全性意味着更高的成本

更多经济学案例 📊

安全即经济学的理念不仅适用于保险箱,还体现在许多其他场景中。

以下是另一个简单的例子:
假设我有一块价值 $1 的石头。我是否会为这块石头配一把 $10 的锁?很可能不会。因为如果石头被偷,我只需再花 $1 买一块新的。这是一个安全经济学的例子——花费 $10 去保护一个只值 $1 的东西,通常没有意义,除非那是一块绝密石头。

让我们再看一个例子:
假设有人给了你一个全世界其他人都不知道的全新攻击方法。换句话说,我向你保证,这个攻击方法对任何你尝试的目标都会奏效。

那么,你会选择谁作为目标呢?你拥有这个对地球上任何人都有效的宝贵攻击方法。你会仅仅用它来攻击你旁边的人吗?很可能不会。你可能会去攻击总统或某个非常富有的人,因为这是一个全新的、必定成功的攻击。所以,我会尝试以某个非常重要的人物为目标。这是安全即经济学的另一个例子——我不会将价值 $100万 的新攻击,浪费在一个只值 $10 的人身上,这很不划算。

总结 📝

本节课中,我们一起学习了安全的核心原则之一:安全即经济学。我们通过选择保险箱的案例看到,安全级别与成本直接相关。同时,我们也了解到,任何安全决策都应进行成本效益分析,权衡保护对象的价值与所需投入的防御成本。理解这一点,是做出明智安全决策的基础。

007:当无法阻止时,学会检测 🚨

在本节课中,我们将要学习网络安全中一个核心的防御理念:并非所有攻击都能被预先阻止,因此,检测攻击的发生并做出响应至关重要。我们将通过生活中的类比来理解“威慑”、“预防”、“检测”和“响应”这几个关键概念。


上一节我们讨论了安全的基本目标,本节中我们来看看应对攻击的不同策略。想象一下你走在社区里看到的这些标志。

这些标志表明此房屋受安保公司保护,若有人试图闯入,警报就会响起。这是一个防盗警报器。

这个例子已经揭示了一些问题。一个问题是,如果你只是不小心打开了门,警报也可能误响。另一个有趣的想法是:攻击者看到这个标志后,可能会选择其他目标。那么,与其花费大量金钱安装真正的警报系统,不如只在前院插上这样一个标志,而不安装实际系统。

这或许仍然能让攻击者转向别处。这个故事给我们的启示不是去偷邻居的院子标志,而是说明了我们应对攻击的不同方式:有时我们预防攻击,有时我们威慑攻击,有时我们检测攻击。

以下是阻止攻击的几种不同方式:

  • 威慑攻击:在攻击发生之前就阻止它。
  • 预防攻击:攻击正在进行中,但我们成功击败并阻止了它。
  • 检测攻击:攻击已经发生,系统已被侵入,但至少我们注意到了它的发生。
  • 响应攻击:在攻击发生后,我们需要采取行动,例如恢复丢失的数据或报警。

我们有时需要区分这些方式。例如,可能存在一种情况,我们无法威慑攻击,攻击注定会发生。那么,也许我们可以尝试预防它。或者,可能存在一种情况,我们无法预防攻击,攻击必将成功。那么,在这种情况下,我们至少应该能够检测它并响应它。因此,在思考如何阻止攻击时,所有这些方面都是我们必须考虑的重要因素。

接下来,我们看一个现实生活中的例子:地震。你能威慑地震吗?你能阻止地震发生吗?恐怕不能。你能预防地震吗?你能在地震开始摇晃时阻止它吗?很可能也不行。但你可以检测到地震正在发生,并且可以响应地震,例如准备食物储备,以便地震发生后有食物可用。

再想想勒索软件。什么是勒索软件?就是有人控制你的电脑,加密上面的所有数据,然后说:“给我一大笔钱,否则我就永久删除数据。”你如何阻止它?你可以尝试预防它,但也可以通过保持备份来响应它。这样,即使电脑被锁,你也有备份数据,损失不大。

最后,我们看一个能检测但难以响应的例子:比特币。这里你只需要知道,比特币交易是不可逆的。换句话说,如果你把比特币转给了别人,除非对方主动归还,否则你将永远失去这些比特币。用代码表示这个特性就是:

交易状态 = “已确认” -> “不可撤销”

这意味着,如果有人窃取了你的比特币,你或许能够检测到(这很好),但你能恢复吗?除非窃贼愿意归还(这不太可能),否则不能。你永久失去了比特币。所以,你检测到了攻击,但无法有效响应。你注意到钱不见了,但钱拿不回来了。这是一个检测但无法响应的例子,这并不理想。


本节课中我们一起学习了网络安全防御的四种策略:威慑、预防、检测、响应。我们明白了并非所有攻击都能被预先阻止,因此在无法预防时,检测攻击的发生就显得尤为重要,而有效的响应能帮助我们减少损失。记住这个完整的防御链条对于构建健壮的安全体系至关重要。

008:纵深防御

在本节课中,我们将要学习一个重要的网络安全概念——纵深防御。我们将通过一个历史故事来理解其核心思想,并探讨它在实际应用中的权衡。

概述

纵深防御是一种通过部署多层安全措施来保护系统或资产的安全策略。其核心理念是,即使一层防御被突破,后续的防御层仍能提供保护,从而增加攻击者的入侵难度和成本。

从历史故事看纵深防御

上一节我们提到了网络安全的基本挑战,本节中我们来看看一个源自历史的经典防御思想。

想象一下我们回到历史上的君士坦丁堡。这是一座古城,后来曾被更名。如果你去到那里,会看到为了阻止攻击者进入而修建的城墙。穿过这道城墙后,你会遇到一条护城河。越过护城河,又会遇到另一道城墙。再越过这道墙,还有一条养着鳄鱼的护城河。最后,还有一道更巨大的城墙,以及会在你试图进入时倾泻火焰的塔楼。

这个故事展示了什么?它展示了第一道墙固然不错,但我们又修建了第二道墙、护城河、另一道墙、鳄鱼以及火焰塔。这种策略被称为纵深防御。我们针对同一种攻击(即人们试图进入城市)部署了多重防御措施。

纵深防御的核心思想

那么,城墙的故事告诉我们什么?它告诉我们,我们可以将防御措施层层叠加。这样一来,如果攻击者想要侵入我们的系统,就必须突破所有的防御层:他们必须突破城墙、护城河、鳄鱼和火焰塔。

因此,纵深防御是一种我们可以采用的思路:通过增加多层防御来阻止攻击者

以下是其核心优势:

  • 增加攻击复杂度:攻击者需要连续突破多个障碍。
  • 提供冗余保护:单一防御措施的失效不意味着系统完全失守。
  • 延缓攻击进度:为防御方争取检测和响应的时间。

经济性的权衡

但是,为什么不直接修建上百道墙呢?请记住,我们还必须考虑经济性。修建城墙并非没有成本。

每一道你修建的墙都需要额外的花费。举例来说,如果我有一道墙,再修建第二道,这很不错,或许值得去做。但如果我已经有100道墙了,我真的还需要修建第101道墙吗?这额外的成本所带来的收益是否值得?这很难说。你必须思考其中的权衡。

所以,纵深防御虽然有效,但它受到经济性的限制。

总结

本节课中,我们一起学习了纵深防御的概念。我们了解到,它通过部署多层安全措施来增强保护,其核心优势在于增加攻击者的入侵难度和成本。同时,我们也认识到,在实际应用中,部署多少层防御需要与成本进行权衡,并非层数越多越好。在后续课程中,我们将看到这一原则如何应用于现代网络安全架构中。

009:最小权限原则

在本节课中,我们将要学习计算机安全中的一个核心概念——最小权限原则。我们将通过日常生活中的例子来理解,为什么只给程序或用户完成其任务所必需的最小权限是至关重要的。


从下载电影说起

上一节我们介绍了权限的基本概念,本节中我们来看看一个具体的场景:下载电影。

假设我们访问一个流媒体网站,并合法地下载一部名为《巨型水蛭的攻击》的电影。在这个过程中,我们运行的下载程序需要获得我们计算机系统的某些权限才能完成任务。

那么,当我们同意下载时,我们实际上授予了这个程序哪些权限呢?这个程序需要能够将电影文件写入我们的硬盘,也需要能够从互联网接收数据。因此,我们不得不赋予这个程序相当多的权力。

以下是该程序可能获得的权限列表:

  • 写入文件系统(保存电影)。
  • 读取文件系统(可能用于检查存储空间)。
  • 访问互联网(下载数据)。
  • 可能还包括运行其他程序、发送网络请求等。

潜在的风险与不必要的权限

如果这个下载程序是恶意的,拥有这些广泛的权限会带来什么后果?一个恶意程序可以利用这些权限读取你的私人文件、删除重要数据、甚至以你的名义发送邮件。

这里的关键问题在于:我们授予了应用程序完成其功能所需的全部权限,但其中一些权限它可能根本不需要。例如,一个电影下载程序不需要访问存储密码的文件,它只需要访问存放电影的特定文件夹。

因此,我们需要仔细思考我们赋予程序的权限。理想情况下,我们应该只赋予程序刚好足够其正常运行所需的权限,避免授予它过多的权限。如果程序是恶意的,过多的权限会导致它对我们计算机造成更多不必要的损害。

我们需要思考程序正确运行所必需的条件,并尽量避免赋予任何非必要的权限。因为如果程序是恶意的,它可能会利用这些多余的权限来攻击我们。通过谨慎地分配权限,我们本可以防范这类攻击。

另一个现实世界的例子:考试保密

让我们通过另一个现实世界的例子来巩固对最小权限原则的理解。假设我们正在编写一份绝密的期中考试试卷。

我们有两种方式来管理试卷的访问权限:

  1. 将试卷分发给所有助教。但如果其中有恶意者,试卷就可能被泄露。
  2. 只将试卷分发给那些真正参与编写、校对工作的助教。

显然,第二种方法是更安全的选择。它遵循了最小权限原则:如果某位助教不参与考试相关工作,就不需要授予他访问试卷的权限,因为他根本不需要它。


本节课中我们一起学习了最小权限原则。其核心思想是:任何程序、用户或进程都应该只拥有完成其合法任务所必需的最小权限集合。这就像在生活中,你只会把家门钥匙交给需要进屋的人,而不是给所有人。在计算机安全中,遵循这一原则可以显著减少因权限过度而带来的潜在风险,是构建安全系统的基础。

010:职责分离

在本节课中,我们将要学习一个重要的安全概念:职责分离。我们将通过一个生动的历史案例来理解其核心思想,并探讨它在计算机安全领域的应用。

一个关于核弹发射的故事

这里有一个故事,它阐述了职责分离的概念。

如果你参观冷战时期建造的旧式核掩体,你会看到一张非常长的桌子。这张图片可能没有完全展示它的长度,它实际上非常长,从报告厅的这一侧延伸到另一侧。桌子上有两个钥匙,必须同时转动它们才能发射核弹。一把钥匙在这一侧,另一把钥匙在那一侧。

那么,为什么要把两把钥匙放得如此之远呢?为什么不把它们放在一起?

核心原理:双人原则

答案是:通过将钥匙分开放置得足够远,我们需要两个人同意才能发射核弹。这就是所谓的“双人原则”。

这个想法的核心是:对于某些极其敏感的操作,例如发射核弹,可以考虑要求多人同意才能执行该特权操作。例如,如果我控制着核弹,我会要求在发射核弹之前,必须有两位独立的人员同意。这样一来,我就能防范单个攻击者。因为如果一个人试图作恶,他无法独自发射核弹;实际上需要两个人都作恶才行。而一个人作恶的可能性远高于两个人同时作恶。

通过要求多人同意——无论是两把钥匙、三把钥匙还是五把钥匙——我都在增加执行“发射核弹”这个危险任务的难度。这就叫做职责分离。我们将一项任务的责任分配到多个人手中,这样,单次攻击就无法伤害我们,需要多人合谋才能造成伤害。

在计算机安全中的应用

上一节我们通过核弹发射的例子理解了职责分离的基本思想。本节中我们来看看这个概念如何应用于计算机系统。

在计算机安全中,职责分离意味着将关键权限或任务拆分,确保没有任何一个个体拥有完成整个危险操作的全部权限。这可以防止内部威胁、误操作或单个账户被攻破导致灾难性后果。

以下是职责分离在计算机系统中的几种常见实现方式:

  • 多因素认证:要求用户提供两种或以上不同类型的凭证(如密码 + 手机验证码),这可以看作是一种“双人原则”的变体,只不过“两个人”变成了“用户拥有的知识”和“用户拥有的设备”。
  • 权限最小化与角色分离:为不同用户分配完成其工作所必需的最小权限。例如,在财务系统中,申请付款的员工、审批付款的经理和实际执行付款的会计应由不同的人担任,系统权限也相应分离。
  • 代码审查与合并:在软件开发中,禁止开发者直接将代码提交到主分支。必须由另一位开发者进行代码审查后,才能合并代码。这确保了代码变更至少经过两个人的确认。
  • 密钥分割:类似于核弹发射钥匙,将加密密钥分成多个部分,由不同的人或系统保管,需要组合足够多的部分才能重建完整密钥。其核心思想可以用一个简单的公式表示:要完成操作 O,需要满足条件 P1 ∧ P2 ∧ ... ∧ Pn,其中 P1, P2, ..., Pn 代表不同的参与者或凭证。

总结

本节课中,我们一起学习了职责分离这一核心安全原则。我们从冷战时期的核弹发射“双人原则”出发,理解了通过将关键任务分配给多个独立实体,可以显著降低由单点故障、内部恶意行为或误操作带来的风险。随后,我们探讨了这一原则在计算机安全领域的多种应用,包括多因素认证、权限分离和代码审查等。记住,在设计安全系统时,考虑将高权限操作进行拆分,是构建深度防御策略的重要一环。

011:确保完全仲裁与检查时间到使用时间

在本节课中,我们将学习两个重要的安全设计原则:确保完全仲裁检查时间到使用时间问题。我们将通过生动的例子来理解这些概念,并学习如何避免相关的安全漏洞。

🚧 确保完全仲裁

上一节我们讨论了安全设计的基础。本节中我们来看看确保完全仲裁原则。

请看这张图片。它存在什么问题。

图片有些模糊。这里有一个小型安全闸门。

这个闸门能阻止车辆通过吗。不能。车辆在做什么。

车辆正在绕行。问题在于我们设置了障碍,但车辆可以简单地绕过它。因此我们必须确保检查点没有可供用户绕过的路径。

我们称此原则为确保完全仲裁。所有访问点都应受到保护,并且不应存在绕过访问点的途径。

有时人们会使用一个专业术语引用监视器,来指代所有访问都必须经过的那个点。

例如,当你去机场时,必须通过安检。所有人都必须通过金属探测器。这是所有乘客都必须经过的单一节点。据我所知,没有方法可以绕过机场安检。

另一个例子是进入宿舍时,只有一个入口,每个人都必须刷卡才能进入。这是一个单一的访问点,每个人都必须通过它,无法绕过。

在本课程后面,我们还会看到防火墙,这也是一个所有人都必须经过单一访问点的例子。

对于这个引用监视器,我们必须确保监视器本身是正确的。无法绕过它,也无法破坏它。另一种表述是,引用监视器应成为可信计算基的一部分。

这是一个未确保完全仲裁的例子。我们必须确保如果有人想访问我们的程序,他们必须通过单一的、安全的访问点。

⏳ 一个更复杂的例子:银行取款

理解了空间上的仲裁问题后,现在让我们看一个更复杂的关于时间仲裁的例子。

以下是模拟从银行取款的代码。我们将在展示例子后讨论它。

# 假设的用户余额
B = 10  # 用户有10美元

# 用户想要提取的金额
W = 20  # 用户想提取20美元

# 检查余额是否足够
if W > B:
    print("错误:余额不足")
    abort()
else:
    # 扣减余额
    B = B - W
    # 给用户现金
    give_user_cash(W)

代码逻辑如下:如果用户想取钱,首先检查用户有多少钱。如果用户只有10美元,却想提取20美元,这是不允许的。程序将中止并显示错误信息。

然而,如果用户有足够的钱可以提取,那么银行将减少余额,然后给用户相应金额的现金。

可以想象,许多不同的ATM机都在运行这段代码来处理用户取款。

🎯 利用竞态条件攻击

现在,让我展示一种巧妙利用这段代码的方法。假设用户只有5美元,但他们想要超过5美元。他们如何利用这段代码获得超过5美元。

假设有两台机器。我将绘制一个时间线来说明我的操作。

我走到第一台机器前,按下按钮提取5美元。记住,用户银行账户总共只有5美元。他按下提取5美元的按钮,代码开始检查:用户银行里有多少钱?5美元。用户想要多少?5美元。这没问题,用户有足够的钱。因此,这个检查通过了。

在这段代码继续运行之前,我迅速切换到第二台机器,并在第二台机器上按下取款按钮。现在我在第二台机器上。第一台机器的代码仍在运行。

我跑到第二台机器,按下按钮,代码再次运行。它检查用户有多少钱。余额仍然是5美元,因为第一台机器的代码尚未完成扣款。

当用户有5美元时,他能提取5美元吗?可以。所以第二台机器将执行:去银行将余额设为0,并给用户5美元。

稍后,右边第一台机器的代码继续运行。它也将余额设为零,并吐出5美元。

于是出现了这种情况:我原本只有5美元,但通过在非常特定的时间点按下取款按钮,我成功提取了10美元。

这很危险。这里到底发生了什么。仔细审视这个过程,这实际上也是一个未能确保完全仲裁的例子。

但与汽车绕行的空间仲裁失败不同,这里我们是在时间上未能确保仲裁。因为这里有一个检查我是否有足够钱的步骤。但在检查成功后、余额更新之前,我去了另一台机器并成功完成了另一次取款。

这是一个虽然实施了检查,但由于人们可以利用竞态条件,实际上并未确保完全仲裁的例子。我未能阻止所有人提取超过其拥有的金额。

这是另一个未确保完全仲裁的案例。我们利用竞态条件提取了超过我们拥有的钱。

🔄 检查时间到使用时间

有时我们称此为检查时间到使用时间问题。因为这是进行检查的时间点,而这是实际使用(即向用户吐出现金)的时间点。如果这两件事没有原子性地一起发生,就可能出现这种奇怪的竞态条件。

这个概念需要一些时间来理解。请随时再多思考一下。

📝 总结

本节课中我们一起学习了两个核心安全原则。

首先,我们学习了确保完全仲裁原则,它要求所有对受保护资源的访问都必须通过一个不可绕过的、单一的引用监视器。无论是物理上的闸门,还是数字系统的入口,都必须杜绝旁路。

其次,我们深入探讨了检查时间到使用时间问题。这是一个特殊的时间上的仲裁失败案例,发生在安全检查与实际操作之间存在时间差时,攻击者可以利用这个间隙(竞态条件)绕过安全策略。我们通过银行取款的代码示例,清晰地看到了这种攻击是如何发生的。

理解并避免这些漏洞,对于设计健壮、安全的系统至关重要。

012:不要依赖“隐匿式安全”

在本节课中,我们将要学习一个重要的安全原则:不要依赖“隐匿式安全”。我们将通过一个高速公路可变信息标志的实例,来理解为何仅仅依靠信息或机制的隐蔽性无法提供真正的安全保障。

实例分析:高速公路信息标志

上一节我们介绍了安全设计的基本原则,本节中我们来看看一个常见的错误实践。

你或许在高速公路上见过可变信息标志。这些标志内部实际上有一个小型计算机,用于控制显示的信息。

如果打开这个标志,你会发现一个控制面板,用于输入要显示的信息。在更新信息前,需要输入密码。


问题在于,如果你在网上搜索这些设备的使用手册,会发现一个默认密码:DOTS。如果设备管理员没有修改这个默认密码,那么任何人都可以使用它。

以下是使用默认密码可能进行的操作:

  • 输入“前方有僵尸”。
  • 输入“快跑,僵尸来了”。

当然,请不要真的这样做。这属于违法行为,可能导致严重的法律后果。

核心原则:香农箴言

这个例子揭示了一个根本问题:依赖“隐匿式安全”。这种想法是:“密码虽然是默认的,但谁会特地上网去搜呢?这很难被发现,所以应该没问题。”

这是一种错误的假设,即攻击者不具备相关知识或找不到关键信息。实际上,攻击者完全可以通过网络搜索找到控制面板的位置、设备手册以及默认密码。



所有这些都可以归结为一个被称为 香农箴言 的核心安全准则。其基本思想是:

必须假设攻击者完全了解你的系统。

这意味着:

  • 攻击者知道机器的工作原理。
  • 攻击者知道控制台在哪里。
  • 攻击者知道密码是什么以及默认密码是什么。

因此,不能依赖隐藏或增加寻找难度来保障安全。“难以发现”不等于“安全”。一个真正安全的系统,即使攻击者知晓其所有细节,也无法被攻破。

生活中的类比

另一个经典的例子可以帮助我们理解这个原则。

你是否曾把备用钥匙放在门垫下面?这安全吗?不。
钥匙难找吗?是的。
但“难找”不等于“安全”。如果攻击者知道人们习惯把钥匙藏在门垫下,他们就会去检查你的门垫,拿到钥匙,然后进入你的房子。这将导致严重的后果。

这个类比再次强调了香农箴言:不要依赖事物的隐蔽性来获得安全。必须始终假设攻击者了解你系统的每一个细节。

总结

本节课中我们一起学习了“隐匿式安全”的谬误。我们通过高速公路信息标志和门垫下藏钥匙的例子,理解了为何不能将安全建立在信息或机制的隐蔽性之上。核心要点是牢记 香农箴言:在设计安全系统时,必须假定攻击者拥有系统的全部知识。真正的安全性意味着即使在这种情况下,系统依然能够抵御攻击。

安全设计原则课程:013:使用故障安全默认值

在本节课中,我们将学习一个重要的安全设计原则:故障安全默认值。我们将探讨在系统发生故障时,应如何设置默认行为以平衡安全性与可用性。


上一节我们介绍了安全设计中的多个原则,本节中我们来看看“故障安全默认值”这一具体概念。

假设你前往苏达楼,房间门使用钥匙卡扫描器控制。扫描钥匙卡后,门才会打开。

现在有一个问题:如果停电了,你认为这些门默认应该锁上还是解锁?请记住,停电意味着钥匙卡扫描器无法工作。

以下是两种选择的考量:

  • 默认锁门(故障关闭):停电时,所有门自动锁闭。这样任何人都无法进出。
  • 默认开门(故障开放):停电时,所有门自动解锁。这样任何人都可以打开这些门,访问门后的空间。

哪种方式更好?这取决于具体的门及其保护的对象。

  • 如果门后存放昂贵设备,或许默认锁门更安全。
  • 如果是紧急出口的门,则很可能应该默认解锁。

这里的要点是:当系统故障时,采取何种行动并非总是显而易见。你必须思考系统故障时应该回退到何种状态。有时“故障关闭”更好,有时“故障开放”更好。这完全取决于你的系统和威胁模型。

因此,核心原则是:你必须为你的系统选择一个安全的默认设置。 这通常涉及权衡。你需要在安全性(即故障关闭、锁住一切以提供保护)和可用性(即系统宕机时,你可能需要某些门保持开放以便访问内部或从紧急出口逃生)之间进行权衡。有时做出正确选择相当困难,默认锁闭还是默认开放并非总是显而易见的。

所以,选取故障安全默认值可能很棘手。


本节课中,我们一起学习了“故障安全默认值”原则。我们了解到,在系统设计时,需要预先定义故障发生时的默认行为,并在安全性与可用性之间做出明智的权衡,选择最适合当前场景的默认安全状态。

014:从设计之初就考虑安全 🔐

在本节课中,我们将学习一个至关重要的安全原则:在系统设计之初就考虑安全性。我们将探讨为何这一原则如此重要,以及忽视它可能带来的后果。

概述

上一节我们讨论了“故障安全默认值”原则。本节中,我们来看看最后一个核心原则:从设计之初就考虑安全。这个原则强调,安全不应是事后补救,而应是系统设计的基础。

从设计之初就考虑安全

当你构建系统时,很容易陷入一种冲动:我有一个很棒的新想法要构建,我要立刻开始疯狂地写代码。你写了500行代码,整个系统运行起来,然后你就发布了它,认为工作完成了。

接着,人们开始攻击它,并且攻击持续不断,这显然不是好事。因此,如果你想构建一个新系统,从设计之初就考虑安全至关重要。

如果你不从设计之初考虑安全,会发生以下情况:你构建了一个漂亮而复杂的系统,但它没有安全性。任何人都可以攻击它。现在你必须回过头去修复它,而这些修复可能会非常笨拙。你不得不在一个地方插入一个安全补丁,在另一个地方再插入一个,这里打个小补丁,那里再打一个。

结果:如果你不从设计之初考虑安全,你的系统将变得非常臃肿、丑陋且难以使用,因为你必须去修补所有出现的小漏洞。这更像是维护,而不是构建。与其先构建、再使用、然后等它坏了再修复,也许在构建时,你就应该安全地构建它,让它从一开始就不容易出问题。

互联网的教训

这个原则在我们讨论网络和互联网单元时经常出现。网络和互联网实际上并非从一开始就考虑了安全性。

当人们最初构建互联网时,是研究人员在构建它。他们并没有真正考虑安全问题。他们认为:“我们只是用互联网来交换研究论文,谁在乎安全呢?”然而,后来互联网逐渐发展壮大。因此,我们现在必须在互联网上构建安全性。

你会发现,我们学习了所有这些不同的补丁,你可能会想:为什么这些代码如此糟糕?为什么我要学习这些不协调的插入式修补?原因就在于,互联网在设计之初并未考虑安全。因此,互联网的很多设计确实很糟糕。

核心建议:如果你想避免写出非常糟糕的代码,就应该从最开始就考虑安全。

实践指南

以下是应用此原则的关键步骤:

  • 在开始编写代码时,时刻牢记所有安全原则:将之前学到的所有安全原则(如最小权限、纵深防御等)作为设计时的指导方针,而不是编码完成后的检查清单。

总结

本节课中,我们一起学习了“从设计之初就考虑安全”这一原则。我们了解到,将安全作为事后的附加功能会导致系统臃肿、脆弱且难以维护。通过借鉴互联网发展的历史教训,我们明白了在项目启动时就将安全理念融入设计过程的重要性。记住,安全的设计是构建健壮、可靠系统的基石。


课程内容回顾:在本系列课程中,我们探讨了约11个安全设计原则。这些是我们在设计安全代码时需要思考的哲学理念,它们将在本课程中反复出现。我们讨论了:理解攻击者、考虑人为因素、安全即经济学、检测与响应、分层防御与纵深防御、最小权限、职责分离、完全仲裁、香农箴言(假设敌人了解系统)、故障安全默认值,以及本节课的“从设计之初就考虑安全”。请在后续课程和构建系统时思考所有这些原则。

015:数字表示

在本节课中,我们将学习计算机如何表示代码和数据。首先,我们会回顾数字在计算机中的表示方式,然后介绍X86汇编语言的基础知识,特别是函数调用的机制。

数字表示

上一节我们介绍了课程的安全原则。本节中,我们来看看计算机如何表示所有数据。

所有数据最终都以比特(bits)的形式表示。无论是数字、图片还是字符串,一切最终都由1和0构成。

比特与字节

就像测量距离有不同的单位(如米、千米、厘米)一样,测量比特数量也有不同的方式。

  • 一个二进制数字称为一个比特(bit)
  • 一组8个比特称为一个字节(byte)

例如,一个16比特的字符串,等价于2个字节。这只是测量同一比特数量的两种不同方式。

十六进制表示法

直接写出一长串的0和1会非常繁琐且难以阅读。因此,我们发明了一种简写方式:十六进制。

十六进制是基数为16的系统,其数字范围从0到F(0-9和A-F)。我们将每4个二进制位映射为一个唯一的十六进制数字,这样可以使长串的二进制数更易于阅读。

以下是二进制到十六进制的映射示例:

  • 1100 映射为 C
  • 0110 映射为 6

因此,二进制数 11000110 可以简洁地表示为十六进制数 0xC6

表示法约定:

  • 二进制数前缀:0b(例如 0b11000110
  • 十六进制数前缀:0x(例如 0xC6

这仅仅是为了清晰而使用的简写,其代表的底层比特值是相同的。

本节课中,我们一起学习了数据在计算机中的基本表示形式:比特和字节,以及如何使用更简洁的十六进制来表示二进制数据。理解这些是后续学习汇编语言和内存安全的基础。下一节,我们将开始探索X86汇编语言。

016:运行C程序

在本节课中,我们将要学习C程序是如何在计算机上实际运行的。我们将从编写好的C代码开始,一步步了解它如何被转换为计算机能够理解和执行的机器指令。同时,我们也会探讨程序运行时,操作系统为其分配的内存空间是什么样子的。

从C代码到机器指令

上一节我们介绍了数字在内存中的表示。本节中我们来看看如何运行一个C程序。

假设你已经写好了一段C程序,你的计算机会采取哪些步骤来运行它呢?以下是运行C程序的基本流程:

  1. 编写C代码:你从编写C语言源代码开始。
  2. 编译:编译器会将你的C代码翻译成一种更低级的语言,称为汇编代码。汇编语言有多种,例如RISC-V或x86。本课程将使用x86汇编语言。
  3. 汇编:汇编器会接收汇编代码,并将其转换为机器码。机器码完全由原始的比特位(0和1)组成,是计算机CPU能够直接理解和执行的指令。

这个过程可以用以下伪代码流程表示:

C源代码 -> 编译器 -> 汇编代码 -> 汇编器 -> 机器码(0和1)

在CS61C等课程中,你可能还见过一个叫做链接器的步骤,它负责处理程序间的依赖关系。但在本课程中,我们暂时不涉及链接器。

最后,加载器会为你的程序设置好可用的内存空间,用于存储数据和变量,并开始执行你生成的机器码。

程序的内存空间布局

如果加载器会设置内存空间,那么这个空间具体是什么样子的呢?如果程序需要存储变量和代码,内存空间是如何组织的?

当程序启动并准备执行时,操作系统会分配一大块内存给你的程序。你可以把它想象成一个巨大的字节数组。这个数组中的每个“格子”恰好能存放1个字节的数据,并且每个格子都有一个唯一的索引,我们称之为内存地址

因此,内存就像一个巨大的字节数组:最低地址(例如全0)位于数组起始处,最高地址(例如全1)位于数组末尾。本课程中,我们使用32位系统,这意味着每个地址的长度是32位(4字节)。由于每个字节都有一个唯一的32位地址,所以总共可以寻址 2^32 个字节的内存空间。

内存的可视化表示

虽然从计算机的角度看,内存就是一个从全0地址到全1地址的一维大数组,但这样画图对我们人类来说不太直观。

因此,在本课程中,为了便于理解和阅读,我们将内存绘制成一个二维网格。请注意,这只是为了我们看图方便,计算机本身并不知道什么行和列,它依然将其视为一个连续的一维数组。

我们绘制内存的约定如下:

  • 最低地址位于网格的左下角
  • 最高地址位于网格的右上角
  • 地址从左到右、从下到上增长。
  • 每一行代表4个字节。

这种可视化方式能让我们更轻松地理解内存布局图。


本节课中我们一起学习了C程序从源代码到可执行文件的转换过程,包括编译、汇编等关键步骤。同时,我们也了解了程序运行时内存空间的基本模型——一个巨大的、可按字节寻址的数组,并学习了如何用二维网格来直观地表示它,为后续深入理解内存操作打下了基础。

017:字节序

在本节课中,我们将要学习一个在深入理解函数调用机制前必须掌握的重要概念:字节序。这是一个容易让人困惑但至关重要的主题,它决定了计算机如何解释存储在内存中的多字节数据。

内存布局回顾

上一节我们介绍了内存可以被视为一个巨大的二维网格,其中每一行可以容纳4个字节。每个字节在内存中都有一个唯一的地址。

例如,地址 0 存储一个字节,地址 1 存储下一个字节,地址 4 存储再下一个字节,依此类推。

然而,有时我们需要表示一个无法用单个字节表示的数据,例如一个整数。这就需要将多个字节组合起来,作为一个整体单元来读取,比如一次读取4个字节。这就引出了一个核心问题:我们应该以何种顺序来读取这些字节?

字节序的类比

为了回答这个问题,我们先来看一个类比。假设你需要通过只发送数字的方式,告诉朋友三个日期:今天的日期、期中考试日期和期末考试日期。

以下是两种可能的发送方式:

  • 方式一:年-月-日
    你可以先发送年份,然后是月份,最后是日期。例如,发送 25, 01, 24 来表示 2025年1月24日。

  • 方式二:日-月-年
    你也可以先发送日期,然后是月份,最后是年份。例如,发送 24, 01, 25 来表示同一天。

哪种方式更好?实际上,这并不重要。关键在于你和你的朋友必须事先约定好使用同一种格式。只要双方一致,通信就能成功。

这个日期格式的约定问题,正是我们面临的字节序问题。当我们将内存中的多个字节(比如4个字节)组合起来解释为一个更复杂的数据(如一个整数)时,我们必须约定这些字节的读取顺序。

大端序与小端序

回到内存读取的问题。当我们有四个连续的字节(例如,地址从低到高存储着 0x11, 0x22, 0x33, 0x44)时,有两种主要的解释顺序:

  • 大端序:从最高有效字节(即数值中权重最大的部分)开始读取,也就是从高地址向低地址读取。组合成的数字是 0x44332211
  • 小端序:从最低有效字节(即数值中权重最小的部分)开始读取,也就是从低地址向高地址读取。组合成的数字是 0x11223344

两种方式本身没有优劣之分,但系统必须统一。在本课程使用的 x86 架构中,采用的是小端序

这意味着,当你想将内存中的四个字节读取为一个整数时,你需要从最低地址的字节开始读取,然后依次读取更高地址的字节。这感觉像是在“倒着”读数字,但这是由系统架构决定的。

字节与字的访问

关于字节序,最后需要明确一点:你可以以两种不同的“粒度”访问内存。

  • 访问单个字节:如果你请求地址 0 处的字节,你会直接得到存储在那里的原始值 0x11,无需考虑字节序。
  • 访问一个字(例如4字节整数):如果你请求从地址 0 开始的一个字(word),系统会按照小端序规则,将 0x11, 0x22, 0x33, 0x44 组合解释为整数 0x44332211

这两种访问方式都是有效的,具体取决于你是想获取原始字节数据,还是想获取由多个字节组合而成的有意义的值(如整数)。

一个常见的简化表示

有时,人们为了书写方便,会直接写出一个整数的值(如 0x44332211),并注明它存储在从地址 0 开始的内存中。

请记住,在小端序系统中,这个值在内存中的实际存储顺序(从低地址到高地址)是:0x11, 0x22, 0x33, 0x44 认识到这种表示法与实际存储之间的区别非常重要,可以避免混淆。

总结

本节课中,我们一起学习了字节序的概念。我们了解到:

  1. 字节序定义了多字节数据(如整数)在内存中的存储和读取顺序。
  2. 主要分为大端序小端序,x86架构采用小端序
  3. 理解字节序的关键在于区分按字节访问按多字节单元(如字)访问内存时的不同解释方式。
    掌握字节序对于后续理解函数调用、参数传递和数据结构在内存中的布局至关重要。虽然它起初可能有些令人困惑,但通过练习你会逐渐熟悉它。

018:内存布局 📊

在本节课中,我们将要学习程序运行时内存的布局结构。我们将了解不同类型的数据(如代码、静态变量、动态分配的内存和局部变量)分别存储在内存的哪个区域,并澄清寄存器与内存的区别。


内存中的四个主要区域

上一节我们了解了内存的基本概念和数据存储方式。本节中我们来看看程序运行时,具体有哪些数据需要放入内存,以及它们是如何组织的。

下图展示了程序内存的典型布局:

以下是内存中需要存储的四种主要数据:

  1. 代码区
    汇编器生成的、代表程序指令的二进制代码(0和1)必须存储在内存中。例如,表示“给X加1”或“将Y乘以2”的指令就存放在这个区域。它对应着你所写程序的“文本”部分。

  2. 数据区
    程序中在整个运行期间都保持不变的静态变量存储在这个区域。

  3. 堆区
    这是一个会增长的内存区域,大小取决于用户需求。当用户调用类似 mallocfree 的函数时,他们请求的是动态分配的内存,即由他们自己管理的内存。随着用户多次调用 malloc 请求更多数据,堆会使用越来越高的地址,因此我们说“堆向上增长”。

  4. 栈区
    这是存储局部变量的地方。例如,当你创建一个函数并在其中定义局部变量时,这些变量就放在栈上。调用函数时,栈会分配额外空间来存储这些变量;函数返回时,栈会释放这些空间以供未来函数使用。栈的分配方向与堆相反,随着函数调用链(如深度递归)变深,栈会在更低的地址提供更多空间,因此我们说“栈向下增长”。

这就是内存的基本布局以及我们需要放入内存的四种数据。


寄存器在哪里? 🤔

了解了内存布局后,你可能会想到在CS61C等课程中听说过的“寄存器”。那么,寄存器在上面的内存布局图中位于何处呢?

你看不到它们,因为它们不在那里。寄存器实际上位于CPU上,是硬件中完全不同的部分。内存中的所有字节都有一个唯一的地址,而寄存器则没有地址,你无法说“给我地址5处的寄存器”,这没有意义。

寄存器是通过名称来引用的。例如,在x86架构中,寄存器有像 %ebp%esp 这样的名称。

所以,本部分的要点是:如果你想将数据存储在内存中,数据会有一个唯一的地址。但如果你想将数据存储在寄存器中,你需要使用寄存器的名称来访问它。寄存器虽然不在内存布局图中,但同样是我们存储数据的一种方式。


总结

本节课中我们一起学习了程序的内存布局。我们明确了内存的四个主要区域:代码区数据区堆区(向上增长,用于动态分配)和栈区(向下增长,用于局部变量)。同时,我们澄清了寄存器是CPU的一部分,通过名称而非地址访问,并不属于主内存布局。理解这些概念是掌握内存管理和安全的基础。

019:x86汇编简介

在本节课中,我们将学习x86汇编语言的基础知识。我们将了解其基本概念、语法和一些关键特性,以便能够阅读和理解简单的x86代码片段。

上一节我们介绍了内存的工作原理以及其中存储的内容。本节中,我们来看看x86汇编语言。

x86是目前最常用的汇编语言。虽然RISC-V等架构也很优秀,但在现实世界中,x86更为普遍。几乎我们所有的计算机都在运行x86,除了部分较新的Mac可能已转向其他架构。重要的是,本课程将使用x86汇编语言。请注意,这不是一门专门的x86课程,因此不会测试具体的语法细节。但我们的目标是,当看到一小段x86代码时,你至少能够阅读并大致理解其含义。

🧠 x86的基本特性

x86采用小端字节序。我们之前讨论过这个概念:当你读取一个4字节的数据块时,是从最高内存地址向最低内存地址读取的。RISC-V实际上也采用相同的方式。

x86与RISC-V的一个不同之处在于指令长度。在x86中,一条指令(如加法指令)被转换为机器码(0和1)后,其长度是可变的。有些指令可能只有1字节,而有些则可能长达16字节。这与RISC-V形成对比,在RISC-V中,每条指令翻译后都恰好是4字节。这个差异是x86的一个特点,你会在后续项目中遇到它。

💾 寄存器

如前所述,x86拥有一组寄存器。它们不在内存中,而是一个完全独立的、可以存储数据的地方。寄存器通过名称来标识。

以下是一些寄存器名称,我们稍后会详细讨论:

  • EAX
  • EBX
  • ECX
  • EDX
  • ESI
  • EDI
  • EBP
  • ESP

需要特别指出的是EIP寄存器。这是指令指针寄存器的一个别致名称,它指示了当前正在执行哪条指令。我们稍后也会看到它。

📝 语法简介

重申一次,这不是x86语法课,因此你无需死记硬背。但当你看到寄存器时,它们前面会有一个百分号%。至于为什么,这只是一个语法规定。

当书写立即数(即指令中的常量值)时,前面会加上美元符号$。例如,$161表示数字161,而不是变量。

如果你想解引用内存(即访问某个地址处的内容),需要使用圆括号()。例如,(%ESI)表示:ESI是一个寄存器,它里面存储着一个地址。圆括号的意思是“请前往这个地址,并告诉我那里存储着什么”。

➕ 指令格式

x86指令的书写格式通常如下所示:
指令助记符 源操作数, 目的操作数

首先写下指令是什么(例如add表示加法,sub表示减法,mul表示乘法等)。然后写下源操作数(即要参与运算的数据来源)和目的操作数(即运算结果存放的位置,并且它本身也参与运算)。

我们不必过分纠结具体的语法细节。以add $8, %ebx为例,这条指令的意思是:我想将数值8加到EBX寄存器中的值上。具体过程是:取出EBX中的值,加上8,然后将结果存回EBX寄存器。

🔄 另一个例子

让我们看另一个例子:xor (%esi), %eax

我们看到(%esi)使用了圆括号,这意味着我必须前往ESI寄存器中存储的地址。这又是一个语法特点。基本逻辑是:ESI中存储着一个地址。我将把这个地址加上4(注意:指令中隐含了偏移量计算,这里(%esi)通常表示以ESI值为地址,但更复杂的寻址模式可能包含偏移,如4(%esi)。原描述可能省略了偏移细节,但核心是解引用)。圆括号表示:我要前往那个(计算后的)地址,也就是进入内存,取出该地址处的数据。

然后,指令执行XOR(异或)操作:将刚从内存取出的数据与EAX寄存器中的值进行异或运算。%eax这里没有括号,所以我不需要解引用它,直接使用其值。但(%esi)需要解引用。所以整体操作是:前往ESI指向的地址获取数据,然后与EAX中的值进行异或,最后将结果存回EAX。这就是圆括号所指示的操作。

本节课中,我们一起学习了x86汇编语言的基础知识,包括其普遍性、小端字节序、可变长度指令、寄存器组、基本语法和指令格式。重点是理解如何通过语法(特别是圆括号)来区分是直接操作寄存器值,还是解引用内存地址。掌握这些将帮助你阅读和理解后续课程中出现的x86代码片段。

020:栈布局 🧱

在本节课中,我们将深入学习栈内存的布局方式,了解程序如何通过栈来管理函数调用和局部变量。我们将重点介绍栈帧的概念、用于追踪栈帧的寄存器,以及如何通过指令操作栈。

栈帧与栈增长 📈

上一节我们介绍了栈的基本概念。本节中我们来看看栈帧的具体布局和增长方向。

当你的代码调用一个函数时,必须在栈上为局部变量分配空间。当函数返回时,这个栈帧就会消失。每次调用函数时,它都会在栈上获得一些空间;当函数返回时,栈帧消失。如前所述,如果你调用很多函数,例如进行深度递归,那么你的栈会变得越来越大。我们通过向下增长来获取额外空间。

这里需要指出,人们常常因为“向下增长”这个说法而认为栈是“颠倒的世界”,甚至想把栈倒过来读,认为所有东西都是自底向上的。事实并非如此。“向下增长”只意味着一件事:如果我需要更多内存来存储东西,我将使用一个更低的地址。这就是“向下增长”的全部含义,不代表其他任何意思。字符串仍然是从最低地址读到最高地址。栈上的地址值仍然随着你向上移动而增加。一切都保持不变。“向下增长”仅仅意味着,当我需要更多空间时,额外的空间来自更低的地址。所以,当你参加考试或类似场合时,不要看到栈就想着把试卷倒过来看。那不是“向下增长”的意思。它只是意味着当我需要更多空间时,向栈的“下方”看,从更低的地址给我分配空间。

追踪当前栈帧 🔍

那么,如何知道当前的栈帧在哪里呢?请记住,尽管我们画得很漂亮,并且用红色为你标出了栈帧,但程序本身并不会看到这个漂亮的红色标记。我的电脑上没有任何东西会说“哦,这是红色的栈帧”。因此,我们必须使用寄存器来跟踪当前栈帧的位置。

请记住,你调用的每个函数都有一个与之关联的栈帧。我需要知道当前正在执行的函数的栈帧是什么。这意味着我需要用寄存器来跟踪这些信息。我的电脑不会拿起彩色铅笔开始把东西涂成红色。我需要寄存器来跟踪当前栈帧的开始和结束位置。

因此,我将使用两个寄存器。请记住,寄存器有名字。这两个寄存器将告诉我当前栈帧的位置。EBP(基址指针)是一个具有特殊名称的寄存器,它存储一个地址,这个地址是当前栈帧顶部的地址。ESP(栈指针)也是一个寄存器。如果你打开它并查看里面的值,它是一个地址,而这个地址恰好是你当前栈帧最低部分的地址。

这两个寄存器保存两个地址,它们告诉我当前栈帧从哪里开始,到哪里结束。两者之间的所有内容,就是我正在使用的当前栈帧。这些寄存器保存的是地址。顺便说一下,如果你看到这些箭头,可能会问这些箭头实际上代表什么。这是和之前一样的图,EBP指向这里,ESP指向这里。当我画这些箭头时,它们实际上意味着什么?如果你不喜欢箭头,实际上也可以这样画:EBP保存一个地址,这个地址就是内存中某个位置的地址。如果我访问内存中的那个地址,那个地址就是我当前栈帧顶部的地址。所以EBP保存着一个地址,它是内存中这一部分的地址。同样地,ESP也保存着一个地址,如果我访问那个地址,它就是我当前栈帧的底部。

但这很难阅读。我必须匹配所有这些数字,这很麻烦。所以,我没有写这种难以阅读的东西,而是画了箭头。箭头只是表示EBP是一个地址,如果我访问那里,就是当前栈帧的顶部。但请记住,这些箭头基本上等同于存储地址。我只是不想像这样画它们,因为很烦人。

操作栈空间:PUSH与POP ⬇️⬆️

现在我知道了当前栈帧是什么。但如果我想要更多空间怎么办?假设我有一个栈帧,这很好。但我想在栈上存储更多数据。如果你想这样做,可以使用PUSH和POP指令。这些指令允许你向栈添加东西和从栈移除东西。

假设你想向栈添加一些东西,你可以使用PUSH指令。PUSH指令做两件事。第一件事是它向栈写入数据。例如,如果我说 PUSH EAX,我会取出EAX寄存器中的值,把它放到栈上。但这还不够,因为如果我把它放在这里,我还应该减少ESP,让它现在指向我栈帧的底部。也许另一种说法是,EBP指向栈的底部。如果我在这里写了这个数据,我应该将ESP向下移动,以表明现在有更多你需要关心的东西。所以ESP向下移动,现在栈上有了更多数据。因此,PUSH指令做两件事:将数据放入栈中,并将ESP向下移动,以提醒自己这些数据现在也是“家族”的一部分,它是栈帧的一部分。

这是相反的操作:POP。那么POP做什么呢?它取出栈上的值并删除它。当我删除它时,我将ESP向上移动,以表示这个曾经是“家族”一部分的东西,现在不再是了。它已经被删除了。所以我向上移动ESP以表明它消失了。如果你愿意,还可以选择性地将该值放入一个寄存器。所以如果我说 POP EAX,意思就是取出这个值,将其从栈中移除,将ESP上移(我不再关心它了),然后把这个值放入EAX寄存器。所以POP是PUSH的反操作,我们将使用这两个指令。

关于x86架构的简化假设 📝

以下是一些假设。x86架构极其复杂,这不是一门x86课程。x86手册大约有3000页,我们不会去读它,尽管我想如果你非常无聊的话可以读。但x86的工作方式非常复杂。因此,我们将做一些假设来简化事情。我不会全部大声读出来,但随着课程的进行你会看到这些假设。

  • 我们假设局部变量出现在栈上。你也可以把它们放在寄存器里,但我们将假设它们在栈上。
  • 我们假设当你声明变量时,我们将按顺序排列它们,第一个变量在最高地址。
  • 我们假设在结构体中,第一个成员在最低地址。
  • 我们假设全局变量中,第一个变量在最低地址。

随着课程的进行,你会看到这些。所以你不必死记硬背。它们只是我们做出的假设,以便当我们绘制x86图表时,我们都使用相同的假设。

理解测验 ✅

如果你想测试对我们刚才所说假设的理解,可以试试这个小测验。如果你正在看视频,可以暂停一下想一想。

基本上,结论是:如果我有一个函数,假设我想声明A,然后B,然后C。A将出现在最高地址。然后我们会分配更多空间来存储B,再分配更多空间来存储C。所以,随着我声明更多变量,后续的变量会出现在栈上更低的位置。如果我声明一个结构体,结构体中的第一个成员F1出现在最低地址,然后是F2,然后是F3。F1是8字节,所以我给了它两行。

总结 📚

本节课中我们一起学习了栈内存的布局。我们明确了“栈向下增长”的确切含义,即新分配的空间使用更低的地址。我们认识了两个关键寄存器:EBP(基址指针)指向当前栈帧的顶部,ESP(栈指针)指向当前栈帧的底部,它们共同界定了一个栈帧的范围。我们还学习了操作栈的两种基本指令:PUSH指令将数据压入栈并减小ESP,POP指令将数据弹出栈并增大ESP。最后,我们了解了一些关于x86内存布局的简化假设,这些假设将帮助我们在后续课程中更清晰地分析问题。理解栈的布局是分析内存安全漏洞的基础。

021:调用约定

在本节课中,我们将要学习函数调用在x86架构中是如何具体执行的。我们将了解调用约定,以及CPU的寄存器如何协同工作,以确保函数调用和返回时数据不会丢失或损坏。


上一节我们介绍了函数调用需要解决的问题,本节中我们来看看x86架构在调用函数时具体做了哪些事情。

当代码从main函数调用一个名为foo的函数时,需要完成以下步骤:

  1. 跳转到foo函数的代码处开始执行。
  2. 执行foo函数中的所有指令。
  3. foo函数执行完毕后,返回到main函数并继续执行。

今天的所有内容都将围绕“如何实现这个过程”来展开。


这个过程有时被称为“调用约定”。其核心在于,我们需要设计一套规则,并且所有程序都遵循这套规则来调用函数。这样就能确保在函数调用和返回时,不会丢失或覆盖其他函数所依赖的数据。

这就像你从饼干罐里拿饼干,只要在用完后把所有东西放回原处,父母回家后就发现不了异常。调用约定的目标也是如此:函数调用结束后,一切恢复原状。


以下是函数调用过程中栈和寄存器状态的变化概览。

在函数调用发生之前,你正处在main函数中。此时:

  • EBP 寄存器指向main函数栈帧的顶部。
  • ESP 寄存器指向main函数栈帧的底部。
  • EIP 指令指针寄存器指向main函数中正在执行的代码地址。

现在你想要调用foo函数。调用发生时,必须发生以下几件事:

  1. 必须为foo函数创建一个新的栈帧。这是foo函数用来存储局部变量、进行计算等操作的空间。
  2. 创建新栈帧后,EBP 需要指向新栈帧的顶部,ESP 指向新栈帧的底部。
  3. EIP 指令指针原先指向调用者(main)的指令,现在必须切换,使其指向被调用函数(foo)的指令,因为接下来要执行foo中的代码。

因此,调用函数时,EBPESP这两个指针需要“下移”以创建新栈帧,而EIP需要“跳转”到新函数的代码位置。


最后,当foo函数执行完毕并返回时,我们需要将所有状态恢复原状。foo函数必须负责清理自己留下的“烂摊子”,这样当控制权回到main函数时,一切就像什么都没发生过一样。

具体来说,当foo返回时:

  • EBP 应恢复为指向main函数栈帧顶部的原值。
  • ESP 应恢复为指向main函数栈帧底部的原值。
  • EIP 应指回main函数中call指令之后的那条指令地址。

这样,所有寄存器都回到了原来的位置,main函数便可以继续正常运行。


本节课中我们一起学习了x86架构下的函数调用约定。我们了解了调用函数时需要为被调用函数创建新的栈帧,并更新EBPESPEIP寄存器。更重要的是,我们明白了函数返回时必须恢复这些寄存器的原始值,以确保调用者能够无缝地继续执行。这套规则是程序正确运行的基础。

022:调用约定设计 🧠

在本节课中,我们将设计一个函数调用约定。我们将学习如何通过操作寄存器和栈,在调用函数时保存当前状态,并在函数返回后恢复原状,确保程序能正确运行。


上一节我们介绍了函数调用时栈和寄存器的基本概念。本节中,我们来看看如何具体设计调用约定,以实现从 main 函数到 F 函数的切换与返回。

首先,我们回顾一下相关的寄存器和栈布局。我们关心三个寄存器:EBP(基址指针)、ESP(栈指针)和 EIP(指令指针)。栈是向下增长的,这意味着需要更多空间时,会在栈的更低地址处分配。

以下是栈和代码的布局示意图。地址从图底部向顶部增加,每一行代表四个字节。


当程序运行 main 函数时,情况如下:

  • EBP 指向当前栈帧的顶部。
  • ESP 指向当前栈帧的底部(也是整个栈的底部)。
  • EIP 指向 main 函数的代码地址。




需要特别指出的是,ESP 实际上承担着双重职责。它不仅标记当前栈帧的底部,也标记着整个栈的底部。栈中低于 ESP 地址的内容是未定义的。因此,当我们向栈中压入(push)数据时,必须同时递减 ESP 的值,使其指向更低的地址,以“记住”我们压入的数据。如果不移动 ESP,写入操作就相当于写入了“虚空”,程序将无法保留该值。


现在,我们来看函数调用的核心过程。从 main 函数调用 F 函数时,状态需要发生转变。

调用前,状态属于 main 函数:EBPESP 界定出 main 的栈帧,EIP 指向 main 的代码。


调用后,状态应切换到 F 函数:EBPESP 需要向下移动,为 F 函数创建新的栈帧,同时 EIP 需要改为指向 F 函数的代码。




我们的目标是,当 F 函数执行完毕返回时,所有寄存器都能恢复到 main 函数调用前的状态。


实现上述目标的关键原则是:在覆盖任何寄存器的值之前,必须保存其旧值

这就像在修改一份重要文件前先做备份。如果我们想改变 EBP 的值,使其指向新的位置,不能直接覆盖它。我们必须先把 EBP 原来的值存到某个地方(通常是栈上),然后再赋予它新值。这样,当函数返回时,我们可以从保存的地方取出旧值,放回 EBP,从而恢复原状。

以下是整个设计过程必须遵循的核心步骤:

  1. 保存返回地址:调用函数时,首先需要将 EIP 的当前值(即 call 指令后的下一条指令地址)压入栈中保存。这是函数返回后应继续执行的位置。
    push eip  ; 将下一条指令地址压栈(实际由 call 指令完成)
    

  1. 保存旧的基址指针:然后,将 EBP 寄存器的当前值(即 main 函数的栈帧基址)压入栈中保存。

    push ebp
    
  2. 建立新的栈帧:将 ESP 的当前值(此时指向刚保存的旧 EBP)赋给 EBP。这样,EBP 就指向了新栈帧的顶部。

    mov ebp, esp
    
  3. 分配局部变量空间:如果需要为被调函数分配局部变量空间,则递减 ESP 的值。

    sub esp, N  ; N 为所需字节数
    
  4. 函数返回与恢复:函数执行完毕后,需要逆向操作。

    • 释放局部变量空间(如果需要):mov esp, ebp
    • 恢复旧的 EBPpop ebp
    • 跳转回返回地址:ret (该指令会从栈中弹出返回地址并赋值给 EIP

牢记:我们今天所做的一切设计,核心目标都是“在过程中保存工作状态”。这是理解调用约定最关键的一点。


本节课中,我们一起学习了函数调用约定的基本设计思路。我们理解了在调用函数时,需要通过操作栈来保存返回地址和旧的栈帧基址(EBP),并更新 EBPESPEIP 以切换到新函数的上下文。最重要的是,我们掌握了“覆盖前先保存”的核心原则,这是确保函数调用后能正确返回并恢复现场的基础。下一节,我们将基于此设计,深入查看具体的汇编指令实现。

023:函数调用步骤(概述)👨‍💻

在本节课中,我们将学习当一个函数(例如 main)调用另一个函数(例如 foo)时,计算机内部发生的具体步骤。我们将重点关注栈内存的变化以及关键寄存器(如 EIPEBPESP)是如何被保存和恢复的,以确保程序能正确执行并返回。

理解这些步骤是掌握程序底层运行机制和内存安全概念的基础。


第一步:将参数压入栈 📥

main 函数需要调用 foo 函数时,它必须将参数传递给 foo。在 x86 架构中,传递参数的常规方式是将它们压入栈中。

以下是具体操作:

  • 假设 main 有两个参数要传给 foo,它会将这两个参数依次压入栈。
  • 记住,当向栈中压入数据时,栈指针 ESP 的值会减小(向低地址移动)。
  • 一个细节是,参数是按逆序压入栈的(这是 x86 的约定)。

至此,我们成功将参数放置在了栈上,为函数调用做好了准备。


第二步:保存返回地址(旧 EIP 值)💾

在开始修改任何寄存器之前,我们必须先保存它们的旧值,以便函数执行完毕后能恢复现场。

首先需要修改的是指令指针寄存器 EIP。当前 EIP 指向 main 函数的代码,我们需要让它指向 foo 函数的代码。但在覆盖 EIP 之前,必须保存其旧值。

保存位置就是栈。我们将 EIP 的当前值(即 main 函数中调用指令之后的下一条指令地址)复制并压入栈中。这个被保存的值通常被称为返回地址RIP

和之前一样,压栈操作会使栈指针 ESP 下移。

现在,EIP 的旧值已安全保存在栈上,我们可以放心地修改 EIP 寄存器了。


第三步:保存旧的栈帧基址(旧 EBP 值)📝

接下来需要修改的寄存器是栈帧基址指针 EBP。我们计划为 foo 函数创建一个新的栈帧,这意味着需要移动 EBP

同样,在修改 EBP 之前,必须保存其当前值(即 main 函数栈帧的顶部地址)。我们将这个值压入栈中保存。这个被保存的值有时被称为保存的帧指针SFP

压栈操作再次使 ESP 下移。

至此,EIPEBP 的旧值都已安全地保存在栈上。我们现在可以自由地更改这些寄存器的内容了。


第四步:建立新的栈帧(更新寄存器)🔄

所有必要的旧值都已保存,现在可以更新寄存器,为 foo 函数建立全新的执行环境。

以下是寄存器变化前后的对比:

  • EBP:之前指向 main 函数栈帧的顶部;现在指向为新函数 foo 创建的栈帧的顶部。
  • ESP:之前指向 main 函数栈帧的底部;现在指向 foo 函数栈帧的底部。
  • EIP:之前指向 main 函数的代码;现在指向 foo 函数的代码。

这三个寄存器的移动,共同为 foo 函数划定了一块专属的栈内存空间,即它的栈帧


第五步:执行被调用函数 🏃‍♂️

新的栈帧已经建立。现在,foo 函数可以开始执行其代码。它可以自由地使用属于自己的栈空间,例如:

  • 存放局部变量。
  • 作为临时计算空间。
  • 进行其他任何它需要的操作。

foo 函数在其栈帧内拥有完全的控制权。


第六步:恢复现场并返回 ↩️

foo 函数执行完毕,需要返回 main 函数时,我们必须将所有寄存器恢复到调用前的状态。

关键在于我们之前保存的旧值:

  • 从栈中取出之前保存的 SFP 值,放回 EBP 寄存器,这样 EBP 就重新指向了 main 函数的栈帧。
  • 从栈中取出之前保存的 RIP 值(返回地址),放回 EIP 寄存器,这样程序就会继续执行 main 函数中调用 foo 之后的下一条指令。
  • 随着这些弹出操作,栈指针 ESP 也会自然上移,回到 main 函数栈帧的底部。

核心原则:在修改任何关键寄存器之前,务必先将其旧值保存到栈上。这样,在函数结束时,才能准确地恢复现场,确保程序流程正确无误。


补充说明与细节 🔍

上一节我们完成了函数调用的核心步骤,本节中我们来看看一些重要的补充细节。

关于 ESP 的保存
你可能注意到,在整个过程中我们没有显式地保存栈指针 ESP 的旧值。这是因为在我们的设计里,ESP 的移动是压栈和弹栈操作的自然结果。当我们将所有保存的值和参数弹出栈后,ESP 会自动回到正确的位置。因此,保存 ESP 是不必要的。

关于栈上的残留数据
函数返回后,原 foo 函数栈帧中的数据可能仍保留在内存中,但因为它们位于当前 ESP 之下,属于“未定义”区域,所以通常被忽略。系统可以将其覆盖为零,但通常出于效率考虑而保持原样。

术语回顾

  • RIP:保存在栈中的返回地址,用于恢复 EIP
  • SFP:保存在栈中的旧帧指针,用于恢复 EBP

总结 📚

本节课中我们一起学习了函数调用的六个核心步骤:

  1. 参数入栈:将调用参数按逆序压入栈。
  2. 保存返回地址:将当前 EIP 值(返回地址)压栈保存。
  3. 保存旧帧指针:将当前 EBP 值(旧栈帧基址)压栈保存。
  4. 建立新栈帧:更新 EBPESPEIP,指向被调用函数的栈帧和代码。
  5. 执行函数:被调用函数在其自己的栈帧内运行。
  6. 恢复与返回:利用栈上保存的值恢复 EBPEIP,使 ESP 回归,控制权交回调用者。

整个过程的核心思想是保存现场、执行任务、恢复现场。通过栈这一数据结构,计算机优雅地管理了函数调用链,确保了程序能够有序地执行和返回。在接下来的课程中,我们将深入更多细节。

024:函数调用步骤(详细)

在本节课中,我们将深入学习X86架构中函数调用的详细步骤。理解这些步骤是掌握后续许多攻击技术的关键,也能帮助你更高效地完成项目。

上一节我们介绍了函数调用的高层概览,本节我们将详细拆解每一步,确保你透彻理解函数调用是如何工作的。

函数调用概述

回忆一下,函数调用涉及两个角色:调用者(caller,如 main 函数)和被调用者(callee,如 foo 函数)。整个过程的核心在于安全地转移三个关键寄存器的指向:

  • EIP:指令指针,指向下一条要执行的指令。
  • EBP:基址指针,指向当前栈帧的顶部。
  • ESP:栈指针,指向当前栈帧的底部。

调用新函数时,EBPESP 需要“下移”(即寄存器中的地址值减小),以指向新的栈帧。同时,EIP 需要改为指向新函数的指令。当函数返回时,一切必须恢复原状。我们通过在栈上保存这些寄存器的旧值来实现恢复。

现在,我们将通过11个具体步骤,展示每个寄存器是如何变化的,以及对应的X86汇编代码。

详细步骤解析

以下是函数调用的完整步骤流程图,我们将逐一讲解:

前几步由调用者(main)执行。一旦更新了 EIP,控制权就转移给了被调用者(foo),由它执行中间步骤。最后,当恢复原始的 EIP 后,控制权回到 main,由它完成最后的清理工作。

初始状态

我们有一段C代码及其编译后的X86汇编代码。我们将使用一个 EIP 小箭头来指示当前正在执行的指令。栈图是我们熟悉的样子:每一行是4字节,地址从下往上递增。需要更多空间时,在更低地址分配内存,这就是栈向下增长的含义。

此时,我们在调用者(main)中,EIP 指向 call 指令,EBPESP 标示着调用者的栈帧。

步骤 1-2:调用者准备参数并转移控制权

现在,调用者需要将控制权转移给被调用者。首先,它必须传递参数。

步骤 1:将参数压入栈中
调用者需要告诉被调用者有两个参数:数字1和数字2。传递方式是将它们压入栈中。按照X86惯例,参数以逆序压入。

push 2
push 1

每压入一个值,ESP 必须下移,以表明这些值现在是栈的一部分。

步骤 2:调用函数,保存返回地址并跳转
现在准备跳转到被调用者的代码。但在此之前,必须保存当前 EIP(即 call 指令之后的下一条指令地址),以便函数返回时能回到这里。call 指令自动完成了这个复杂操作:

  1. 将旧的 EIP 值(返回地址)压入栈中。
  2. ESP 下移。
  3. EIP 设置为被调用函数(foo)的起始地址,从而跳转过去。

至此,控制权正式转移到被调用者 foo

步骤 3-5:被调用者建立新栈帧(序言)

现在轮到被调用者 foo 执行了。它需要建立自己的新栈帧。

步骤 3:保存旧的 EBP 值
foo 首先将当前 EBP 的值(指向调用者栈帧顶部)压入栈中保存。

push %ebp

这样,即使之后改变 EBP,其旧值也安全地保存在栈上。

步骤 4:设置新的 EBP
接着,fooEBP 设置为当前 ESP 的值,即新栈帧的顶部。

mov %esp, %ebp

现在,EBPESP 指向同一个地址。

步骤 5:为局部变量分配栈空间
最后,foo 通过减小 ESP 来在栈上分配空间,用于局部变量等。

sub $4, %esp

这里的数字 4(或其他值)由编译器根据函数的复杂程度(如局部变量数量)决定。这样就创建了一个专属于 foo 的新栈帧。

步骤 6:被调用者执行函数体

现在,foo 可以自由使用它的栈帧空间了。它可以进行运算、设置局部变量等。

// 对应的C代码操作
int local = a + b; // 假设的操作
return 42;

在我们的例子中,它可能创建一个局部变量并准备返回值(通常放在 EAX 寄存器中)。

步骤 7-10:被调用者清理并返回(尾声)

函数执行完毕,需要返回。此时必须将所有寄存器恢复原状,以便调用者继续执行。

步骤 7:释放局部栈空间(恢复 ESP)
首先,将 ESP 移回 EBP 的位置,从而“释放”或“丢弃”为局部变量分配的空间。

mov %ebp, %esp

步骤 8:恢复旧的 EBP
接着,需要将 EBP 恢复为调用者栈帧的顶部地址。这个旧值我们在步骤3中保存在栈上。pop 指令将其从栈中取出并放回 EBP 寄存器。

pop %ebp

执行 pop 时,ESP 会自动上移。

步骤 9:返回调用者(恢复 EIP)
最后,需要让 EIP 指回调用者中 call 指令之后的位置。这个返回地址在步骤2中被保存在栈上。ret 指令相当于 pop %eip,它将栈顶的返回地址弹出并放入 EIP

ret

至此,控制权交还给了调用者 main

步骤 11:调用者清理参数

函数已经返回,但之前压入栈中的参数还在。调用者需要清理它们。

add $8, %esp

这条指令将 ESP 上移8字节(因为压入了两个4字节参数),从而将参数从栈中移除。

最终状态与总结

观察最终状态图,并与初始状态对比,你会发现一切已恢复原状:

  • EBPESP 重新指向调用者(main)栈帧的顶部和底部。
  • EIP 指向 call 指令之后的下一条指令。
  • 栈上为此次函数调用临时保存的数据(参数、返回地址、旧EBP)已被清理。

本节课中,我们一起详细学习了X86函数调用的完整11个步骤,从调用者准备参数、转移控制权,到被调用者建立栈帧、执行代码、清理恢复,最后调用者完成收尾。这个过程虽然步骤繁多,但每一步都遵循着保存现场、执行任务、恢复现场的原则。透彻理解这些步骤是理解栈操作和后续内存安全概念的基础。如果你觉得复杂,这是完全正常的,建议多看几遍图示和讲解,直到内化于心。

025:x86汇编与调用栈总结 🧠

在本节课中,我们将总结之前视频中关于C语言内存布局、x86汇编关键寄存器以及函数调用约定的核心内容。这些知识是理解程序如何在底层运行以及后续内存安全概念的基础。

C语言内存布局 📊

上一节我们介绍了函数调用栈的细节,本节我们来回顾程序运行时的整体内存布局。一个C程序在内存中主要分为四个我们关心的区域。

以下是这四个内存区域及其用途:

  • 代码区:存放编译后的机器指令,即由0和1组成的二进制代码。
  • 静态区:存放静态变量,例如全局变量。
  • 堆区:存放通过malloc等函数动态分配的内存。
  • 栈区:存放函数的局部变量。

在最近的课程中,我们花了大量时间深入探讨了栈区的工作原理。

关键寄存器 💾

理解了内存布局后,我们需要知道CPU如何与这些区域交互,这离不开寄存器的帮助。本节我们来看看三个至关重要的寄存器。

以下是三个核心寄存器及其作用:

  • EIP:指令指针寄存器。它告诉我们当前正在执行的是哪一条指令。
  • ESP:栈指针寄存器。它指向当前栈帧的底部。
  • EBP:基址指针寄存器。它指向当前栈帧的顶部。

人们常常会忽略EBP,但它非常重要。它标明了栈帧的顶部位置,我们可以利用它来定位栈上的其他数据,这非常有用。

函数调用约定 📞

掌握了寄存器的功能后,我们来看看它们是如何在函数调用过程中协同工作的。这就是函数调用约定,它包含了一系列步骤。

调用约定包含许多步骤,你可以反复观看视频以掌握它。但最重要的是,无论何时你要修改EIP或EBP这类寄存器的值,都必须先将它们的旧值压入栈中。这样,当函数返回时,才能从栈上取出这些值并恢复EBP和EIP。

这可能是本系列视频中最重要的知识点。


本节课中,我们一起学习了C程序的内存四区划分,认识了EIP、ESP、EBP三个关键寄存器的作用,并理解了函数调用约定的核心原则——保存和恢复上下文。这些底层机制是理解程序控制流和后续内存漏洞(如缓冲区溢出)的基础。

026:机场类比 🛫

在本节课中,我们将学习内存安全漏洞的基本概念。我们将通过一个机场值机系统的类比,来理解C语言中缓冲区溢出的核心原理。上一节我们回顾了X86汇编和调用栈的基础知识,本节中我们来看看一个具体的漏洞场景。

课程概述

本节课我们将探讨一个模拟的机场值机系统漏洞。这个例子将帮助我们理解,当程序在内存中存储数据时,如果缺乏明确的边界,攻击者如何通过输入超长数据来破坏程序的正常逻辑,从而获得未授权的访问或特权。

机场值机系统类比

想象一个使用非常老旧计算机的机场值机柜台。这台计算机会加载乘客提交的机票信息,通常包括姓名、舱位等级(如经济舱、头等舱)以及任何特殊说明。其界面可能看起来非常古老。

现在,假设一位名叫Alice Smith的乘客在订票时,不小心(或故意)在输入姓名时睡着了,或者她的猫在键盘上走过。结果,她的姓名不是“Alice Smith”,而是变成了“Alice SmithHHHHHHHHH”。这是一个非常奇怪的名字。

那么,当Alice Smith带着这个姓名去机场值机时,会发生什么呢?她前往值机,那台老旧的电脑显示了以下信息:你的名字是 Alice Smith HHHHH,而你的舱位等级变成了 HH。


这很有趣。我们来思考一下这里发生了什么。或许可以问你一个问题:你希望如何利用这一点?想一想,Alice可以给自己起一个什么样的名字,来导致一些不好的事情发生?

暂停视频,思考一下。

好的。假设现在Alice的名字是“Alice Smith HHHHHH business”。让我们看看如果输入这个名字会发生什么。

也许我先输入名字,但得到了类似的效果:“Alice Smith HHHHHH first”。我甚至可以更聪明一点,不用‘H’,而是用空格键。所以名字变成了“Alice Smith”后面跟着一串空格,然后是“first”。

现在,当你值机时,系统突然只显示你在头等舱。这对你来说是好事,但对航空公司来说是坏事。这就是我们可以进行的一种攻击。

如果我想更有创意,我可以添加更多字符,甚至可以更改特殊说明,比如“给我香槟”或“不要弄丢我的行李”之类的。

那么,我用了哪两个想法来实现“Alice Smith”后面跟一串空格然后是“first”呢?这里发生了两件事。

一件事是,我没有全部用‘H’,而是用了空格键,以此来欺骗机场值机柜台的人员,让他们不觉得这里有什么奇怪。

但更重要的是,这里到底发生了什么?如果你思考一下这台计算机在做什么,它似乎非常老旧,似乎不知道姓名在哪里结束,舱位等级在哪里开始。这里有这两行信息。而且似乎没有任何边界来阻止某人写入超过第一行的末尾并进入第二行。

因此,如果我必须总结这台计算机的问题所在,那就是:这两行之间没有边界

这允许Alice Smith使她的名字变得非常长,写入超过第一行的末尾,并写入到第二行,而这本不应该发生。现在,Alice Smith有了香槟,她的行李也不会丢失了。所以,坏事发生了。

核心概念总结

这就是我们的类比。从这个故事中得出的关键要点(你不需要记住任何关于机场柜台的具体细节)是:内存中缺乏边界

在内存的某个地方,没有明确的概念来标识事物从哪里开始,到哪里结束。这使得攻击者能够越过一个事物的末尾,写入到另一个本不应由他们控制的事物(比如舱位等级)中。这就是这个故事的核心要点。

本节总结

本节课中,我们一起通过一个机场值机系统的生动类比,理解了内存安全漏洞中的一个基本问题——缓冲区溢出。我们看到了当程序在内存中存储数据时,如果缺乏明确的边界检查,攻击者如何通过提供超长的输入数据,覆盖相邻的内存区域,从而篡改程序逻辑和数据。下一节,我们将把这个类比映射到实际的C代码和内存布局中,深入探讨缓冲区溢出的技术细节。

027:缓冲区溢出漏洞 🛡️

在本节课中,我们将要学习C语言中一个核心的安全问题:缓冲区溢出漏洞。我们将通过对比其他高级语言的行为,来理解C语言处理内存访问时的独特方式及其潜在风险。

对比其他语言的行为

上一节我们介绍了内存的基本概念,本节中我们来看看C语言在处理数组访问时与其他语言有何不同。

在Java或Python等高级语言中,如果你尝试访问数组边界之外的元素,程序会明确阻止并报错。例如,以下代码在其他语言中会导致错误:

char name[4] = "Dave";
name[5] = 'A';

以下是其他语言对此类操作的处理方式:

  • 程序会检测到索引5超出了数组name(有效索引为0到3)的边界。
  • 程序会抛出异常或错误,并终止运行。
  • 这是一种安全机制,防止程序访问不属于它的内存区域。

C语言的内存模型

那么,C语言会怎么做呢?C语言对内存的看法与上述语言截然不同。

C语言将内存视为一个巨大的、连续的字节序列,地址从0x0到0xFFFFFFFF。它本身并不跟踪变量或数组在内存中的确切边界。当执行name[5] = 'A';时,C语言会进行以下操作:

  1. 找到数组name的起始内存地址。
  2. 计算偏移量:起始地址 + 5 * sizeof(char)。
  3. 将字符'A'写入计算得到的内存地址。

核心概念:C语言默认不进行数组边界检查。其内存访问可以抽象为以下公式:
目标地址 = 基地址 + 索引 * 元素大小

这意味着,只要计算出的地址在进程可访问的虚拟内存范围内,无论它是否属于name数组,C语言都会执行写入操作。因此,这段代码在C语言中是有效的,能够通过编译并运行。

缓冲区溢出漏洞的原理

正是由于C语言不检查边界,才导致了缓冲区溢出漏洞。

攻击者可以利用这一点,就像我们之前看到的航空公司值机系统示例一样。如果一个程序允许用户向固定大小的缓冲区(如数组)输入数据,但没有严格限制输入的长度,攻击者就可以输入超长的数据。

以下是可能发生的后果:

  • 超出的数据会覆盖相邻内存区域的内容。
  • 这些被覆盖的区域可能存储着其他变量、函数参数,甚至是控制程序流程的关键数据(如函数返回地址)。
  • 通过精心构造的输入,攻击者可以改变程序行为,例如执行恶意代码或使程序崩溃。

这种攻击被称为缓冲区溢出攻击,它是利用软件漏洞最常见的手段之一。

总结

本节课中我们一起学习了缓冲区溢出漏洞的根源。我们了解到,C语言出于性能和灵活性的设计,将内存视为一个扁平的字节数组,并且不自动检查数组访问的边界。这与Java、Python等语言的安全机制形成鲜明对比。这种“信任程序员”的设计哲学,在缺乏足够安全检查时,会打开安全漏洞的大门,允许攻击者通过溢出缓冲区来读写相邻内存,从而可能控制程序执行流程。理解这一核心原理是学习后续更多内存安全攻击与防御技术的基础。

028:数据覆写攻击

概述

在本节课中,我们将学习一种经典的内存安全漏洞:缓冲区溢出攻击。我们将通过具体的代码示例,理解攻击者如何利用C语言不检查数组边界的特性,覆写本不应被修改的内存数据,从而改变程序的行为。

缓冲区溢出基础

上一节我们介绍了C语言中数组边界检查的缺失。本节中我们来看看一个最经典的例子:gets函数。

gets函数的作用是从用户处获取输入。它不限制用户输入的字符数量,会一直读取字符,直到用户按下回车键(输入换行符)。然后,它会将所有读取到的字符写入到指定的字符数组中。

char name[20];
gets(name); // 危险!不检查输入长度

问题在于,如果用户输入的字符数量超过了name数组的容量(例如20个字符),gets函数依然会将所有字符写入内存。这会导致写入的数据超出数组边界,覆盖相邻内存区域的内容。

覆写相邻变量

理解了基础原理后,我们来看一个更具体的攻击场景。假设我们有两个相邻的字符数组。

char name[20];
char instructions[20];
gets(name); // 攻击向量

在内存中,这两个数组通常是连续存放的。以下是内存布局的简化表示:

[ name 数组 (20字节) ]
[ instructions 数组 (20字节) ]

当调用gets(name)时,用户输入的数据从name数组的起始地址开始写入。如果用户输入超过20个字符(例如“Alice SmithHHHHH please serve me champagne”),多出的字符就会越过name数组的边界,继续向下写入内存,从而覆盖掉instructions数组的内容。

这样,攻击者就成功地篡改了instructions变量,而程序本身只允许向name写入数据。

覆写关键控制变量

让我们将问题升级。考虑一个控制用户权限的变量。

char name[20];
int authenticated = 0; // 0表示未认证,1表示已认证
gets(name); // 攻击向量

在这个例子中,authenticated是一个整数变量,用于决定用户是否有权执行敏感操作(如查看密码)。其初始值为0(无权限)。

内存布局可能如下:

[ name 数组 (20字节) ]
[ authenticated 变量 (4字节) ]

当攻击者通过gets(name)输入超长字符串时,数据同样会从name开始写入。在写满20字节的name数组后,继续写入的字节就会覆盖后面的authenticated变量。

如果攻击者精心构造输入,使第21到24个字节的值为1(注意整数在内存中的表示方式),那么authenticated的值就会被从0修改为1。这样,攻击者就绕过了认证检查,非法获得了高级权限。

这个漏洞的根源在于,代码本意只允许修改name,但由于缺乏边界检查,攻击者得以篡改关键的authenticated变量。

总结

本节课中我们一起学习了缓冲区溢出攻击中的“数据覆写”类型。我们通过例子看到,由于C语言不检查数组边界,使用不安全的函数(如gets)会导致用户输入的数据覆盖相邻的内存区域。攻击者可以利用这一点,覆写程序中的其他变量,从而改变程序逻辑,例如覆写一个权限控制变量来提升自己的访问权限。理解这种攻击模式是编写安全代码、避免此类漏洞的第一步。

029:覆盖命令与函数指针

在本节课中,我们将学习缓冲区溢出漏洞的另外两种具体表现形式:覆盖终端命令和覆盖函数指针。我们将通过简单的例子,理解攻击者如何利用这些漏洞执行恶意操作。

概述

上一节我们介绍了通过缓冲区溢出来覆盖普通变量(如champagne)的值。本节中,我们将看到同样的溢出原理,如何被用来覆盖更危险的元素:系统命令函数指针。理解这些模式,有助于我们认识到此类漏洞在真实世界中的普遍性与严重性。

覆盖终端命令

以下是第一个示例。我们有一个字符缓冲区和一个存储终端命令的字符数组。

char buffer[512];
char command[] = "ls"; // 列出文件的命令
gets(buffer); // 不安全的输入函数
system(command); // 执行命令

代码中,gets(buffer)允许用户向buffer写入任意长度的数据。由于buffer在栈上,紧随其后的可能就是command数组。如果用户写入的数据超过了512字节,多出的部分就会“向上”覆盖栈内存,从而可能修改command的内容。

此时,command中原本无害的ls命令,可能被替换成任何恶意命令,例如:

  • email_passwords_to_attacker
  • rm -rf / (删除系统文件)
  • 发送垃圾邮件

随后,system(command)函数会打开终端并执行这个已被篡改的命令,导致严重后果。

覆盖函数指针

接下来,我们看一个更特殊的例子:函数指针。

什么是函数指针?在C语言中,函数指针是一个存储函数地址的变量。通过这个地址,程序可以找到并执行相应的函数代码。

void (*func_ptr)(); // 声明一个无参数、无返回值的函数指针
func_ptr = &harmless_function; // 指向一个无害函数
gets(buffer); // 不安全的输入
func_ptr(); // 调用函数指针

在这段代码中,gets(buffer)同样允许溢出。如果buffer溢出后覆盖了func_ptr变量,那么func_ptr中存储的地址就会被改变。

攻击者可以将这个地址覆盖为恶意函数的地址,例如:

  • delete_all_files函数的地址
  • print_secrets函数的地址

当程序执行func_ptr()时,它就会跳转到攻击者指定的恶意函数去执行,而不是原本无害的函数。

在以上所有代码示例中,核心问题都是相同的:向缓冲区写入的数据超过了其边界,并覆盖了后续内存中的关键变量。无论是覆盖一个字符串、一条系统命令,还是一个函数指针,其破坏性都取决于被覆盖变量的用途。

现实世界中的重要性

你可能会觉得这些例子是刻意构造的,现实中不会有程序员犯这种低级错误。然而,事实恰恰相反。

此类漏洞——即“越界写”漏洞——在真实软件中无处不在。它长期位列最具危险性的软件漏洞榜单前列。例如,在某个权威机构发布的历年十大危险软件漏洞中,“越界写”漏洞 consistently 位居前五,甚至在2023年位列第一。

这意味着,理解并防范此类漏洞,对于编写安全的代码至关重要。

总结

本节课中,我们一起学习了缓冲区溢出漏洞的两种高级利用方式:

  1. 覆盖终端命令:通过溢出修改本应执行的系统命令,导致执行任意恶意指令。
  2. 覆盖函数指针:通过溢出修改函数指针的地址,导致程序跳转并执行任意恶意函数。

它们的共同根源都是不检查输入长度的不安全函数(如gets。在接下来的项目中,即使你使用Python等更安全的语言,理解这些底层C语言的漏洞模式,也能帮助你建立起牢固的软件安全意识。

030:栈溢出攻击

在本节课中,我们将学习一种经典的内存安全漏洞利用技术——栈溢出攻击。我们将了解函数调用时栈帧的结构,并探索攻击者如何通过覆盖关键数据来控制程序的执行流程。

概述

上一节我们介绍了如何通过溢出缓冲区来覆盖相邻的变量。但那种情况似乎有些刻意,因为现实中很少会恰好有一个函数指针紧邻着缓冲区。然而,有一种“函数指针”在每次函数调用时都会自动出现在栈上,这就是我们将要利用的关键。

栈帧与返回地址

当调用一个函数时,系统需要保存一些信息以便函数返回后能继续正确执行。这些信息被保存在称为“栈帧”的内存区域中。

以下是压入栈的两个关键值:

  • 保存的 EBP:指向调用者栈帧的基址。
  • 保存的 EIP(返回地址):这是最关键的值。它保存了函数执行完毕后,下一条要执行的指令的地址。本质上,它是一个由系统自动管理的函数指针

函数返回时,会从栈上取出这个返回地址,将其放回 EIP 寄存器,然后 CPU 就会跳转到那个地址继续执行。

漏洞代码示例

考虑以下一段看似普通的 C 代码:

void vulnerable() {
    char name[16];
    printf("What's your name? ");
    gets(name); // 危险函数:不检查输入长度
}

vulnerable 函数被调用时,栈帧结构大致如下(地址从高到低增长):

| ...         |
| 保存的 EIP   | <- 返回地址,指向调用者(如 main 函数)
| 保存的 EBP   |
| name[15]    |
| ...         |
| name[0]     |
| ...         |

gets(name) 函数会无限制地读取用户输入并写入 name 缓冲区。如果用户输入超过 16 个字符,多出的字符就会继续向高地址方向写入,从而覆盖栈上的其他数据。

构造攻击

假设攻击者已将恶意代码放置在内存地址 0xDEADBEEF 处。他们的目标是让程序在 vulnerable 函数返回后,跳转到这个地址执行恶意代码。

攻击者需要精心构造输入,使其不仅能填满 name 缓冲区,还能精确覆盖栈上的返回地址。

以下是攻击输入的计算与构成:

  1. 填充缓冲区:首先输入足够多的字符(例如 16 个‘A’)来填满 name 数组。
  2. 覆盖保存的 EBP:继续输入 4 个字符(例如另外 4 个‘A’)来覆盖掉“保存的 EBP”。这部分内容对本次攻击本身不重要,但需要被覆盖以到达目标。
  3. 覆盖返回地址:最后,输入目标地址 0xDEADBEEF 的字节序列。需要注意的是,x86 架构采用小端字节序,因此地址 0xDEADBEEF 在内存中应从低到高存储为 \xEF\xBE\xAD\xDE

因此,完整的攻击输入是:16个‘A’ + 4个‘A’ + “\xEF\xBE\xAD\xDE”

攻击过程

当攻击者提供上述输入后,gets 函数会将其写入栈中,导致栈帧被破坏:

| ...              |
| 0xDEADBEEF       | <- 返回地址被覆盖为恶意地址
| 0x41414141 (AAAA)| <- 保存的 EBP 被覆盖
| AAAAAAAAAAAAAAAA | <- name 缓冲区
| ...              |

vulnerable 函数执行完毕准备返回时,它会从栈上取出“返回地址”并跳转。此时,它取出的不再是原来合法的返回地址,而是被覆盖后的 0xDEADBEEF。于是,CPU 将跳转到该地址,开始执行攻击者预设的恶意指令,攻击就此完成。

总结

本节课我们一起学习了栈溢出攻击的基本原理。我们了解到,函数调用时保存在栈上的返回地址是一个关键的“隐形”函数指针。通过使用不安全的函数(如 gets)向栈上的缓冲区写入超长数据,攻击者可以覆盖这个返回地址,从而劫持程序的执行流程,使其跳转到任意地址(通常是恶意代码所在处)。这种攻击是许多安全漏洞的根源,理解它对于编写安全的代码至关重要。

031:编写Shellcode 🐚

在本节课中,我们将学习如何利用缓冲区溢出漏洞,通过向目标程序注入并执行我们自己的恶意代码(即Shellcode)来获得系统控制权。我们将从理论到实践,一步步构建一个完整的攻击链。

上一节我们介绍了如何通过覆盖返回地址来劫持程序的控制流。本节中我们来看看,当内存中没有现成的恶意指令时,攻击者如何自行注入并执行代码。

概述:从覆盖地址到注入代码

之前我们假设内存中已存在恶意指令(例如在地址 0xdeadbeef),并通过覆盖返回地址跳转到那里执行。但在现实中,程序开发者不会主动留下恶意代码。因此,攻击者需要一种方法,将自己编写的指令放入内存并执行。

一种可行的方法是:利用 gets 这类不安全的函数。gets 允许向内存写入任意数据。攻击者不仅可以写入填充字符(如 'A')和地址,还可以直接写入编译好的机器指令(即 shellcode)。

例如,攻击者可以用汇编语言编写恶意指令,将其编译成二进制机器码(一系列 01)。这些二进制字节可以被写入到程序内存中。随后,攻击者通过溢出漏洞覆盖返回地址,使其指向刚刚写入的 shellcode 的起始地址。当函数返回时,程序就会跳转并执行这些攻击者注入的指令。

这些指令可以执行任意操作,例如删除文件、运行其他程序或发送垃圾邮件。但攻击者最常做的一件事是 生成一个shell(终端)。这意味着在目标机器上打开一个命令行终端,攻击者便可以在上面输入任意命令。试想,如果这段存在漏洞的代码运行在一台机密服务器上,攻击者通过注入 shellcode 获得了一个终端,就相当于完全控制了那台机器。

这种用于生成shell的恶意代码,通常就被称为 Shellcode

所以,本课的核心要点是:即使程序本身没有恶意代码,攻击者也可以自行注入并执行。接下来,我们将尝试组装一次完整的攻击。

构建完整的Shellcode攻击

我们现在知道,可以自行编写恶意代码,将其放入程序内存,并诱使程序执行它。让我们看看具体如何操作,并尝试将整个攻击串联起来。

以下是我们的攻击步骤:

  1. 发现漏洞:找到程序中存在缓冲区溢出的位置。
  2. 注入Shellcode:将我们编写的Shellcode写入内存。
  3. 覆盖返回地址:修改保存返回地址的寄存器(如x86架构的EIP/RIP),使其指向我们刚刚写入的Shellcode的起始地址。

换句话说,我们告诉程序:“当你从这个函数返回时,请跳转到这个地址。” 而这个地址处存放的,正是我们刚刚写入的Shellcode。我们将在一个步骤中完成所有这些操作。当函数返回时,它会查看返回地址,跳转到指定位置,并开始执行恶意的Shellcode,攻击便成功了。

因此,我们的目标是:写入一些Shellcode,然后强制返回地址(RIP)指向这段Shellcode

实战演练:内存布局与攻击构造

让我们通过一个具体的栈内存布局图来理解这个过程。下图展示了存在漏洞的函数(如 vulnerable)的栈帧结构,并标注了关键数据的地址。

假设我们有一段12字节的Shellcode。它原本不在内存中,我们需要通过溢出漏洞将其写入。现在的问题是:我们该向内存写入什么内容,以及如何布局,才能让所有部分正确对齐,使得函数返回时能执行Shellcode?

我们需要精心构造输入数据。以下是一种可行的解决方案:

  1. 首先写入Shellcode:Shellcode本身是12字节,在内存中占据3行(假设每行4字节)。我们通过 gets 函数将其写入缓冲区起始位置。例如,写入到地址 0xbffffcd40 开始的地方。现在,恶意指令已经存在于内存中了。
  2. 然后写入填充数据gets 函数会连续地向更高地址写入数据,不能中途跳转。为了覆盖到位于栈中更高地址的返回地址(RIP),我们需要在Shellcode之后写入一定数量的“垃圾字节”(例如字符 'A')作为填充。在这个例子中,我们需要填充12字节来覆盖栈图中的另外三行数据。
  3. 最后覆盖返回地址:填充数据之后,下一个写入的数据就会覆盖关键的返回地址(RIP)。此时,我们写入我们希望的地址:Shellcode的起始地址,即 0xbffffcd40。在x86小端序系统中,我们需要以字节序列的形式写入这个地址。

以下是构造的攻击载荷在内存中的布局示意图:

当程序执行这段被精心构造的输入时,会发生以下情况:
函数执行完毕准备返回,它会读取被我们覆盖后的返回地址(RIP),发现其值为 0xbffffcd40。于是,程序跳转到该地址,并开始执行位于此处的指令——正是我们注入的Shellcode。攻击者由此成功执行了Shellcode,获得了系统控制权。

总结

本节课中我们一起学习了如何利用缓冲区溢出漏洞进行代码注入攻击。我们了解到,即使目标内存中没有现成的恶意指令,攻击者也可以通过溢出漏洞将自己的Shellcode写入内存,并通过覆盖返回地址来引导程序执行它。我们详细拆解了攻击的构造过程:先注入Shellcode,再用填充数据覆盖到返回地址的位置,最后用Shellcode的地址覆盖返回地址本身。这就是你的第一个完整的、包含自定义代码的内存安全漏洞利用实例。理解这个原理是认识更复杂攻击和构建有效防御措施的基础。

032:另一种攻击结构

概述

在本节课中,我们将学习内存安全漏洞利用的另一种结构。我们将探讨如何通过不同的方式在内存中布局,最终实现执行恶意代码的目标。理解这种灵活性对于掌握漏洞利用技术至关重要。

回顾:上一节的核心概念

上一节我们介绍了第一个内存安全漏洞利用。其核心思想是覆盖返回指令指针。这个指针保存了一个地址,当函数返回时,程序会跳转到该地址并执行那里的代码。如果我们能覆盖这个地址,就能在函数返回时强制程序跳转到我们指定的位置,执行我们注入的恶意代码。

漏洞利用的“杂耍”艺术

构思这些漏洞利用需要一点“杂耍”技巧。内存中有许多不同的“活动部件”,必须将它们都精确地放置在正确的位置,攻击才能按预期工作。

例如,在之前的例子中,我们选择将shellcode放在name字符数组的开头。这意味着shellcode的起始地址是0xBFFFFD40。因此,我们需要覆盖的RIP值也必须被设置为这个地址0xBFFFFD40,以便程序跳转到我们的shellcode。

另一个需要协调的部分是gets函数的行为。它会将用户输入连续写入内存中地址递增的位置。假设我们的shellcode长度为12字节,那么写完shellcode后,如果我们继续输入数据,接下来被覆盖的还不是RIP,而是name数组剩余的部分以及保存的EBP。因此,我们必须先写入一些“垃圾”字节来填充这些空间,然后才能覆盖到RIP

所以,我们需要协调:把shellcode放在哪里?在RIP处写入什么地址?需要使用多少填充字节?通过将所有部分组合起来,我们才能构建出那个特定的攻击。

引入:另一种可行的攻击结构

然而,事实证明,这并不是在栈上布局和放置数据的唯一方式。下面我将展示另一种同样能导致shellcode执行的漏洞利用结构,也许这正是你想出来的方法。

也许你会想,与其把shellcode放在name数组的最开头,不如先写入填充用的垃圾字节。

以下是这种新结构的步骤:

首先,写入12个‘A’作为填充字节。这些字节可以是任何值,如‘B’、‘C’或‘X’,具体内容无关紧要。

然后,紧接着写入12字节的shellcode。这同样完全可行,它仍然实现了将shellcode写入内存的目标。这12个字节对应一些机器指令,即x86指令翻译成的、存在于内存中的机器码。

由于我们把shellcode放在了不同的位置,我们需要覆盖RIP的地址也必须随之改变。

为什么?因为shellcode不再位于地址0xBFFFFD40。从图中可以看出,shellcode现在位于地址0xBFFFFD4C,这是一个不同的内存地址。

因此,当我覆盖RIP时,我现在需要写入的地址是0xBFFFFD4C。地址改变的原因仅仅是因为我把shellcode放在了不同的位置。

仔细观察一下,你会发现这种攻击结构同样可以完美地工作。

总结

本节课中我们一起学习了内存安全漏洞利用的另一种结构。我们了解到,通过调整shellcode在内存中的位置以及相应地修改覆盖RIP的地址,可以构建出功能相同但布局不同的攻击。这展示了漏洞利用的灵活性,核心在于理解内存布局并精确控制数据流向,以确保程序最终跳转到我们注入的恶意代码。

033:编写更长的Shellcode

在本节课中,我们将要学习当攻击者想要执行的Shellcode长度超过目标缓冲区容量时,如何调整攻击策略。我们将探讨如何利用gets函数的特性,将较长的Shellcode放置在栈上的不同位置,并成功劫持程序控制流。

问题引入:缓冲区空间不足

上一节我们介绍了如何利用短小的Shellcode覆盖返回地址。本节中我们来看看当Shellcode过长时会发生什么。

假设攻击者编写了一段Shellcode,编译成x86机器码后,其长度为28字节。然而,目标程序中的name字符数组仅能容纳20字节。

char name[20];

紧接着name数组的是4字节的保存帧指针。这意味着,从name数组起始位置到返回地址之间,总共只有24字节的连续可覆盖空间。这不足以容纳28字节的Shellcode。

思考解决方案

以下是需要思考的核心问题:如果无法将Shellcode放置在返回地址之前,应该将它放在哪里?

请观察栈的内存布局图,并思考可能的解决方案。关键在于gets函数的行为:只要用户持续提供输入,gets函数会无限制地向栈上的更高地址写入数据。

解决方案:将Shellcode置于返回地址之后

经过思考,我们可以得出解决方案:既然Shellcode无法放入返回地址之前的空间,我们可以将它放在返回地址之后。

具体的攻击载荷结构如下:

  1. 首先,用24字节的任意数据填充name数组和SFP。
  2. 接着,在返回地址的位置,写入一个特定的内存地址。
  3. 最后,在这个地址之后,写入我们28字节的Shellcode。

那么,返回地址处应该写入什么地址呢?这个地址应该指向Shellcode的起始位置。根据栈的布局,Shellcode现在位于返回地址之上的某个位置,假设其地址是0xBFFFFD5C

因此,我们需要将地址0xBFFFFD5C以小端字节序格式写入到返回地址所在的内存中。

# 攻击载荷结构示例
payload = b'A' * 24          # 填充 name[20] 和 SFP
payload += b'\x5c\xfd\xff\xbf' # 覆盖返回地址,指向Shellcode (0xBFFFFD5C)
payload += shellcode         # 28字节的Shellcode

当程序从易受攻击的函数返回时,它会读取被我们覆盖的返回地址,并将控制权转移到0xBFFFFD5C。CPU跳转到该地址后,便开始执行我们放置在那里的Shellcode。

总结

本节课中我们一起学习了如何利用gets函数无边界检查的特性,将过长的Shellcode放置在栈上返回地址之后的位置。攻击的核心模式与之前相同,都是覆盖返回地址以劫持控制流。唯一的区别在于,当Shellcode无法放入原始缓冲区时,我们可以利用gets的写入能力,将其放置在栈的更高地址处,并相应地调整返回地址指向这个新位置。

034:缓冲区溢出实战演练 🚀

在本节课中,我们将通过实际执行步骤,详细剖析缓冲区溢出攻击是如何发生的。我们将跟随程序的执行流程,特别是函数尾声(epilogue)的步骤,来观察攻击者如何利用漏洞劫持程序控制流,并最终执行恶意代码。

上一节我们介绍了缓冲区溢出的基本概念,本节中我们来看看当程序实际执行时,缓冲区溢出攻击的具体过程。

攻击场景回顾

假设我们有一个存在漏洞的函数 getS,它负责将用户输入写入栈上的缓冲区。攻击者精心构造的输入包含三部分:恶意代码(shellcode)、用于填充缓冲区的垃圾数据(如‘A’),以及一个指向shellcode的返回地址。

进入 getS 函数

程序执行进入 getS 函数。指令指针(EIP)指向 getS 的代码。该函数从用户处获取输入,并将其写入栈上名为 name 的字符数组中。

以下是攻击者提供的输入结构:

  1. Shellcode:一段恶意机器指令。
  2. 12字节的垃圾数据:用于填充 name 缓冲区剩余空间。
  3. 返回地址:一个指向栈上shellcode起始位置的地址(例如 0xBFFFCD40)。

getS 函数忠实地将这些数据写入栈内存。它首先写入12字节的shellcode,然后写入12个‘A’(即 0x41414141)来填充缓冲区。

覆盖关键数据

在写入过程中,发生了溢出。name 缓冲区被填满后,继续写入的‘A’覆盖了其后的栈内存。

  • 覆盖SFP:首先被覆盖的是保存的帧指针(SFP)。SFP原本存储着调用者(vulnerable函数)栈帧的基地址(EBP),用于函数返回时恢复。现在它被 0x41414141 覆盖,导致EBP在恢复时将指向一个未知的、无意义的内存地址。
  • 覆盖返回地址:紧接着,返回地址(RIP) 被覆盖。RIP原本存储着 vulnerable 函数中 call getS 指令之后的下一条指令地址,用于 getS 返回后继续执行。攻击者用精心计算的shellcode地址(0xBFFFCD40)覆盖了它。

至此,getS 函数的任务完成,攻击数据已全部写入栈中。

函数返回与尾声执行

现在,getS 函数执行完毕,需要返回 vulnerable 函数。随后,vulnerable 函数也执行完毕,准备返回其调用者(如 main 函数)。在x86架构中,函数返回时总会执行一段固定的指令序列,称为函数尾声(function epilogue)

以下是 vulnerable 函数尾声的三个标准步骤,正是攻击发生的关键:

第一步:移动栈指针(ESP)

指令 mov esp, ebp 将栈指针(ESP)移动到当前帧指针(EBP)的位置。这相当于释放了 vulnerable 函数的整个栈帧(包括被溢出的 name 缓冲区区域)。虽然栈指针上移,但被写入的恶意数据仍然物理存在于那片内存中,并未被清除。

第二步:恢复帧指针(EBP)

指令 pop ebp 从栈顶弹出一个值,并将其存入帧指针寄存器(EBP)。这个弹出的值本应是之前保存的、有效的SFP。然而,由于SFP已被覆盖,现在弹出的值是 0x41414141。因此,EBP寄存器被设置为这个无意义的地址。这可能导致程序后续如果错误地使用EBP访问内存时崩溃,但攻击本身不依赖于此。

第三步:返回并跳转(RET)

指令 ret 从栈顶弹出下一个值,并将其作为下一条指令的地址加载到指令指针(EIP)中。这个弹出的值就是返回地址(RIP)。由于RIP已被攻击者覆盖为shellcode的地址(0xBFFFCD40),因此 ret 指令执行后,EIP将指向栈上的shellcode起始位置。

攻击达成

此时,程序的控制流已被完全劫持。处理器接下来将执行EIP所指向的指令,即攻击者放置在栈上的shellcode。shellcode通常会执行诸如打开一个系统shell(如 /bin/sh)等恶意操作,使得攻击者能够完全控制该进程。


本节课中我们一起学习了缓冲区溢出攻击的详细执行步骤。我们看到了攻击者如何通过溢出漏洞覆盖栈上的关键数据(SFP和RIP),并利用函数返回时必然执行的尾声指令,特别是 ret 指令,将程序控制流导向恶意代码。这个过程清晰地展示了为什么简单的内存写入越界会带来如此严重的远程代码执行后果。理解这些底层细节是构建有效防御措施的基础。

035:堆溢出与不安全的C库函数 🛡️

在本节课中,我们将要学习堆内存溢出的概念,并了解C语言中一些看似安全但实际上存在风险的库函数。我们将探讨这些函数为何危险,以及如何通过使用更安全的替代函数来保护程序。

堆内存溢出的风险

上一节我们介绍了栈缓冲区溢出的工作原理。本节中我们来看看堆内存溢出的情况。

以下是一段示例代码:

char *name = malloc(20);
gets(name);

这段代码看起来可能没有问题,因为name指向堆内存,而非栈内存。因此,攻击者可能无法像之前那样覆盖返回地址(RIP)来实施栈粉碎攻击。然而,危险依然存在,因为C语言没有边界检查。攻击者可以通过gets函数输入超过20个字符,从而溢出name数组的边界。

堆内存中可能存储着许多敏感数据,例如用户身份验证标志、航班特殊指令等。如果攻击者向name写入超过20字节的数据,这些溢出数据就会覆盖堆上的其他敏感信息,造成安全问题。

如何防御内存安全漏洞

为了防御此类攻击,我们必须使用更安全的C库函数来替代那些不安全的函数。

以下是使用安全函数的一个例子:

char *name = malloc(20);
fgets(name, 20, stdin);

fgets函数允许我们指定一个限制(本例中为20),确保用户输入不会超过缓冲区的大小。这样,即使用户尝试输入更多数据,程序也只会读取前20个字节,从而防止了溢出。

提高代码安全性

我们可以通过以下方式进一步提高代码的安全性:

char *name = malloc(20);
fgets(name, sizeof(name), stdin);

使用sizeof(name)可以确保限制值与缓冲区大小始终保持一致。这样,如果我们将来将缓冲区大小从20改为15,限制值也会自动更新,避免了因忘记同步修改而引入的错误。

不安全函数与安全替代函数

C语言中有许多函数存在类似的安全问题。开发者必须记住哪些是不安全函数,并使用其安全版本进行替代。

以下是一些常见的不安全函数及其安全替代方案:

  • gets 是危险的,请使用 fgets 代替。
  • strcpy 是危险的,请使用 strncpy 代替。
  • strlen 在某些情况下是危险的,请使用 strnlen 代替。

在C语言编程中,必须仔细查阅函数文档(例如Unix/Linux系统中的man手册页),确认所使用的函数是否安全,并选择正确的安全替代方案。即使是一个小小的疏忽,也可能导致整个程序变得脆弱易受攻击。

总结

本节课中我们一起学习了堆缓冲区溢出的风险,它允许攻击者覆盖堆内存中的敏感数据。我们认识到C语言中许多标准库函数(如gets)由于缺乏边界检查而存在安全隐患。防御的关键在于使用具有长度限制的安全替代函数(如fgets),并确保代码中缓冲区大小与读取限制保持一致。在C语言开发中,始终保持警惕并查阅官方文档是避免内存安全漏洞的重要实践。

036:有符号与无符号整数漏洞 🔢

在本节课中,我们将学习一种称为“整数内存安全漏洞”的攻击方式。我们将分析一段看似安全的C语言代码,并揭示攻击者如何利用有符号和无符号整数之间的差异来绕过安全检查,从而执行恶意代码。

概述

上一节我们讨论了缓冲区溢出的基本原理。本节中,我们将深入探讨一种更隐蔽的漏洞类型:有符号与无符号整数漏洞。这种漏洞源于程序员对同一数据(一串二进制位)在不同上下文中解释方式(有符号数或无符号数)的混淆。

代码示例与分析

以下是一段存在潜在漏洞的C语言代码。让我们逐步分析它。

void safe_copy(char* data, int length) {
    char buffer[64];
    if (length > 64) {
        return;
    }
    memcpy(buffer, data, length);
}

这段代码的功能是安全的吗?它接收一个字符数组 data 和一个表示其长度的整数 length。在C语言中,传递数组通常需要两个参数:一个指向数组起始地址的指针,以及一个表示数组中元素数量的参数。这是因为C语言本身不会进行数组边界检查。

代码创建了一个大小为64字节的缓冲区 buffer。它首先检查传入的 length 是否大于64。如果大于64,函数直接返回,不执行复制操作。如果 length 小于或等于64,则使用 memcpy 函数将 data 中的 length 个字节复制到 buffer 中。

乍看之下,这段代码似乎没有问题。它有一个安全检查,防止复制超过缓冲区大小的数据。只要攻击者诚实地报告长度,就不会发生溢出。

然而,这里存在一个非常微妙的漏洞。

漏洞原理

问题的关键在于参数 length 的数据类型是 int,即有符号整数。有符号整数可以表示正数、负数和零。

考虑以下场景:攻击者传入一个非常大的数组,其长度用十六进制表示为 0xFFFFFFFF。如果将其解释为无符号整数,这是一个巨大的正数(4,294,967,295)。攻击者诚实地报告了这个长度。

但是,当这个值 0xFFFFFFFF(二进制全为1)进入函数 safe_copy 时,由于参数 lengthint 类型,程序会将其解释为有符号整数。在二进制补码表示法中,0xFFFFFFFF 代表 -1

现在,让我们用 length = -1 来执行代码:

  1. 检查 if (length > 64)-1 > 64 吗?不成立。因此,安全检查被绕过。
  2. 程序执行到 memcpy(buffer, data, length);
  3. memcpy 函数的第三个参数类型是 size_t,这是一个无符号整数类型。于是,值 -1(在内存中仍是 0xFFFFFFFF)又被解释为一个巨大的无符号整数。
  4. memcpy 试图从 data 复制 0xFFFFFFFF 个字节(约4GB数据)到仅有64字节的 buffer 中,这必然导致缓冲区溢出

核心漏洞公式
0xFFFFFFFF (无符号) → 巨大正数
0xFFFFFFFF (有符号 int) → -1
-1 (作为 size_t 传入 memcpy) → 0xFFFFFFFF (无符号) → 巨大正数

攻击者利用了对同一串二进制位(0xFFFFFFFF)在代码流中不同位置(比较语句 vs. 复制函数)解释方式的切换,绕过了安全检查。

漏洞修复

修复这个漏洞的关键在于保持数据类型解释的一致性。以下是修复后的代码:

void safe_copy(char* data, size_t length) { // 将参数类型改为 size_t
    char buffer[64];
    if (length > 64) {
        return;
    }
    memcpy(buffer, data, length);
}

修复方法:将函数参数 length 的类型从 int 改为 size_t(无符号整数类型)。这样,在整个函数内部以及传递给 memcpy 时,对 length 的解释都是无符号的。安全检查 if (length > 64) 将会正确地将巨大的长度值识别出来并阻止复制操作。

总结

本节课中我们一起学习了有符号与无符号整数漏洞。这种漏洞非常隐蔽,因为程序员常常忽略整数类型的符号性。其核心是同一数据在不同上下文中的解释冲突:比较时可能被当作有符号数(如-1),而在进行内存操作时又被当作无符号数(一个巨大的正数),从而绕过边界检查。

要避免此类漏洞,应始终保持对同一数据解释的一致性,特别是在涉及边界检查和安全关键操作时,谨慎选择并使用无符号整数类型(如 size_t)来表示大小和长度。

037:整数溢出漏洞 🧮

在本节课中,我们将学习整数溢出漏洞。这是一种当整数运算结果超出其数据类型所能表示的范围时,导致程序行为异常的安全问题。我们将通过一个具体的代码示例来理解其原理、危害及修复方法。

上一节我们讨论了整数表示和符号问题,本节中我们来看看另一种与整数相关的常见漏洞:整数溢出。

漏洞代码分析

我们来看一段代码。攻击者输入一个字符数组,程序接收一个指向该数组起始位置的指针,以及一个表示数组字节长度的参数 L。我们假设这些参数是如实传递的。

以下是程序的操作步骤:

  1. 程序在堆上分配一个新的缓冲区。
  2. 它调用 malloc 函数,分配大小为 L + 2 字节的内存。这里加2可能是为了在用户数组末尾添加一个换行符和一个空字节。
  3. 程序使用 memcpy 将用户数组的数据复制到新分配的缓冲区中。

代码逻辑如下:

size_t buffer_size = L + 2;
char* buffer = malloc(buffer_size);
memcpy(buffer, data, L); // data 是用户输入的数组指针
buffer[L] = '\n';
buffer[L+1] = '\0';

这段代码看起来没有问题,因为分配的空间 (L+2) 足够容纳要复制的数据 (L)。但这里潜藏着一个整数溢出问题。

漏洞原理 🔍

问题在于 L + 2 这个加法运算。如果攻击者传入一个非常大的 L 值,例如 0xFFFFFFFFFFFFFFFF(在64位系统上 size_t 能表示的最大值),会发生什么?

当我们对这个已经达到最大值的数加2时,会发生整数溢出(或称回绕):

0xFFFFFFFFFFFFFFFF + 1 = 0x0000000000000000
0xFFFFFFFFFFFFFFFF + 2 = 0x0000000000000001

因此,L + 2 的实际计算结果为 1。这意味着 malloc 只分配了 1个字节 的内存。

然而,接下来的 memcpy 操作仍然试图从 data 复制 L(一个巨大的数字)个字节到 buffer 中。由于 buffer 实际只分配了1字节,这将导致大量的数据被写入缓冲区之外的内存区域,造成堆缓冲区溢出

这种漏洞被称为整数溢出漏洞。关键问题在于,我们取了一个非常大的数,加上一个小数字后,它回绕成了一个非常小的数(如0或1)。

修复方法 🛡️

修复这类漏洞的代码通常比较繁琐,但为了安全是必要的。核心思想是在进行可能导致溢出的运算前,先进行检查。

以下是修复思路:

  1. 我们需要检查 L 是否已经接近 size_t 类型能表示的最大值(SIZE_MAX)。
  2. 如果 L 太大,以至于 L + 2 会溢出,那么我们应该拒绝这个请求,直接返回错误,而不是继续执行分配和复制操作。

示例修复代码如下:

#include <stdint.h> // 用于 SIZE_MAX

if (L > SIZE_MAX - 2) {
    // 处理错误:长度过大,加法会溢出
    return ERROR;
}
size_t buffer_size = L + 2; // 现在这个加法是安全的
char* buffer = malloc(buffer_size);
if (buffer == NULL) { /* 处理分配失败 */ }
memcpy(buffer, data, L);
// ... 其余代码

这段代码通过检查 L 是否大于 SIZE_MAX - 2 来确保 L + 2 不会溢出。虽然代码看起来有些冗长,但这是用C语言编写安全程序时常常需要做的防御性检查。

有人可能会质疑,传入如此巨大的数组长度是否现实。为了更直观地理解,可以考虑使用范围更小的数据类型,例如 uint8_t(表示0到255)。对于一个期望 uint8_t 类型长度的函数,传入值255并加上2,就会溢出变成1,这是一个更易触发的场景。

总结

本节课中我们一起学习了整数溢出漏洞。我们看到了,即使代码看起来分配了足够的空间(L+2),但如果用户输入的长度 L 过大,简单的加法运算 L + 2 会导致整数回绕,使得实际分配的内存远小于预期,进而引发缓冲区溢出。

关键要点是:在对来自不可信来源的整数(特别是用于内存分配大小的整数)进行算术运算(如加法、乘法)时,必须预先检查运算结果是否会发生溢出。 这是编写安全C/C++代码需要养成的重要习惯。

038:现实中的整数溢出与总结 🛡️

在本节课中,我们将通过两个真实案例,了解整数溢出漏洞在现实世界中的具体表现及其危害。同时,我们将回顾并总结此前学习过的两类主要内存安全漏洞:缓冲区溢出和整数漏洞。

现实中的整数溢出案例

上一节我们讨论了整数溢出的原理,本节中我们来看看它在实际应用中是如何引发问题的。

以下是两个真实发生的整数溢出案例:

  1. 投票机故障(2004年):某投票机软件出现异常行为。市长指出:“该软件无法统计超过32000张选票。当票数达到32000时,软件会开始反向计数。”从计算机科学的角度看,32000非常接近2的幂次方(32768)。这很可能是因为软件使用了16位有符号整数(int16_t)来存储票数。其最大正数值为32767。当票数达到此上限后,再加1就会发生溢出,变为-32768。后续的计数就会在负数范围内递增,这或许就是市长所说的“反向计数”。这个案例的教训是:必须谨慎选择使用的数据类型。错误地混合使用intsize_tintXX_t等类型,可能导致溢出攻击或有符号/无符号转换漏洞。

  2. 2022年的代码提交漏洞:你可能会觉得之前例子中“长度加2”导致溢出的场景过于刻意。但现实中确实存在这样的案例。在2022年的一次代码提交中,开发者将原本安全的 页面大小 - 2 的检查逻辑,错误地修改为了 长度 + 2。这恰好复现了我们之前演示的攻击模式。这个例子表明,由于C语言在类型检查和边界检查方面的不足,看似“愚蠢”的错误确实会在现实代码中发生。

课程内容总结

现在,让我们快速总结一下到目前为止学到的所有内容。

我们主要探讨了两大类内存安全漏洞:

  • 缓冲区溢出:这是一个总称,指攻击者覆盖了本不应被覆盖的内存区域。其中,我们重点学习了一种非常常见的类型——栈粉碎攻击。这种攻击的目标是覆盖栈上的返回地址(RP)。每个函数调用时,其返回地址都保存在栈上,用于指示函数执行完毕后应跳转到哪里继续执行。通过用攻击者自行注入的shellcode地址覆盖这个返回地址,就能导致恶意代码执行。修复此类漏洞的方法是:查阅C语言手册,使用各种输入/输出函数的安全版本(例如用fgets替代gets,用strncpy替代strcpy)。

  • 整数漏洞:这类漏洞与整数类型相关,核心在于同一串二进制位可以有两种不同的解释方式。例如,比特序列 0xFFFFFFFF 如果被解释为无符号整数,它是一个很大的正数(4294967295);如果被解释为有符号整数,它则是一个很小的负数(-1)。攻击者可以利用这种歧义性,或者利用“大数加2变成小数”的溢出漏洞,绕过程序中的安全检查,从而覆盖本无权访问的内存区域。

本节课中,我们一起学习了整数溢出在现实世界中的具体案例,并系统回顾了缓冲区溢出(特别是栈粉碎攻击)和整数漏洞这两大内存安全威胁的核心原理。理解这些漏洞的成因,是编写安全代码、防范潜在攻击的第一步。

039:差一错误问答环节 🎯

在本节中,我们将探讨关于“差一错误”缓冲区溢出攻击的一些深入问题与解答。我们将分析攻击可能失效的场景、堆栈操作细节、寄存器的作用以及攻击载荷的灵活放置方式。


问题一:攻击是否在所有情况下都有效?

这是一个很好的问题。是否存在某些条件导致这种攻击无效?例如,如果这个C以某种方式“咬”过去,或者说回绕到了CE,你是对的。实际上在某些情况下,这种攻击在项目上可能不会成功。如果你运气不好,可能会遇到其中一种情况。我们稍后会讨论在项目中如何修复它,所以我把这留作一个练习,或者你可以稍后再问。某些地址配置可能导致攻击失败。这是一个好问题。

问题二:关于add $16, %esp指令

哦,是的,请讲。我在ESP上加16。这在这里确实不是最重要的事情,但如果你回顾函数调用的步骤,这是第11步,是我从堆栈中移除参数的步骤。在这里,我向堆栈压入了四个参数,为了删除它们,我加上16以从堆栈中移除四个参数。所以这只是完成对E的调用。真的不是那么重要,但为了完整性我们添加了它。

问题三:为什么打开shell后EBP的位置就不重要了?

这是一个很好的问题。为什么一旦你打开一个shell,EBP的位置就不重要了?这取决于你的shell代码在做什么。但如果你的shell代码从不引用这个值,那么你在那里放什么就真的不重要了。因为如果你仔细想想,EBP只是一个寄存器。它只是一个寄存器,它保存一个值。如果你需要它,你就向EBP询问它的值。但在这一点上,因为你正在执行自己编写的代码,如果你自己编写的代码从不询问EBP的意见,那么EBP里放什么就真的无关紧要了。实际上,如果你关心EBP里是什么,你甚至可以让自己的shell代码,把你关心的某个值重新注入到EBP中,如果你真的在乎的话。但它只是一个寄存器,它保存一个值。如果你的shell代码不使用它,那么就不会发生什么坏事。这是一个好问题。

问题四:伪造的RIP必须放在特定位置吗?

还有一个问题。是的,有一个问题是,在main函数中,你把伪造的RIP放在哪里重要吗?这也是一个好问题,你今天用所有这些好问题难住我了。你是对的,实际上还有其他方法也可以使这个漏洞利用成功。就像在最原始的缓冲区溢出中,我们展示的第一个例子一样,你可以把shell代码和shell代码的地址放在RIP下方或上方的各种不同位置。你可以把这组值,即伪造的SFP和伪造的RIP,放在下面这里,也可以放在上面这里,或者放在更上面这里,只要它是你可以覆盖的某个地方,就完全没问题。

问题五:为什么这些地址不改变?

这是一个关于为什么这些地址不改变的很好的问题。如果你再坚持大约一周,你就会得到答案。所以请保持关注。这是一个好问题。目前,我们假设它们是相同的。如果你再坚持一周,我们会改变它们。


总结

在本节中,我们一起探讨了关于差一错误攻击的几个关键问题。我们了解到攻击的成功依赖于特定的内存布局,并非在所有地址配置下都有效。我们回顾了清理堆栈参数的操作细节,并理解了在成功执行自定义shell代码后,某些寄存器(如EBP)的值可能变得无关紧要。此外,攻击载荷(如伪造的返回地址)在可覆盖的内存区域内具有放置的灵活性。最后,我们提到了内存地址的稳定性是一个暂时的教学假设,实际情况可能更为复杂。对于初学者来说,理解这些边界条件和假设是构建扎实漏洞利用知识的重要一步。

040:printf函数的预期行为 🖨️

在本节课中,我们将继续探索内存安全漏洞。我们将研究更复杂的方式,攻击者如何利用乍看之下似乎安全的代码片段,但实际上存在的漏洞来执行他们选择的任意代码。首先,我们将要分析的攻击类型被称为“格式化字符串漏洞”。这类漏洞相当棘手,接下来我将为你详细讲解。

格式化字符串漏洞概述

这类漏洞的核心围绕着一个我们经常使用的函数:printf。我们用它来打印输出值。你可能已经注意到,在C语言中使用printf与其他语言(如Python或Java)不同,它需要一种特殊的语法,即必须添加百分号(%)。如果不这样做,编译器就会报错,提示这不是使用printf的正确方式。那么,这些百分号究竟意味着什么呢?

理解printf格式化符号

这些百分号,我称之为“printf格式化符号”或“百分号格式化符号”。你可以将它们视为用户稍后想要替换的变量的占位符。

例如,在下面的printf调用中,第一个参数是字符串 "one something costs something"。由于字符串中包含了百分号符号,我告诉C程序,这里有两个占位符,我想打印它们,但在编写这段代码时,我还不知道它们的值。因此,我想打印 "one something costs something",但具体是什么“something”,要等到程序运行时才知道。

那么,这些“something”是什么呢?用户可以通过向printf提供额外的参数来指定它们,即指定应该填入这些占位符的内容。

例如,在这个例子中,有两个格式化符号:%s%d。因此,我将为printf提供两个额外的参数。我会说:第一个%s,请在运行程序时用变量fruit的值替换它;而这个%d,请用变量price的值替换它。

当C程序执行到这行printf代码时,它会打印“ONE ”,然后遇到一个百分号。它会取出第一个参数fruit,将其值替换到%s的位置并打印出来。接着,它会打印“ COSTS ”。然后,它又遇到一个百分号,并认为:“哦,用户又有一个格式化符号需要替换。”于是,它会查看下一个未使用的参数(第一个参数fruit已经用过了),即price参数,并将其值替换到%d的位置。简而言之,这将打印出变量fruitprice的当前值。

printf的可变参数机制

printf的一个有趣之处在于,它可以接受可变数量的参数。因为用户可能有一个占位符,也可能有两个、五个、零个,甚至十个。printf实际接受多少个参数,取决于用户想要添加多少个占位符。

具体来说,printf如何知道它期望多少个参数呢?它会查看第一个参数(即格式字符串)。如果我在第一个参数中看到一个%s和一个%d,这就告诉我:“好的,我需要两个参数。”然后,我会从栈上取出这两个参数,将它们分别替换到对应的位置。

如果第一个参数(格式字符串)中有五个百分号符号,那就告诉我实际上需要从栈上获取五个参数,并将它们替换到printf中的五个格式化符号里。

正常工作的printf流程

当一切正常时,printf的工作流程如下:用户在第一个参数中指定带有占位符和其他要打印内容的格式化字符串。然后,他们为每一个占位符指定一个额外的参数,所有内容都能很好地匹配。printf看到五个百分号符号,就从栈上获取五个参数,一切顺利。

总结

本节课中,我们一起学习了printf函数的预期行为。我们了解了格式化符号(如%s%d)作为占位符的作用,以及printf如何通过解析第一个参数(格式字符串)来确定需要从栈上获取多少个额外参数进行替换。这是理解后续格式化字符串漏洞攻击的基础。下一节,我们将探讨当这种预期行为被破坏时,可能产生的安全问题。

041:printf参数不匹配漏洞详解

在本节课中,我们将要学习printf函数的一个关键安全问题:当格式化字符串与提供的参数数量不匹配时会发生什么。我们将通过分析栈内存布局来理解攻击者如何利用这种不匹配来读取程序中的敏感数据。

正常情况下的printf调用

上一节我们介绍了printf的基本工作原理,本节中我们来看看一个正常工作的printf调用在栈上是如何组织的。

考虑以下函数:

void example() {
    int secret = 42;
    printf("%d\n", 123);
}

当这个函数被调用时,栈的布局如下:

  • 返回地址(RIP)和栈帧指针(SFP)
  • 局部变量secret(值为42)
  • 调用printf时压入的参数:首先是字符串"%d\n"的地址,然后是整数123

printf开始执行后,它会读取格式化字符串"%d\n"。当遇到%d时,它会到栈上寻找下一个参数(即123),并将其替换到输出中。因此,程序会正常输出123

参数不匹配的情况

了解了正常情况后,现在我们来看看当printf的参数不匹配时会发生什么。

假设我们有以下不正确的printf调用:

void vulnerable() {
    int secret = 42;
    printf("%d\n"); // 错误:缺少对应 %d 的参数
}

以下是此时栈的布局:

  • 返回地址(RIP)和栈帧指针(SFP)
  • 局部变量secret(值为42)
  • 调用printf时压入的参数:有字符串"%d\n"的地址

printf函数开始执行,它依然会读取格式化字符串"%d\n"。当遇到%d时,它仍然会尝试到栈上寻找下一个参数来替换。然而,我们并没有为%d提供对应的参数。

因此,printf会错误地将栈上紧接着格式化字符串地址之后的值(在这个例子中,恰好是局部变量secret的值42)当作参数读取并打印出来。这导致了敏感信息secret的意外泄露。

核心要点与总结

本节课中我们一起学习了printf参数不匹配漏洞的核心机制。

关键要点如下:

  • printf的第一个参数(或称第0个参数)必须是一个包含格式说明符(如%d%s)的硬编码字符串。
  • 对于格式化字符串中的每一个格式说明符,都必须提供一个对应的额外参数。
  • 如果提供的参数数量少于格式说明符的数量,C编译器通常不会报错,但printf在运行时仍会尝试从栈上读取“缺失”的参数。
  • 这会导致printf读取并输出栈上的其他数据,这些数据可能是局部变量、返回地址或其他敏感信息,从而造成信息泄露。

这种参数不匹配是后续我们将要探讨的许多内存攻击技术的核心基础。在下一节视频中,我们将继续深入探索攻击者如何利用这一特性进行更复杂的攻击。

042:攻击者控制的printf

在本节课中,我们将学习当攻击者能够控制printf函数的格式化字符串参数时,会引发何种安全问题。我们将通过一个具体的例子,理解这种漏洞的原理和潜在危害。

回顾:参数不匹配的printf

上一节我们介绍了当向printf函数提供不匹配的参数时会发生什么。printf函数使用其第一个参数(即格式化字符串)来确定存在多少个格式说明符(即%占位符)。对于每一个%占位符,printf都需要一个额外的参数与之匹配。

如果你提供的参数数量不匹配,例如格式化字符串中有5个%符号,但你只提供了3个对应的参数,就会发生奇怪的事情。具体来说,printf会继续在栈上寻找参数,即使这些参数并未被提供。这会导致内存中的意外数据被当作参数处理。

在之前的视频中,我们看到了如何利用这一点来打印出预期之外的值。

攻击者控制的格式化字符串

一个更普遍的此类攻击场景是类似下面这样的代码:

char buffer[64];
fgets(buffer, 64, stdin);
printf(buffer);

这段代码定义了一个字符数组buffer,并允许用户输入最多64个字节。这里使用了fgets函数,它保证一旦用户写入64字节,就不再读取更多输入。用户被限制在64字节内。

乍一看,这似乎完全没问题。用户只能向大小为64的字符数组写入64字节,他们无法溢出buffer的边界。

问题所在

那么问题在哪里?

问题在于,我们观察这个printf函数调用:printf(buffer);。它接收buffer作为其第一个输入,而这个输入我们允许用户写入。请记住,第一个输入至关重要,因为它决定了printf应该期待多少个额外的参数。

当参数数量不匹配时,坏事就会发生。所以这里的问题是,我们允许用户(在这里可能是攻击者)控制printf那个关键的第一个输入。

如果攻击者能够控制这个输入,他们实际上可以输入一些%格式说明符。如果攻击者输入了格式说明符,printf就会尝试将它们与不存在的参数匹配,从而导致坏事发生。

因此,核心问题是我们将printf关键的第一个输入的控制权交给了攻击者。

总结

本节课中我们一起学习了“攻击者控制的printf”漏洞。我们了解到,即使代码通过fgets等函数限制了输入长度以防止缓冲区溢出,但如果将用户输入直接作为printf的格式化字符串参数,攻击者仍可通过注入%格式说明符,迫使程序从栈上读取未提供的参数,从而可能泄露内存中的敏感信息或导致程序崩溃。这强调了永远不要将不可信的用户输入直接用作printf格式化字符串的重要性。

043:格式化字符串漏洞详解

在本节课中,我们将要学习C语言中printf函数的格式化字符串漏洞。攻击者如果能够控制printf的第一个参数(即格式化字符串),就可以利用%格式化符号来读取栈上的数据,甚至可能泄露敏感信息。我们将详细解释不同格式化符号的含义及其在漏洞利用中的行为。

攻击者如何利用格式化字符串

上一节我们介绍了printf函数的工作原理,本节中我们来看看如果攻击者能够控制printf的第一个格式化字符串参数,他们能做什么。

攻击者可以输入任何字符串,特别是包含%格式化符号的字符串。例如,攻击者可能输入一个包含%d的字符串。printf函数看到%符号后,会尝试从栈上获取一个额外的参数来匹配这个格式化符号。然而,如果程序没有提供额外的参数,这个%d就会匹配到栈上的其他未知值。

同样,如果用户输入包含%s,问题会更严重。printf会尝试从栈上读取一个地址,并将其解释为字符串的起始地址,然后打印出该地址指向的内存内容,直到遇到空字符为止。

攻击者甚至可以输入多个连续的格式化符号,例如%x%x%x%x。对于每一个%格式化符号,printf函数都会从栈上读取并打印出相应的值。这些值本应是函数参数,但实际上可能是栈上的其他数据。

以下是攻击者可能输入的恶意数据示例:

  • %d:尝试将栈上的值作为整数打印。
  • %s:尝试将栈上的值解释为地址,并打印该地址处的字符串。
  • %x%x%x%x:连续将栈上的多个值以十六进制形式打印出来。

所有这些方式都利用了printf函数参数不匹配的特性,可能导致数据泄露或其他不良后果。

格式化符号详解

前面我们提到了%d%s等符号,现在我们来详细看看这些符号的具体含义。

%符号后面的字母指明了你想要打印的变量或值的类型。

  • %d:用于打印整数。当printf看到%d时,它会从栈上读取4个字节(假设是32位系统),并将这些字节解释为一个整数进行打印。代码示例:printf(“%d”)。如果未提供对应的整数参数,printf会读取栈上的其他数据并当作整数输出。
  • %s:用于打印字符串。在C语言中,字符串以字符数组起始地址的形式存储。当printf看到%s时,它会从栈上读取4个字节,将其解释为一个内存地址,然后跳转到该地址,开始打印字符,直到遇到空终止符(\0)为止。公式描述:%s → 读取地址 → 解引用 → 打印字符直到 \0
  • %x:用于以十六进制形式打印值。当printf看到%x时,它会从栈上读取4个字节,并将其以十六进制格式打印出来。

这张图试图说明的核心概念是:%符号后的字母决定了printf要匹配的值的类型,并根据类型采取不同的操作。例如,%s会进行解引用操作(访问指针指向的内存),而%d则不会,它直接打印读取到的整数值。

本节课中我们一起学习了printf函数的格式化字符串漏洞。我们了解到,攻击者通过控制格式化字符串,可以迫使printf读取并打印栈上的额外数据,利用%d%s%x等格式化符号可能泄露内存中的敏感信息。理解这些符号的含义是识别和防范此类漏洞的基础。

044:基本printf漏洞 - 环境搭建 🛠️

在本节课中,我们将学习一个具体的例子,展示当攻击者能够控制printf函数的第一个参数(格式化字符串)时,如何利用这个漏洞泄露内存中的秘密值。我们将从程序的内存布局开始,逐步分析漏洞的成因。

概述

上一节我们介绍了printf函数的工作原理以及控制其格式化字符串参数的危险性。本节中,我们将通过一个具体的代码示例,搭建一个存在漏洞的环境,并分析其栈内存布局,为后续的漏洞利用演示做好准备。

程序结构与内存布局

我们有一个定义在函数外部的缓冲区buff,它位于内存的静态区域。此外,我们有一个名为vulnerable的函数。

char buff[64]; // 定义在函数外部的缓冲区

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/ucb-cs161-comp-sec-25/img/9f80d669b7ca2f2957c2b5e8bfcb1836_2.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/ucb-cs161-comp-sec-25/img/9f80d669b7ca2f2957c2b5e8bfcb1836_3.png)

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/ucb-cs161-comp-sec-25/img/9f80d669b7ca2f2957c2b5e8bfcb1836_4.png)

void vulnerable() {
    char* secret_string = "PANCAKE";
    int secret_number = 42;
    // ... 用户输入操作 ...
    printf(buff); // 漏洞点
}

记住,每当调用一个函数时,系统会为其开辟一个新的栈帧。首先被压入栈的是函数的返回地址(RP,即保存的EIP寄存器值)和保存的帧指针(SFP,即保存的EBP寄存器值)。

局部变量在栈上的表示

vulnerable函数中,我们有两个局部变量:

  1. secret_string:这是一个char*类型的指针,指向字符串"PANCAKE"。
  2. secret_number:这是一个int类型的变量,值为42。

以下是关于字符串变量在栈上存储方式的关键点:

  • 字符串本身(字符序列"P A N C A K E"以及结尾的\0)并不直接存放在栈上。
  • 存放在栈上的secret_string变量,实际上是一个内存地址。这个地址指向存储字符串"PANCAKE"的真实位置。
  • 因此,栈上的布局是:先保存secret_string这个指针(一个地址值),然后保存secret_number这个整数值42。

用户输入与漏洞触发

程序接下来允许用户(或攻击者)向buff缓冲区写入最多64个字节的任意数据。然后,程序会以buff作为第一个参数调用printf函数。

// 模拟用户输入操作,例如使用 gets(buff) 或 scanf
// 然后调用 printf
printf(buff); // 攻击者可以控制buff的内容

调用printf时的栈状态

现在,让我们分析调用printf(buff)时栈的具体状态。回想函数调用的步骤,第一步是将参数压栈。

  1. printf在这里只有一个参数,即buff
  2. 在C语言中,数组名作为参数传递时,传递的是其地址(指针)。因此,压入栈中的是buff的地址。
  3. 接着,执行call printf指令,printf函数开始执行,并建立自己的栈帧(包含其RP、SFP和局部变量)。

此时,栈的布局(从高地址到低地址)大致如下所示,这是printf函数正在执行时的视角:

| ... (更高地址) ... |
|--------------------|
| printf的局部变量    | <-- printf的栈帧
|--------------------|
| printf的SFP        |
|--------------------|
| printf的RP         | <-- 指向调用printf后的下一条指令
|--------------------|
| 参数:buff的地址    | <-- printf格式化字符串参数的位置
|--------------------|
| secret_number (42) | <-- vulnerable函数的局部变量
|--------------------|
| secret_string (地址)| <-- vulnerable函数的局部变量(指针)
|--------------------|
| vulnerable的SFP     |
|--------------------|
| vulnerable的RP      |
|--------------------|
| ... (更低地址) ...  |

关键点printf函数期望其第一个参数(格式化字符串)之后的内存位置(即栈上的更高地址处)对应着格式说明符(如%s%x%n)所需要的参数。然而,在本例中,我们只传递了一个参数(buff的地址)。当printf解析buff中的内容时,它会将栈上“下一个”位置(即secret_number所在的位置)的数据当作第一个格式说明符的参数,将再下一个位置(即secret_string所在的位置)当作第二个格式说明符的参数,依此类推。

总结

本节课中,我们一起学习了存在printf格式化字符串漏洞的程序环境搭建。我们分析了程序的栈内存布局,明确了当攻击者控制printf的格式化字符串后,函数会如何错误地将栈上相邻的、本不应被访问的数据(如secret_numbersecret_string的地址)当作参数来解析。这为下一节演示如何利用%x%s等格式符泄露这些秘密值奠定了理论基础。

045:基础printf漏洞利用

概述

在本节课中,我们将学习一个基础的printf函数漏洞利用案例。我们将看到当攻击者能够控制传递给printf的格式字符串时,如何利用参数不匹配来泄露程序中的秘密数据。

漏洞原理分析

上一节我们介绍了printf函数的工作原理,本节中我们来看看当攻击者能够控制格式字符串时会发生什么。

攻击者可以控制我们放入缓冲区的内容。假设攻击者写入了一些百分号格式说明符。

攻击者写入了%d %s。这些是百分号格式说明符。

现在,当我们对包含%d %s的缓冲区调用printf时,将会出现参数不匹配,并开始发生不好的事情。

让我们思考一下,当printf读取攻击者提供的这个输入时,它内部是如何处理的。

printf函数逐个字符地读取,每当它看到一个百分号符号,它就会到栈上获取下一个参数,并将该参数插入到百分号格式说明符中。这就是printf的工作方式。

我们开始读取,立即看到一个百分号%d%d表示十进制整数。因此,我应该到栈上,获取下一个参数,将其与%d匹配,并打印出该值,因为%d实际上是该参数的占位符。

这里是%d。我访问栈,获取下一个参数。下一个参数是什么?

第0个参数是buff,它在这里。如果第0个参数是buff,那么下一个参数必须在它上面4个字节处。

请记住,这就是我们将参数压入栈的方式:按相反顺序一个一个地压入。

如果buff是第0个参数并且在这里,那么arg1,即下一个参数,必须在这里。问题是我们实际上从未压入这个参数。

所以,谁知道这里是什么?这里碰巧是secret_number。因此,printf认为这是传递给它的一个参数,但vulnerable函数实际上并没有向printf传递任何额外的参数。

这里存在不匹配。printf并不知道这个不匹配,它访问栈并认为“嗯,这看起来像是arg1。如果它存在,就应该在这里。它实际上并不存在,但如果存在,printf就会到这里来寻找它。”

总而言之,我们访问栈,获取下一个未使用的参数,即第0个参数上方4个字节处,而它碰巧就是secret_number。因此,我将把secret_number作为整数打印出来,以匹配%d

所以,我们不是打印出%d,而是打印出42,因为这恰好是与%d匹配的参数。

printf期望还有两个参数,但实际上并没有。尽管如此,我们仍会去寻找这些参数,并可能发现一些要泄露的秘密值。所以,42被打印出来。

泄露字符串数据

接下来会发生什么?printf继续逐个字符地读取这个输入,它立即看到另一个百分号。

我看到%ss代表字符串。这意味着我必须访问栈,获取下一个未使用的参数,并将其插入到%s占位符中。

到目前为止我使用了什么?我已经用%d使用了这个arg1

那么下一个尚未使用的参数,必须在它上面4个字节处,必须就在这里,我将其标记为arg2

因此,这个secret_string值应该被替换到%s中。s表示字符串,而字符串是指向字符数组开头的指针。

所以我将其读取为一个地址。我前往那个地址,并开始打印字符,直到看到空字符为止。

我打印出PACAKE。我看到了空字符,然后结束。所以总的来说,%d %s导致我打印出42 pancake

我的两个秘密值都被泄露了,因为攻击者提供了一个%d,它匹配到了secret_number,以及一个%s,它匹配到了secret_string

本来不应该有这些参数给printf,但printf不知道这一点,仍然将它们视为参数。

总结

本节课中我们一起学习了如何利用printf函数的格式字符串漏洞。当攻击者能够控制格式字符串并包含额外的格式说明符(如%d%s)时,printf会盲目地从栈上读取本不属于它的数据,并将其作为参数处理,从而导致敏感信息泄露。这个案例清晰地展示了不匹配的参数如何被利用来访问内存中的秘密值。

046:%n 格式化符 🖋️

在本节课中,我们将要学习 printf 函数中一个特殊且危险的格式化符:%n。我们将了解它的工作原理,以及攻击者如何利用它向内存中写入数据,从而可能控制程序的行为。

概述

之前我们已经看到,如果提供给 printf 的格式化符数量与参数不匹配,printf 会从栈上读取本不应被读取的数据,导致信息泄露。然而,printf 的功能远不止于此。%n 格式化符赋予了 printf 向内存写入数据的能力,这比单纯读取数据要危险得多。

%n 格式化符的工作原理

%n 是一个特殊的格式化符。与 %d%s 类似,它会从栈上获取下一个参数。但它不是读取该参数的值来输出,而是将该参数解释为一个内存地址

它的行为可以概括为以下步骤:

  1. printf 计算到目前为止已经输出到屏幕的字符总数。
  2. 遇到 %n 时,它从栈上获取下一个参数。
  3. 它将这个参数的值视为一个地址(指针)。
  4. 它将步骤1中计算的字符总数,写入到这个地址指向的内存位置。

用伪代码描述其核心行为:

int bytes_printed_so_far = ...; // 已输出的字节数
int *address = (int*)next_argument_on_stack; // 将栈上的下一个参数解释为地址
*address = bytes_printed_so_far; // 将字节数写入该地址

这非常奇怪,因为 printf 本应是一个输出函数,但它却执行了写入内存的操作。据我所知,设计 %n 的本意可能是为了帮助格式化输出(例如对齐表格),但除了攻击者,几乎没人会使用它。

%n 的正确用法示例

为了更好地理解,我们先看一个 %n 在参数匹配情况下的正常使用例子。

假设有以下代码:

int val;
printf("Item %d: %n", 3, &val);

以下是 printf 的执行过程:

  1. 输出字符串 "Item "
  2. 遇到 %d,用参数 3 替换,输出 "3"。目前输出了 "Item 3",共6个字符。
  3. 输出冒号 :。目前总共输出了 "Item 3:",共7个字符。
  4. 遇到 %n。它从栈上获取下一个参数 &val(变量 val 的地址)。
  5. 它将到目前为止输出的字符总数(7)写入到 val 变量中。

执行后,变量 val 的值变为 7。

再看另一个例子:

int val;
printf("Item %d: %n", 987, &val);

执行过程:

  1. 输出 "Item "
  2. 输出 "987"
  3. 输出 :
  4. 此时已输出字符为:"Item 987:",共9个字符。
  5. %n 将数字 9 写入 val

所以,%n 在参数正确时,会将已输出的字符数写入指定的变量。

结合参数不匹配的漏洞

上一节我们介绍了 %n 在正常情况下的行为。本节中我们来看看,如果将 %n 与我们之前学过的“参数不匹配”问题结合起来,会发生什么更危险的事情。

当格式化符数量多于实际参数时,printf 会继续从栈上读取数据,并将它们当作参数。如果其中包含 %nprintf 就会把栈上的某些数据当作地址,并向该地址写入数据。

考虑以下危险的调用:

printf("000%n");

这里,格式化字符串包含三个 0 和一个 %n,但没有提供任何额外参数。printf 会这样执行:

  1. 输出三个字符 "000"
  2. 遇到 %n。由于没有提供对应参数,它会从栈上读取本应是下一个参数的位置的数据。
  3. 它将这4个字节的数据解释为一个地址
  4. 它将到目前为止输出的字符数(3)写入到这个未知的地址指向的内存中

这极其危险。攻击者可以通过精心构造输入的字符串,控制输出的字符数,从而控制写入的值(比如通过增加更多字符来写入更大的数字)。更重要的是,他们可以尝试让栈上的特定数据被解释为目标地址(例如某个函数的返回地址或关键变量),从而实现任意内存写入

总结

本节课中我们一起学习了 printf%n 格式化符。

  • %n 使 printf 具备了向内存写入数据的能力,它会将已输出的字符数写入到由栈上参数指定的地址。
  • 在正常使用时,这是一个生僻的功能。
  • 当与参数不匹配的漏洞结合时,攻击者可以诱使程序将数据写入到非预期的内存位置,这为更复杂的攻击(如修改程序控制流)打开了大门。

理解 %n 是理解格式化字符串漏洞攻击的关键一步,因为它将漏洞从“信息泄露”升级到了“内存篡改”。

047:利用%n的基本printf漏洞

在本节课中,我们将学习如何利用printf函数中的%n格式说明符,实现从内存读取数据到向内存写入数据的转变。我们将通过一个具体的代码示例,详细解析攻击者如何控制程序向任意内存地址写入数据。

上一节我们介绍了利用%d%s进行内存读取的原理,本节中我们来看看如何利用%n进行内存写入。

漏洞代码环境

我们使用与之前相同的代码环境。核心部分如下:

char buff[64];
// 攻击者可以控制buff的内容
printf(buff); // 关键调用

我们定义了一个64字节的缓冲区buff,并允许攻击者向其写入任意内容。随后,程序使用用户控制的buff作为格式化字符串参数调用printf函数。

%n格式说明符的作用

与读取数据的%d%s不同,%n是一个用于写入的格式说明符。它的功能是:将到当前位置为止,printf已经成功打印输出的字符总数,写入到一个由参数指定的内存地址中。

以下是printf处理%d%n这类格式化字符串的详细步骤。

第一步:处理%d - 读取并打印

printf首先解析格式化字符串。当遇到第一个格式说明符%d时,它会执行以下操作:

  1. 到调用栈上寻找下一个未使用的参数。
  2. 将该参数的值(例如42)视为一个整数。
  3. 将这个整数(42)以十进制形式打印输出。

此时,输出为“42”,共计打印了2个字符printf内部会记录这个计数。

第二步:处理%n - 计算并写入

紧接着,printf遇到了%n格式说明符。此时,它将执行写入操作:

  1. 确定写入地址printf再次到调用栈上寻找下一个未使用的参数。这个参数必须是一个指针(即内存地址)。在我们的例子中,这个参数是secret_string的地址。
  2. 确定写入内容printf计算从开始输出到%n之前已经打印的字符总数。根据第一步,这个数字是2
  3. 执行写入printf跟随在栈上找到的指针,找到对应的内存位置,然后将数字2写入该地址。

这个过程可以用以下伪代码描述:

写入地址 = 从栈上获取的下一个参数(作为指针)
*写入地址 = 已打印的字符数(例如 2)

攻击效果演示

因此,当攻击者提供%d%n作为输入时,整个攻击流程会产生两个效果:

  1. 打印出栈上的一个秘密数值(例如42)。
  2. 将数字2写入到由栈上另一个参数(secret_string)所指向的内存位置,从而修改了该处的数据。

本节课中我们一起学习了printf函数中%n格式说明符的基本利用方法。我们了解到,攻击者可以通过精心构造的格式化字符串,不仅读取栈上的数据,还能将数据(已打印字符数)写入到由栈上参数指定的任意内存地址中。这为更复杂的攻击(如修改函数返回地址、劫持程序流程)奠定了基础。

048:更复杂的printf漏洞 - 环境搭建 🛠️

在本节课中,我们将学习一个更复杂的 printf 格式化字符串漏洞的利用场景。与之前不同,这次我们将缓冲区放置在栈上,而非内存的静态区域,以展示更复杂的攻击可能性。

栈布局分析 📊

上一节我们介绍了基本的 printf 漏洞原理,本节中我们来看看当缓冲区位于栈上时,内存布局会发生什么变化。

首先,当函数 vulnerable 被调用时,会创建一个新的栈帧。栈帧中必须包含以下内容:

  • 返回地址
  • 保存的帧指针
  • 局部变量 buffstring

以下是栈帧的构成示意图:

函数调用顺序 🔄

接着,代码会调用 printf 函数。printf 会建立自己的栈帧。我们传递给 printf 的参数是 buff。在C语言中,字符数组作为参数传递时,传递的是指向该数组的指针。因此,我们传递的是 buff 的地址。

printf 的栈帧包含其返回地址和保存的帧指针。一个常见的疑问是:fgets 的栈帧在哪里?代码是按行执行的。我们先执行 fgets,它运行时会创建自己的栈帧。但当 fgets 返回后,它的栈帧就被完全清除了。然后我们才执行下一行代码,调用 printf 并创建其栈帧。所以,在 printf 被调用时,fgets 的栈帧已经不存在了。

以下是 printf 被调用时的栈状态示意图:

printf的参数匹配机制 🎯

现在,程序执行到 printf 内部。printf 会解析 buff 中的内容。如果 buff 中包含任何格式化占位符(如 %s, %x),printf 就需要为它们匹配对应的参数。

printf 会从栈上寻找这些参数。具体来说,它会按照以下顺序进行匹配,为了方便理解,我们为栈上的位置进行了编号:

匹配规则如下:

  • 第0个参数(即包含所有占位符的格式字符串)位于 buff 所指的位置。
  • 如果格式字符串中有第一个 % 占位符,printf 会尝试与栈上的这个位置匹配。
  • 第二个 % 占位符会与这个位置匹配。
  • 第三个 % 占位符会与这个位置匹配。
  • 第四个 % 占位符会与这个位置匹配,依此类推。

关键点在于:printf 认为栈上的这些位置存放着它的参数,但实际上这些位置可能原本存放的是其他数据(如返回地址、局部变量等),而并非传递给 printf 的真正参数。

下图清晰地标出了 printf 寻找参数的各个位置:

总结 📝

本节课中我们一起学习了栈上 printf 格式化字符串漏洞的环境搭建与分析。核心在于理解当攻击者控制 printf 的格式字符串参数,并且该字符串位于栈上时,printf 会错误地将栈上原有的数据(如返回地址、局部变量)当作其可变参数来读取。这为后续更复杂的攻击(如读取内存、写入内存)奠定了基础。下一节我们将探讨如何利用这一机制。

049:更复杂的printf漏洞利用 - 目标设定 🎯

在本节课中,我们将学习如何利用printf函数的格式化字符串漏洞,实现一个更复杂的目标:将任意数值写入任意内存地址。我们将以写入数值100到地址0xdeadbeef为例,讲解攻击的构思过程。

攻击目标设定

上一节我们介绍了printf漏洞的基本原理。本节中,我们来看看如何设定一个具体的攻击目标。

我们的目标是:利用printf函数的格式化字符串漏洞,将数值100写入内存地址0xdeadbeef

这个目标比单纯打印出秘密值要复杂一些。你可以选择写入其他数值和目标地址,例如写入shellcode的地址,或者写入RIP寄存器的地址。但核心攻击方法是通用的。我们选择1000xdeadbeef作为示例,你可以将其替换为你需要的任意值和地址。

以下是本次攻击的代码环境设定:

  • 我们有一段存在漏洞的代码。
  • 在执行printf时,我们绘制了其栈布局图。
  • printf函数会跟踪栈上的参数。当它遇到一个格式化占位符(如%s%x)时,会将其与栈上下一个未使用的参数匹配,并打印该值或向该地址写入数据。
  • 我们作为攻击者的总体目标,是向Buff中提供特定的输入,使得printf最终将数值100写入地址0xdeadbeef

攻击原理与挑战

在设定好目标后,我们现在开始思考如何将100写入0xdeadbeef

我们已经知道,攻击中必然包含%n,因为%n是让我们能够向内存写入数据的关键。但此时事情开始变得棘手,因为要让这次攻击成功,许多条件必须同时满足。

我将这种攻击比作杂耍。在杂耍中,你必须让所有球同时保持在空中;在漏洞利用中,你必须将所有要素放置在正确的位置。有时,让它们同时就位是非常困难的。

在构造漏洞利用时,经常发生这样的情况:你修正了一个问题,却破坏了另一个问题。你去修复那个新问题,又破坏了另外两个。这个过程可能需要一些试错。经过多次的“破坏”与“修复”,最终所有要素必须以一种非常特定的方式排列整齐,目标值才能被写入目标地址。

这就是我们接下来要看到的内容:我们将尝试让printf将所有要素排列在正确的位置。

需要同时满足的两个条件

我们已经知道攻击中需要%n,因为%n是写入内存的方式。当%n出现时,请记住,printf会逐字符读取第零个参数(格式化字符串)。每当它看到一个%,就会用相应的操作(如打印或写入内存)来替换它。

printf逐字符扫描并遇到%n时,两件事必须同时为真。这就是“杂耍”的难点所在:你可能满足了其中一个条件,却破坏了另一个;当你修复它时,可能又破坏了第一个。但我们需要两者同时成立。

以下是必须同时满足的两个核心条件:

  1. 控制写入地址:当%n出现在printf的输出中时,栈上的下一个未使用的参数必须是目标地址0xdeadbeef

    • 原理printf看到%n,它会到栈上取出下一个未使用的参数,将其用作一个地址,然后前往该地址写入数据。
    • 要求:因此,当%n出现时,无论下一个未使用的参数是arg4arg6还是arg7,我们都必须确保值0xdeadbeef恰好位于那个内存位置。这样%n看到0xdeadbeef,就会尝试向该地址写入。
    • 后果:如果搞错了这一点,printf可能仍然会写入100,但会写入错误的位置。
  2. 控制写入数值:到目前为止已打印的字节数必须恰好是100

    • 原理printf如何知道要写入什么值?它会计算到目前为止已打印的字节数
    • 要求:因此,第二个必须同时满足的条件是:当执行到%n时,已打印的字节数必须正好是100。这样printf会认为“我已经打印了100个字节,很好”,然后前往0xdeadbeef并写入数字100
    • 难点:正是这一点使得漏洞利用如此棘手。

所以,我们必须同时“杂耍”好这两个事实:我需要已打印100个字节,并且当printf看到%n时,栈上无论哪个位置的下一个未用参数,其内容恰好是值0xdeadbeef。所有这些都必须通过我们输入到Buffprintf格式化字符串来实现。这就是必须发生的“杂耍”。接下来,我们将实现它。

总结

本节课中,我们一起学习了如何为一次复杂的printf格式化字符串攻击设定具体目标:将指定值(100)写入指定地址(0xdeadbeef)。我们分析了攻击的核心在于同时满足两个条件:1) 控制%n读取的栈参数为目标地址;2) 控制%n执行时已打印的字节数为目标值。这个过程就像杂耍,需要精心构造输入字符串,让所有要素在正确的时间点对齐。在接下来的课程中,我们将具体实施这个构造过程。

050:更复杂的printf漏洞 - 控制写入位置 🎯

在本节课中,我们将学习如何利用printf函数的格式化字符串漏洞,精确控制程序向内存中的哪个地址写入数据。我们将通过一个具体的攻击示例,分解其工作原理,并理解攻击者如何通过精心构造的输入来达成目标。

概述

攻击的核心在于,攻击者只能通过fgets函数向一个名为buff的缓冲区写入16字节的数据。然而,通过巧妙构造这些数据,并利用后续printf函数处理格式化字符串的方式,攻击者可以控制程序向任意内存地址(例如0xdeadbeef)写入一个特定的值(例如100)。

攻击载荷分析

以下是攻击者构造并写入buff的完整载荷:

\xef\xbe\xad\xde%94c%c%c%n

让我们逐步分析这个载荷在内存中的布局以及printf如何处理它。

第一步:数据写入内存

首先,fgets函数将用户输入的原样写入buff缓冲区。此时,printf尚未执行,所有数据都只是普通的字节序列。

以下是数据在内存中的布局示意图:

  • 0xdeadbeef:这是4个字节的目标地址。它被写入buff的开头。
  • %94c:这是4个字符:%94c。它们被顺序写入内存。
  • %c:2个字符。
  • %c:另一个2字符的%c
  • %n:2个字符,作为载荷的结尾。

至此,fgets的任务完成。它只是简单地将字符序列复制到了栈上的buff中。

第二步:printf解析与执行

现在,程序调用printf(buff)printf开始将buff的内容作为格式化字符串进行解析。它会逐个字符读取,当遇到%符号时,会将其视为一个格式化占位符,并从栈上按顺序取出一个参数与之匹配。

以下是printf解析过程的示意图:

  1. printfbuff起始处开始读取。
  2. 它依次读取并输出字节 0xef0xbe0xad0xde。因为它们都不是%,所以被当作普通字符打印。
  3. 接着,它遇到了第一个%(属于%94c)。printf知道这是一个格式化指令。它需要从栈上找一个“朋友”(即参数)来匹配这个占位符。这个%94c与栈上的第一个未使用参数(arg1)配对。
  4. printf继续解析,遇到第二个%(第一个%c)。这个%c与栈上的下一个未使用参数(arg2)配对。
  5. 同理,第三个%(第二个%c)与arg3配对。
  6. 最后,它遇到第四个%%n)。这个%n与栈上的下一个未使用参数(arg4)配对。

关键点在于:通过精心构造输入字符串中%格式化符的数量,我们让%n成功匹配到了arg4的位置。而在我们的内存布局中,arg4这个位置存放的正是我们之前写入的地址 0xdeadbeef

这个过程需要反复试验和调整(例如增减%c的数量),以确保%n能对准目标地址。

第三步:完成攻击

%n格式化符与存放着0xdeadbeef的栈位置配对后,printf会执行%n的功能:将截至目前已输出的字符总数,写入%n对应的参数所指向的地址

在这个例子中:

  • %n对应的参数值是0xdeadbeef
  • 在遇到%n之前,printf已经输出了:
    • 4个地址字节(0xefbeadde
    • %94c会输出94个字符(填充空格)
    • 两个%c各输出1个字符(其对应的arg2arg3的值被当作字符打印)
  • 因此,已输出的总字符数为:4 + 94 + 1 + 1 = 100

所以,printf最终会将数字100写入内存地址0xdeadbeef,从而完成了攻击。

总结

本节课我们一起学习了一个利用printf格式化字符串漏洞进行内存写入的复杂案例。我们了解到:

  1. 控制写入地址:通过将目标地址(如0xdeadbeef)作为数据的一部分放入缓冲区,并精心调整格式化字符串中占位符(%)的数量,可以引导printf%n格式化符在解析时恰好使用该地址作为其参数。
  2. 控制写入值:通过组合使用其他格式化输出(如%94c用于输出大量字符,%c用于消耗栈参数并增加计数),可以精确控制printf在遇到%n时已输出的字符总数,从而决定写入目标地址的数值。
  3. 攻击的本质:这种攻击是对程序预期数据流(数据)与控制流(格式化指令)的混淆。程序将用户输入的数据直接当作控制指令(格式化字符串)执行,导致了安全漏洞。

理解这个构建过程需要耐心和试验,但它清晰地展示了格式化字符串漏洞的强大与危险。

051:利用printf漏洞控制写入内容

在本节课中,我们将学习如何利用printf函数的格式化字符串漏洞,精确控制向内存中写入的数值。我们将通过一个具体示例,演示如何将目标值(例如100)写入指定的内存地址(例如0xdeadbeef)。


概述

printf函数中的%n格式化符用于将截至目前已打印的字符数写入一个指针参数指向的内存地址。要成功利用此漏洞,需要同时满足两个条件:

  1. 控制%n对应的参数,使其指向我们想要写入的目标内存地址。
  2. 控制已打印的字符总数,使其等于我们想要写入的目标数值。

上一节我们介绍了如何控制写入的地址,本节中我们来看看如何控制写入的具体数值。


控制写入的数值

%n会将已打印的字符总数写入内存。因此,要写入目标值(例如100),就必须确保在遇到%n时,printf已经打印了恰好100个字符。

计算已打印字符数

假设我们的格式化字符串以地址0xdeadbeef开头,其十六进制表示deadbeef作为字符串被打印时,会输出8个字符(d, e, a, d, b, e, e, f)。但这距离目标100还差得很远。

直接写入大量字符(如92个‘A’)来凑数可能不可行,因为缓冲区空间有限。

使用宽度修饰符进行填充

printf的格式化字符串支持宽度修饰符。例如,%94c表示:获取栈上的下一个参数,并将其作为一个字符输出,但输出宽度至少为94个字符。如果该字符本身不足94个字符,则会用空格在左侧填充至指定宽度。

这是一个非常实用的技巧,它允许我们仅用一个参数就打印出大量字符,而无需在缓冲区中实际存放大量字符。

以下是利用此技巧的步骤分解:

  1. 打印地址:首先,deadbeef作为字符串被打印,贡献 8 个字符。
  2. 使用%94c:接着,%94c会获取栈上的下一个参数,并将其打印为至少94个字符宽的字符。这贡献了 94 个字符。
    • 此时总打印字符数 = 8 + 94 = 102
    • (注:原视频计算为98,此处根据常见实现校正。核心原理不变:通过%数字c来精确增加打印计数。)
  3. 微调计数:为了精确达到100,我们可以在%94c前使用%c来消耗栈上的参数并打印单个字符。假设我们安排两个%c
    • 第一个%c贡献 1 个字符,总数变为 8 + 1 = 9。
    • 接着%94c贡献 94 个字符,总数变为 9 + 94 = 103。
    • 第二个%c贡献 1 个字符,总数变为 103 + 1 = 104。
    • (可见,需要精细调整%c的数量和宽度值来命中目标100。原示例中通过%94c和两个%c达到了100。)
  4. 触发%n写入:当打印字符总数恰好达到100时,下一个格式化符%n被执行。它从栈上获取下一个参数(即我们预先放置的地址0xdeadbeef),并将当前计数100写入该地址。

关键点:同时满足条件

这个漏洞利用的复杂性在于必须同时考虑以下两点:

  • 地址对齐:确保%n在栈上对应的参数正好是我们想要写入的目标地址(0xdeadbeef)。
  • 计数控制:通过组合普通字符、%c和带有宽度修饰的%nc,精确控制到%n被执行时的总打印字符数等于目标值(100)。

当所有条件都正确对齐时,printf在遇到%n时会执行以下操作:

  1. 从栈上读取下一个参数,得到地址0xdeadbeef
  2. 查询内部计数器,得到当前已打印字符数,例如100。
  3. 将数值100写入地址0xdeadbeef指向的内存中。

关于%c格式化符的说明

一个常见的疑问是关于%c的行为。虽然%c打印一个字符(1字节),但它在栈上消耗的数据大小通常是一个机器字长(例如4字节或8字节)。它只使用该数据的最低有效字节作为字符值,忽略高位字节。

这是C语言的一个特性。所有格式化符参数(如%d%c%n%s)在栈上都占用一个完整的指针或整数大小的空间,即使它们实际处理的数据量可能不同。


总结

本节课中我们一起学习了如何利用printf格式化字符串漏洞来向任意内存地址写入任意数值。我们掌握了两个核心技巧:

  1. 通过精心构造格式化字符串和输入数据,控制%n对应的参数,从而控制写入的地址
  2. 综合利用普通字符、%c和带有宽度修饰符的%nc格式化符,精确控制printf已输出的字符总数,从而控制写入的数值

成功利用需要使这两方面的条件在栈布局上精确对齐。理解这个过程对于认识格式化字符串漏洞的威力及其缓解措施至关重要。

052:高级printf漏洞 - 修改攻击参数

在本节课中,我们将学习如何修改一个已构建的格式化字符串漏洞利用程序,以改变其写入的目标内存地址和写入的数值。我们将分析攻击载荷的各个组成部分,并理解如何调整它们来实现不同的攻击目标。

概述

上一节我们完成了一个基础的格式化字符串漏洞利用。本节中,我们来看看如何修改这个利用程序的两个关键参数:目标内存地址要写入的数值。我们将通过两个具体问题来探讨修改方法。

修改目标写入地址

假设我们不再想向地址 deadbe 写入数据,而是希望写入另一个地址,例如 BFFF1234。那么,我们需要修改攻击输入的哪一部分?

以下是攻击载荷中需要修改的部分:

  • 在格式化字符串中,用于指定 %n 格式符所对应参数(即我们希望写入的目标地址)的部分需要被替换。具体来说,就是将原本代表 deadbeef 的字节序列,替换为代表新地址 BFFF1234 的字节序列。
  • 由于攻击载荷的布局是精心构造的,%n 格式符仍然会从栈上对应位置读取参数,只是现在读取到的将是新的地址 BFFF1234,从而将数据写入内存中的不同位置。

修改目标写入数值

假设我们希望写入的数值不再是 100,而是 89。那么,我们需要修改攻击输入的哪一部分?

以下是需要进行的修改:

  • 我们需要改变 %n 格式符被触发前已打印的字符总数,使其等于目标数值 89
  • 因此,我们必须调整格式化字符串中用于填充字节数的部分。例如,将原本用于生成特定数量填充字符的宽度指示符(如例子中的 94)减小,使得在遇到 %n 时,printf 已输出的总字节数恰好为 89

总结

本节课中我们一起学习了如何调整格式化字符串漏洞利用的关键参数。要成功修改一次攻击,需要同时协调两个上下文:一是通过栈布局控制 %n 写入的目标地址;二是通过控制已打印字符数来设定写入的数值。这通常需要一些调试和计算,但掌握了这两个要素的修改原理,你就能灵活地定制攻击载荷以实现不同的内存写入效果。

053:printf 漏洞防御 🛡️

在本节课中,我们将学习如何防御之前讨论的格式化字符串漏洞。我们将看到,防御的核心非常简单直接,关键在于理解漏洞的根源并采取正确的编码实践。

上一节我们详细介绍了格式化字符串漏洞的成因和危害,本节中我们来看看如何有效地防御它。

漏洞根源与防御原理

格式化字符串漏洞的根源在于攻击者能够控制传递给 printf 函数的第零个参数(即格式化字符串本身)。如果攻击者能在这个字符串中插入任意数量的 % 格式化符号,而程序又没有提供相应数量的后续参数,printf 就会从栈上读取本不属于它的数据,从而导致信息泄露或任意内存写入等严重后果。

因此,防御的核心思路非常直接:永远不要让攻击者控制 printf 的第零个参数。因为只有第零个参数会被用来解析 % 符号并与栈上的数据进行匹配。

正确的编码实践

以下是防御格式化字符串漏洞的关键步骤:

  1. 永远使用静态字符串作为格式化参数:在调用 printf 时,格式化字符串应该是硬编码的常量字符串,而不是用户输入或变量。

    // 正确做法
    printf("%s", user_input_buffer);
    // 错误做法
    printf(user_input_buffer);
    
  2. 将用户输入作为后续参数传递:如果你需要打印用户提供的内容,应该使用 %s 等格式化占位符,并将用户缓冲区作为后续参数传入。这样,即使用户在缓冲区中插入了 % 符号,它们也不会被 printf 解释为格式化指令。

遵循以上实践,即使攻击者在缓冲区中放入大量 % 符号也无济于事,因为这些内容不再是第零个参数,因此不会被当作格式化指令处理。只有第零个参数才用于确定后面需要多少个参数。

总结

本节课中我们一起学习了防御格式化字符串漏洞的方法。关键点在于:漏洞的根源是攻击者控制了 printf 的第零个参数(格式化字符串),导致参数数量不匹配。修复方法极其简单——永远不要将用户可控的数据直接作为 printf 的第一个参数传递,而应使用静态的格式化字符串(如 "%s")并将用户数据作为后续参数。虽然修复方法简单,但开发者常常忘记这一点,从而导致严重的安全问题。牢记这一原则,就能有效避免此类漏洞。

054:printf 漏洞利用的深远影响 🎯

在本节课中,我们将快速回顾并总结刚刚看到的格式化字符串漏洞的深远影响。我们将了解,一旦代码存在此类漏洞,攻击者能够实现何种程度的控制,以及如何利用它来构建复杂的攻击。

漏洞核心原理回顾

上一节我们演示了格式化字符串漏洞的基本利用。本节中,我们来看看这个漏洞所能带来的更广泛的影响。

记住,如果代码存在格式化字符串漏洞,我们能够实现以下操作:

  • 我们可以选择任意目标数值(我们选择了100,但也可以是89、105或其他任何值)。
  • 我们可以将这个值写入任意目标地址(我们选择了 deadbeef,但也展示了如何用其他地址替换它)。

本质上,这实现了 向任意地址写入任意值

漏洞的扩展利用

更进一步,如果代码多次调用 printf,或者你在提供的输入中包含了更多的格式化说明符(我们提供了4个,但也可以是8个、12个或更多),你实际上可以 向多个地址写入多个值

你可以利用这一点来构建我们之前见过的一些攻击利用链。例如:

  • 你可以利用这个 printf 格式化字符串漏洞将 shellcode 写入内存。
  • 你可以用 shellcode 的地址覆盖返回地址指针。

因此,你基本上可以实现任何你想要的操作。虽然可能需要一些努力和技巧(例如,精心调整栈上的数据以使所有 %n 格式化说明符正确对齐),但只要付出足够的努力,你就能覆盖 RIP,使其指向 shellcode,从而再次执行任意你想要的 shellcode。

总结与评估

本节课中,我们一起学习了格式化字符串漏洞的深远影响。尽管实际利用起来可能需要更多的工作量才能确保正确,但这种 printf 格式化漏洞与我们见过的其他漏洞一样危险。它赋予了攻击者强大的内存读写能力,是构建复杂攻击链的关键一环。

055:Off-by-One漏洞 - 设置

在本节课中,我们将学习并利用一种被称为“Off-by-One”的漏洞。这种漏洞在项目中很常见,它能很好地检验你对调用栈工作原理的理解。如果你尚未完全掌握,强烈建议你回顾第二讲,确保你清楚地理解了当一个函数被调用和返回时,EBP、ESP和EIP是如何变化的,这对于理解本漏洞至关重要。

漏洞代码示例

让我们考虑如下代码:

int main() {
    vulnerable();
}

void vulnerable() {
    char name[20];
    fgets(name, 21, stdin); // 注意:这里允许写入21个字符,但数组大小是20
}

这里,vulnerable函数定义了一个大小为20的字符数组name。然而,在调用fgets时,代码错误地允许写入21个字符。这个小小的打字错误就是“Off-by-One”漏洞的根源。

绘制栈图分析

面对不熟悉的漏洞时,绘制栈图是一个非常有用的方法。它能帮助我们直观地理解内存布局。

以下是调用vulnerable函数时的栈结构示意图:

(高地址)
+-------------------+
|   main的返回地址   | <-- RIP (Saved EIP)
+-------------------+
|   main的帧指针     | <-- SFP (Saved EBP)
+-------------------+
|                   |
|    name[16-19]    |
|    name[12-15]    |
|    name[8-11]     |
|    name[4-7]      |
|    name[0-3]      | <-- ESP (栈顶)
(低地址)
  • EBP:指向vulnerable栈帧的顶部(即保存的SFP位置)。
  • ESP:指向vulnerable栈帧的底部(即name数组的开始位置)。
  • name数组占用20字节,在栈上占据5行(每行4字节)。

说明:为了简化教学,我们假设main函数也像普通函数一样有自己的RIP和SFP。在实际操作系统中,main的调用方式可能更复杂,但这对我们理解当前漏洞没有影响。

分析可覆盖的内存范围

作为攻击者,我们只能通过输入来控制写入栈的数据。因此,明确我们能覆盖和不能覆盖哪些内存区域是关键。

根据代码,我们可以向name数组写入21字节。这意味着:

  • 可以覆盖:整个20字节的name数组。
  • 还可以覆盖保存的帧指针(SFP)的最低有效字节(即第21个字节覆盖的位置)。
  • 无法覆盖返回地址(RIP)。我们无法像经典缓冲区溢出那样,直接修改返回地址指向shellcode。

下图高亮部分(黄色)展示了我们能够覆盖的内存区域:

+-------------------+
|   main的返回地址   | <-- 无法覆盖
+-------------------+
|   SFP (字节3)     | <-- 无法覆盖
|   SFP (字节2)     | <-- 无法覆盖
|   SFP (字节1)     | <-- 无法覆盖
|   SFP (字节0)     | <-- **可以覆盖(第21字节)**
+-------------------+
|    name[16-19]    | <-- 可以覆盖
|    name[12-15]    | <-- 可以覆盖
|    name[8-11]     | <-- 可以覆盖
|    name[4-7]      | <-- 可以覆盖
|    name[0-3]      | <-- 可以覆盖
+-------------------+

通过以上分析,我们已经排除了直接覆盖RIP的经典攻击方式。攻击的焦点自然落在了那个唯一能被覆盖的SFP字节上。

本节总结

本节课我们一起设置了“Off-by-One”漏洞的分析环境。我们首先查看了存在漏洞的代码,然后绘制了详细的栈图来理解内存布局。最关键的一步是,我们分析了攻击者输入所能影响的内存范围,发现只能覆盖name数组和保存的帧指针(SFP)的一个字节,而无法触及返回地址(RIP)。这为我们指明了下一步的探索方向。

在下一节中,我们将深入探讨,如何仅仅通过覆盖这一个字节,来实现有效的漏洞利用。

056:Off-by-One漏洞利用构建 🧩

在本节课中,我们将学习如何利用一个“差一字节”(Off-by-One)漏洞来构建一个完整的攻击。我们将从一个具体的栈内存布局图出发,理解哪些内存区域可以被覆盖,并重点分析如何通过修改一个关键字节来劫持程序的控制流。

栈布局回顾与关键字节

上一节我们介绍了栈的基本布局。现在,我们来看一下当前问题的设定。我们已经绘制了栈内存图,并明确了哪些内存区域可以被覆盖,哪些不可以。

关键在于,我们只能覆盖一个字节,即保存的帧指针(SFP)的最低有效字节

一个小提示:如果你对字节序(Endianness)不熟悉,可以回顾相关视频。在x86架构(小端序)中,存储在最低内存地址的字节是数值的最低有效字节。例如,如果SFP保存的地址是 0xBFFFCD60,那么我可以修改的 0x60 就是最低有效字节。

这告诉我们,我们可以改变这个SFP,让它指向另一个地方。那么,我们首先要问自己:它当前指向哪里?程序如何使用栈上存储的这个值?改变它为什么能帮助我们执行shellcode?

帧指针(SFP)的作用

回想栈帧和函数调用过程,这个SFP值代表了前一个栈帧的顶部。当易受攻击的函数返回时,这个值会被放回EBP寄存器。当程序返回到main函数时,EBP应该恢复到main函数栈帧的顶部。

换句话说,这个值指示了前一个栈帧的顶部。更具体地说,我们定义任何栈帧的顶部就是它的SFP。因此,每个SFP值都保存着前一个栈帧的SFP的地址。这是一种方便的说法:每个SFP都持有一个地址,该地址是前一个栈帧顶部的地址。

那么,这个地址有什么用呢?程序如何使用它?程序将这个值恢复回EBP后,就可以使用这个基指针来定位栈上的其他值。例如,如果EBP在这里,程序可以通过从EBP向上偏移4字节来找到返回地址(RIP),或者继续向上偏移来找到函数的参数。基指针对于在栈上定位自身和其他数据非常有用。

利用SFP定位关键地址

所以,SFP(因为它保存了将要放入EBP的值)是程序在栈上定位其他值的一个便捷方式。我们特别关心的是找到返回地址(RIP),因为如果我们能覆盖它,就可能引发恶意行为。

举个例子:

  • 如果程序在vulnerable函数中,我们问“vulnerable的RIP在哪里?”,程序会查看当前EBP指向的位置(即SFP),然后向上看4字节,那里就是RIP。
  • 如果我们问“main函数的RIP在哪里?”,程序会怎么做?它会利用保存的基指针作为锚点。它会查看vulnerable的SFP值(这是一个地址),跟随这个地址,到达它所认为的main函数的SFP位置,然后再向上看4字节,就找到了main的RIP。

核心逻辑:程序通过 当前函数的SFP值 -> 前一个函数的SFP位置 -> 向上偏移4字节 这个链条来定位前一个函数的返回地址。

构造攻击:篡改SFP

既然我们控制着用于寻找main函数RIP的那个值(vulnerable的SFP),并且我们可以改变它的一个字节,那么如果我们改变这个字节会发生什么?

假设我们将SFP的最低字节从 0x60 改为 0x44。现在,这个地址不再指向main函数真正的SFP,而是指向了main函数中更下方的某个位置(比如,在字符数组name内部)。但程序并不知道我们修改了它

如果我们再次问程序“main的RIP在哪里?”,程序仍然会执行相同的逻辑:

  1. 取出vulnerable的SFP值(现在已被我们修改)。
  2. 跟随这个地址(现在指向name数组内部)。
  3. 认为这里就是main的SFP(我们称之为 伪造的SFP)。
  4. 向上偏移4字节,认为那里就是main的RIP(我们称之为 伪造的RIP)。

实际上,那里并不是真正的RIP,而是name数组中的一部分内存。关键在于,这部分内存是我们能够完全控制的!

完成攻击载荷

这就是“差一字节”攻击背后的核心思想。我们无法直接覆盖vulnerable函数自身的RIP,但我们可以通过修改其SFP的一个字节,间接地让程序误以为main函数的RIP位于我们控制的内存区域(name数组)中。

现在,我们只需要填充剩余部分来完成攻击。

以下是构建攻击的步骤:

  1. 计算偏移:精确计算输入数据中,哪个字节对应着vulnerable函数SFP的最低有效字节。
  2. 确定目标地址:决定我们希望伪造的SFP指向name数组中的哪个位置。将这个地址的最低字节(例如0x44)写入SFP的覆盖点。
  3. 放置Shellcode地址:在name数组中,伪造的SFP位置向上偏移4字节的地方(即程序认为的“伪造的RIP”),写入我们shellcode的内存地址。例如,如果shellcode在地址 0xDEADBEEF,我们就在这里写入 0xDEADBEEF
  4. 写入Shellcode:将实际的恶意shellcode代码写入name数组的其他位置(通常是伪造的RIP之后或之前,并确保地址正确指向它)。

最终,当vulnerable函数返回,然后main函数返回时,程序会取出我们伪造的RIP值(即shellcode的地址)并跳转执行,从而完成攻击。

总结

本节课中,我们一起学习了如何利用一个微小的Off-by-One漏洞构建完整的控制流劫持攻击。我们了解到:

  • 栈帧指针(SFP)在函数返回和栈导航中的关键作用。
  • 通过修改SFP的一个字节,可以诱使程序错误地定位前一个函数的返回地址。
  • 攻击的核心在于让程序从一个我们可控的内存区域(如缓冲区)中读取它“认为”的返回地址,而我们则在该地址处放入恶意代码的指针。
  • 整个攻击链可以概括为:覆盖SFP最低字节 -> 重定向SFP指向可控缓冲区 -> 在伪造的RIP位置写入shellcode地址 -> 执行shellcode

在下一节中,我们将具体观察函数实际返回时,这个攻击是如何一步步执行的。

057:Off-by-One漏洞利用详解 🎯

在本节课中,我们将详细分析一个Off-by-One漏洞的完整利用过程。我们将看到,通过精心构造的输入,攻击者如何利用一个字节的溢出,最终引导程序执行恶意代码。理解这个过程需要掌握两个核心概念:栈帧指针的链式关系,以及需要两次函数返回才能触发代码执行的原因。

上一节我们介绍了如何构造攻击载荷来覆盖栈上的关键数据。本节中,我们来看看当vulnerable函数和main函数依次返回时,程序的控制流是如何被劫持的。

核心概念一:栈帧指针链 🔗

第一个关键概念是,栈帧指针总是指向前一个栈帧的栈帧指针。用公式表示这个关系就是:

SFP_current -> SFP_previous

这意味着,如果你顺着当前栈帧指针的值去查找,你应该能找到前一个栈帧的栈帧指针。但是,由于我们覆盖了这个指针的一个字节,程序在顺着这个被破坏的地址查找时,会定位到一个错误的位置。程序误以为这个错误位置是一个栈帧指针,并进一步认为它上方存储的是返回地址。这是理解该漏洞利用为何能成功的第一个要点。

核心概念二:需要两次返回 🔄

第二个关键概念是,我们覆盖的是main函数的返回地址,而不是vulnerable函数的。这意味着,要让这段代码执行Shellcode,我们需要经历两次函数返回过程:

  1. vulnerable函数返回。
  2. main函数返回。

只有第二次从main函数返回时,才会触发Shellcode的执行,因为程序误将我们覆盖的数据当作了main函数的返回地址。而vulnerable函数的返回地址未被触碰,因此第一次返回是正常的。

接下来,让我们分步观察这两次返回是如何发生的。

第一次函数返回过程

此时栈的布局已经设置好,我们准备执行第一组函数收尾指令。

以下是第一次返回的三个标准步骤:

  1. 移动栈指针ESP被移动到EBP所在的位置,这释放了vulnerable函数的栈帧空间。
    mov esp, ebp
    
  2. 恢复前一个栈帧指针pop ebp指令将栈顶的值(即保存的帧指针SFP)弹出并放入EBP寄存器。由于我们覆盖了这个值的一个字节,EBP现在指向一个我们控制的错误位置。
    pop ebp
    
  3. 返回ret指令类似于pop eip,它将栈顶的下一个值弹出并放入指令指针EIP。这个值(vulnerable的原始返回地址)未被修改,因此EIP会正常地跳回main函数继续执行。
    ret
    

第一次返回完成后,只有EBP寄存器指向了错误的位置,EIPESP都处于正常状态。这为第二次返回埋下了伏笔。

第二次函数返回过程

现在,我们开始执行第二次函数返回。此时唯一的异常是EBP指向了栈上的一个错误位置。

以下是第二次返回的步骤:

  1. 移动栈指针:同样,ESP被移动到EBP所在的位置。但由于EBP指向错误位置,ESP也被拖到了这个低地址处。
    mov esp, ebp
    
  2. “恢复”栈帧指针:程序执行pop ebp,将当前栈顶的值(程序误以为是SFP)弹出到EBP。这个值是我们填充的垃圾数据,因此EBP会指向一个不可预知的地址。
    pop ebp
    
  3. 关键跳转:这是整个利用的“高光时刻”。程序执行ret指令,将下一个栈值弹出到EIP。程序误以为这是一个返回地址,但实际上,这个值是我们精心构造的、指向Shellcode的地址(例如0xdeadbeef)。
    ret  ; 实际效果: pop eip
    
    EIP被设置为Shellcode的地址后,处理器便开始执行我们的恶意代码。

总结 📝

本节课中我们一起学习了Off-by-One漏洞的完整利用链。我们了解到,由于只覆盖了一个字节,攻击需要依赖两次函数返回才能完成:

  • 第一次返回破坏了EBP,将其引导至攻击者控制的栈区域。
  • 第二次返回则利用了这个被破坏的EBP,通过标准的函数收尾指令,最终将程序控制流劫持到Shellcode的地址。

这个利用过程巧妙地利用了程序对栈布局的信任,通过一个微小的溢出,引发了连锁反应,最终实现了代码执行。理解这个过程对于防御此类漏洞至关重要。

058:NOP雪橇技术

在本节课中,我们将探讨如何使内存安全漏洞利用更加鲁棒。我们将介绍一种名为“NOP雪橇”的技术,它允许攻击者在信息不完整的情况下,提高其攻击成功的概率。通过理解这种技术,你将明白为何仅仅依赖“攻击者不了解我的代码”这种假设是不足以保障安全的。

假设攻击者拥有完整信息

上一节我们讨论了多种内存安全漏洞利用技术。你可能会质疑这些攻击的可行性,认为它们假设攻击者能获得你的代码副本、知晓所有内存地址并能精确预测一切。这种质疑是合理的,我们确实做了一些假设。

然而,回顾我们最初关于安全原则的讲座,我们曾讨论过“通过隐匿实现安全”的概念,并强调不应依赖这种安全方式。隐藏信息并非保障安全的有效借口。我们不能假设攻击者不了解我们的代码或内存布局,因此他们无法实施攻击。

我们必须始终假设攻击者了解我们的系统。例如,他们可能入侵系统获取代码副本,或者代码是开源的,甚至可能因泄露而被获取。因此,我们不能依赖“希望攻击者不了解我的代码”这种想法来确保安全。我们应该假设攻击者确实拥有代码副本,并确保我们的代码足够健壮,能够抵御攻击,即使代码信息已泄露。

提高攻击成功率:NOP雪橇技术

虽然我们强调应假设攻击者拥有完整信息,但现实中,攻击者有时可能信息不全。他们可能对地址有大致猜测,但无法精确知晓栈的布局或代码细节。在这种情况下,攻击者可以使用一些巧妙技巧来提高成功率。以下将介绍一种这样的技巧,它表明即使攻击者信息有限,仍能造成严重危害。

这种技巧称为“NOP雪橇”。首先,在X86汇编语言(以及几乎所有汇编语言)中,存在一条名为“NOP”的指令,其含义是“无操作”。例如,一条将0与0相加但不存储结果的指令,就是一条什么都不做的指令。NOP指令有多种用途,但攻击者可以利用它来构建“NOP雪橇”。

假设我们没有使用NOP指令,而只想执行位于内存中特定位置的shellcode(例如,从XOR指令开始的一系列指令)。在经典的缓冲区溢出攻击中,我们需要将返回指令指针(RIP)精确覆盖为XOR指令的地址。成功的条件只有一个:RIP必须精确指向XOR指令的地址。如果稍有偏差,例如地址偏移了4个字节,程序可能会跳转到非指令数据区,导致崩溃或不可预测行为,攻击便会失败。

现在,考虑使用NOP雪橇的情况。攻击者可以重写其shellcode,使其不是立即以XOR指令开始,而是在前面插入大量NOP指令。修改后的shellcode功能与原始完全相同,只是在有效指令前执行了大量无操作。

这样做的好处是,攻击者成功的机会大大增加。现在,攻击者不仅可以将RIP覆盖为XOR指令的地址,还可以覆盖为前面任何一个NOP指令的地址。无论跳转到雪橇中的哪个NOP指令,程序都会顺序执行这些无操作指令,“滑行”通过整个NOP区域,直到抵达第一个有效指令(XOR),然后完整执行后续的shellcode。

因此,NOP雪橇技术为信息不完整的攻击者提供了一种提高成功概率的有效方法。它表明,仅依赖“攻击者不了解我的代码”这种防御思想是远远不够的。即使攻击者对你的系统知之甚少,他们仍能利用此类技巧使恶意事件发生,并提高其成功率。

总结

本节课中,我们一起学习了NOP雪橇技术。我们首先重申了不应依赖“通过隐匿实现安全”的原则,必须假设攻击者可能拥有系统代码和信息的访问权限。接着,我们探讨了当攻击者信息不完整时,如何利用NOP雪橇技术来扩大成功覆盖的地址范围,从而提高缓冲区溢出等攻击的成功率。理解这种技术有助于我们认识到,构建安全的系统需要从根本上消除漏洞,而非依赖攻击者对系统内部细节的无知。

059:总结

在本系列视频中,我们探讨了多种内存安全漏洞。本节课程将对所有内容进行总结,帮助您巩固理解。

格式字符串漏洞

上一节我们介绍了缓冲区溢出,本节中我们来看看格式字符串漏洞。其核心思想是,如果向 printf 函数传递的参数数量不匹配,printf 仍会从栈中读取参数,这可能导致危险。

以下是该漏洞的关键点:

  • printf 会从栈中读取本不应被打印的数据,导致信息泄露。
  • 如果使用 %n 格式化符号,printf 会将当前已打印的字节数写入栈中下一个值所指向的地址。这允许攻击者向任意内存地址写入数据,非常危险。

因此,我们必须谨慎防范格式字符串漏洞,确保用户无法控制 printf 至关重要的第一个参数(即格式字符串本身)。

差一错误漏洞

在讨论了格式字符串漏洞后,我们来看另一种常见漏洞:差一错误。这种漏洞虽然未在幻灯片上显示,但其原理非常重要。

差一错误是指,如果仅覆盖了保存帧指针(SFP)的一个字节,就可以欺骗程序,使其认为前一个函数的返回地址(RIP)位于不同的位置。通过这种欺骗,攻击者可以在该位置写入自己的RIP值,从而导致程序执行任意代码(如shellcode)。

此外,我们还学习了如何通过添加“空操作雪橇”(NOP sleds)等技巧来增强攻击的鲁棒性。即使没有关于目标系统的完整信息,这些技巧也能提高攻击成功的概率。

总结与警示

本节课中,我们一起学习了格式字符串漏洞和差一错误漏洞。如果代码中存在内存安全漏洞,我们必须假设系统已被攻破,攻击者可以执行任何操作。这些漏洞威力巨大,即使攻击者只覆盖一个字节,也可能完全控制您的系统。

因此,这些漏洞极其危险,我们必须时刻保持警惕,并在开发中采取严格措施来预防它们。

060:引言

在本节课中,我们将学习如何防御或缓解之前介绍过的内存安全漏洞。我们将首先探讨这些漏洞为何至今仍然存在,然后深入分析四种主要的防御策略,以降低这些漏洞被利用的风险和影响。

为什么漏洞依然存在?🤔

上一节我们介绍了内存安全漏洞的危险性。本节中,我们来看看一个更根本的问题:为什么在2025年的今天,这些漏洞仍然普遍存在?它们常年位居最常见安全漏洞榜单前列,这背后有四个较为哲学层面的原因。

四种防御策略概览 🛡️

理解了漏洞的根源后,我们将重点转向防御。以下是本系列视频将详细探讨的四种主要防御策略类别,我们将逐一审视。

  • 策略一: 通过改进编程语言或工具,在程序运行前预防漏洞的产生。
  • 策略二: 在程序编译或链接阶段,加入检测和阻止漏洞的机制。
  • 策略三: 在程序运行时,实时监测并阻止恶意行为。
  • 策略四: 即使漏洞被触发,也采取措施限制其可能造成的损害。

本节课中,我们一起学习了内存安全漏洞防御的引言部分,探讨了漏洞持续存在的原因,并概述了后续将深入讲解的四大防御策略。接下来,我们将逐一详细分析这些策略。

061:使用内存安全语言 🛡️

在本节课中,我们将要学习如何通过使用内存安全语言来从根本上防止内存安全漏洞。这是一种非常直接且有效的方法。

概述

防止内存安全漏洞的第一类方法是使用一种本身就不存在此类漏洞的编程语言。这听起来似乎显而易见,但实际上是一种非常有效的防御策略。核心思想是:选择一种在设计上就具备安全特性的编程语言。

内存安全语言的工作原理

上一节我们介绍了使用内存安全语言的基本概念,本节中我们来看看它们是如何工作的。

一些编程语言在设计时,就内置了边界检查机制。例如,Java、Python、Go 等语言会在你访问数组时自动检查索引是否越界。

当你定义一个大小为 5 的数组,并试图访问第 6 个元素时,程序会直接拒绝这个操作,不会让你访问。

代码示例:

# 在Python中尝试访问越界索引
my_list = [1, 2, 3, 4, 5]
# 以下操作会引发 IndexError 异常,程序会停止
# element = my_list[5]

C 语言则不是这样设计的。在 C 语言中,如果你定义了一个大小为 5 的数组并请求第 6 个元素,C 语言会允许你访问,即使它已经超出了边界。

内存安全语言的设计者在构建语言时,就加入了自动的边界检查。因此,这些语言本身就不存在内存安全漏洞。我们讨论过的所有内存安全漏洞,都与向数组末尾之外进行读写操作有关。如果你的语言根本不允许你越界读写,那么内存安全漏洞就不会发生。

如何选择内存安全语言

以下是选择内存安全语言时需要考虑的几个关键点:

  • 广泛的选择:实际上,大多数现代编程语言都是内存安全的。我们能想到的非内存安全语言主要是 C、C++ 及其衍生语言(如 Objective-C)。只要你不使用这些语言,就无需担心内存安全问题。
  • 最坚固的防御:使用具备边界检查的语言,是对抗内存安全漏洞最稳健的防御方式。因为在这种语言中,此类漏洞根本不存在。这是本课程中少数几种可以保证 100% 有效的防御措施之一。

如果你使用像 Java、Python 或 C# 这样的语言,你就能防御 100% 的此类漏洞,因为这种攻击方式在你的编程环境中并不存在。你从根本上解决了问题。

为什么人们仍然使用 C 语言?

基于以上内容,你可能会产生一个疑问:既然内存安全语言这么好,为什么人们还在使用 C 语言呢?

以下是几个常见的原因:

  • 性能考量:一个常见的论点是,C 语言虽然不安全,但速度非常快。人们认为需要在速度(C语言)和安全性(内存安全语言)之间做出权衡。例如,Java 因为是内存安全语言,需要进行额外的内存跟踪和边界检查,这可能会引入一点延迟。而 C/C++ 的内存管理可以达到最快的速度。
  • 性能权衡的再思考:首先,性能差异在日常编程中往往被夸大了。对于 99% 的应用程序(如作业、工作项目),使用 Java 和 C 之间的速度差异微乎其微,用户根本无法察觉。用 10 毫秒的微小延迟换取巨大的安全性提升,通常是值得的。更重要的是,如今出现了许多既高性能又内存安全的语言(如 Rust),打破了“安全必然慢”的旧观念。如果两者可以兼得,为什么还要做取舍呢?
  • 开发效率:从更高的哲学层面看,使用像 C 这样的语言,你会花费大量时间调试内存安全漏洞。如果从“每日产出代码行数”的效率来看,使用内存安全语言可能反而更快,因为它节省了调试和修复错误的时间。
  • 利用高性能库:即使你使用像 Python 这样相对较慢的语言,它们也通常拥有可以调用底层 C/C++/Rust 库的接口(如 NumPy)。这样,你既能享受内存安全语言的安全性和开发便利,又能通过高性能库获得所需的运行速度。
  • 遗留代码问题:最后一个重要原因是历史遗留。在过去几十年里,人们用 C 语言编写了大量的代码。当你接手一个庞大的 C 语言项目时,你只有两个选择:要么用 Rust 等安全语言重写整个项目,要么在现有代码库上继续开发。几乎所有人都会选择后者,因为这容易得多。如果你加入一家公司,其所有代码都是 C 语言写的,你不太可能将其全部重写,而很可能会继续用 C 语言进行开发。

总结

本节课中我们一起学习了使用内存安全语言来防御内存安全漏洞。我们了解到,像 Java、Python、Go、Rust 等语言通过内置的自动边界检查,从根本上消除了缓冲区溢出等漏洞。虽然历史上人们因性能和遗留代码问题仍在使用 C/C++,但现代语言的发展正在缩小性能差距,使得安全与高效可以兼得。对于新项目,选择一门内存安全语言是避免此类安全问题的强有力策略。

062:编写内存安全代码

在本节课中,我们将学习为何会存在内存安全漏洞,以及如何在编写代码时采取策略来避免它们。我们将重点探讨在使用不安全语言(如C语言)编程时,如何通过防御性编程等方法,尽可能地减少安全风险。

上一节我们讨论了内存安全漏洞的根源。本节中,我们来看看如何在实际编程中应对这些风险。

防御性编程的必要性

我们目前处于一个现实情况:大量遗留代码是用C语言编写的。当你在一个使用C语言的公司工作时,你很可能需要继续编写C代码。这就是为什么内存不安全的语言至今仍然存在。

既然我们或多或少被这些不安全的语言所“困住”,那么如何编程才能避免我们之前看到的、非常危险的缓冲区溢出攻击呢?本节将探讨在使用不安全语言编写代码时,如何尝试更安全地编程。

什么是防御性编程?

防御性编程的理念类似于防御性驾驶,但应用于编程领域。其核心思想是:在使用内存不安全语言(如C或其衍生语言)编程时,必须保持高度警惕。因为我们已经看到,即使是最微小的错误也可能导致整个程序变得脆弱。

因此,我们必须格外小心。具体来说,为代码添加检查至关重要,即使你认为这些检查没有必要,或者你认为输入“总是”会在缓冲区内。添加检查可以防止你犯错或应对意外情况。

以下是防御性编程的一些具体实践:

  • 检查指针:在解引用指针(即访问该内存地址)之前,务必检查它是否为NULL,即使你非常确定它不是。
    if (ptr != NULL) {
        // 安全地使用 ptr
    }
    
  • 验证输入:当从用户接收输入时,必须检查接收的字节数是否符合预期,且没有超出限制,即使你对此很有信心。
    if (bytes_received <= buffer_size) {
        // 安全地处理数据
    }
    

防御性编程是一种好方法,但说实话,它实施起来颇具挑战性。这要求程序员非常有纪律性,并且对编程方式非常谨慎。在临近截止日期、时间紧迫时,人们往往容易忽略这些检查。因此,这是一种理想的做法,但并非总能被始终贯彻。这是在编写不安全语言代码时应具备的思维方式。

选择安全的库函数

我们上次也提到,如果你用C这样的不安全语言编写代码并需要调用C库函数,务必查阅这些库函数的文档,确保你使用的是安全的版本。

以下是一些安全与不安全函数的对比示例:

  • 不要使用 gets,因为它允许写入超出数组末尾。应使用 fgets,因为它限制了写入量。
    // 不安全
    gets(buffer);
    // 相对安全
    fgets(buffer, sizeof(buffer), stdin);
    
  • 不要使用 strcpy,它允许用户复制任意多数据到目标位置。应使用 strncpy,它允许你指定一个限制,并在复制超出数组末尾时截断。
    // 不安全
    strcpy(dest, src);
    // 相对安全
    strncpy(dest, src, dest_size - 1);
    dest[dest_size - 1] = '\0'; // 确保字符串终止
    

库函数有安全和不安全之分,确保使用正确的版本非常重要。同样,这需要程序员的高度自律。在截止日期压力下,一旦疏忽使用了不安全的函数,整个代码的安全性就可能崩塌。

人们常问,既然gets如此不安全,为何它仍然存在?原因还是在于遗留代码。如果直接从C库中移除gets,大量旧代码可能会停止工作。因此,出于兼容性考虑,许多不安全的函数被保留了下来。我们将在后续视频中尝试缓解它们的影响。但如果你不得不使用C语言编程,上述就是你应该秉持的理念。

形式化验证:代码正确性证明

如果你想更精确地确保代码安全,实际上可以通过形式化验证来严格检查代码的正确性。这涉及到计算机科学的一个专门领域。

其做法是:取一段代码,对其进行严谨的逻辑推理,写出称为前置条件后置条件不变式的断言。本质上,他们是在尝试为代码的安全性编写一个数学证明。

例如,可以尝试证明:基于输入满足特定条件,并且代码的每一行执行特定操作,最终可以论证这段代码是内存安全的,不会发生越界写入。

然而,坦白说,这个过程也相当繁琐。这是一种良好的实践,也确实有人这么做。但现实是,当编程截止日期临近时,你很可能不会坐下来为你的代码为何能工作而撰写证明。你更可能将其提交给自动评分器并希望它能通过。因此,我们不会过多深入此话题。只需知道,更复杂的代码正确性证明策略是存在的,尽管在日常编程中你可能不会用到它们。

本节课中,我们一起学习了在面对内存不安全语言时的编程策略。我们介绍了防御性编程的核心思想,即通过添加额外检查来预防错误。我们还强调了选择安全的库函数的重要性,并简要了解了通过形式化验证证明代码正确性的高级方法。记住,在使用C这类语言时,保持警惕和自律是编写安全代码的关键。

063:构建安全系统的工具 🛠️

在本节课中,我们将学习在不得不使用内存不安全语言(如C语言)进行编程时,如何利用各种工具和方法来增强系统的安全性。我们将探讨运行时检查、监控、沙箱、自动化分析工具以及安全测试等多种策略。

运行时检查与监控 🔍

上一节我们讨论了通过切换语言或谨慎编程来避免内存安全问题。然而,这些方法有时并不足够。本节中,我们来看看如何利用在程序运行时工作的工具。

以下是两种主要的运行时方法:

  • 运行时检查工具:这些工具与你的程序一同运行,可能会为你执行一些边界检查。如果检查失败,它们会发出警报或使程序崩溃。例如,它们可以检测数组越界访问。
    • 代价:引入性能开销,会减慢程序运行速度。
  • 代码行为监控工具:这些工具监控代码的执行行为,寻找异常活动。例如,如果你的代码从未调用过某个危险的系统函数(如 execv),但监控工具发现它突然开始调用,这可能表明程序已被攻击者利用缓冲区溢出漏洞劫持。
    • 局限性:这类工具通常在异常行为发生后才能发现攻击,可能为时已晚,无法阻止损害发生。

隔离与遏制损害 🏝️

既然我们无法完全阻止所有攻击,另一个思路是尽量控制攻击成功后的破坏范围。

一种有效的方法是使用沙箱虚拟机。这些是隔离的运行环境。即使攻击者通过缓冲区溢出控制了你的程序,他们也被限制在这个隔离环境中,无法逃逸出去影响系统的其他部分。

这类似于我们之前讨论的安全原则中的权限分离:只赋予程序完成任务所必需的最小权限。这样,即使程序被攻破,攻击者也无法获得整个系统的控制权。

自动化分析与外部审计 📋

除了运行时工具,还有一些静态或半自动化的方法可以帮助发现漏洞。

以下是一些可行的途径:

  • 自动化代码扫描工具:这些工具扫描源代码,尝试发现常见的不安全模式,例如使用了不安全的函数 gets。它们并不完美,可能会有误报,但能提供有价值的参考。
  • 聘请外部安全公司进行代码审计:由专业的安全工程师人工审查代码,寻找内存安全错误。这种方法可能更有效,但成本也更高。这涉及到安全经济学:你是否愿意为代码审查付费?
  • 漏洞扫描与渗透测试:这两种方法的核心思想是:在程序发布前,自己尝试攻击它。
    • 漏洞扫描:使用工具自动探测系统中已知的漏洞。
    • 渗透测试:聘请安全专家(在授权范围内)尝试入侵你的系统。如果他们成功了,他们会告诉你如何被入侵的,从而让你有机会在问题暴露给外界之前修复它。

针对安全性的测试 🧪

测试代码功能是否正确是基础,但针对内存安全性进行专门测试同样至关重要。

编写安全性测试颇具挑战性,因为我们并非总是知道“正确的安全行为”具体是什么。以下是几种方法:

  • 模糊测试:向程序输入大量随机生成的数据(成千上万甚至数百万条)。如果任何一条输入导致程序崩溃或行为异常,就可能发现了内存安全漏洞。有时,简单的随机测试就足够有效。
  • 使用专门工具:例如,Valgrind 这类工具可以帮助检测内存泄漏和越界访问等问题。
  • 故意测试边界情况:专门测试那些你之前未考虑到的极端输入,确保程序不会在这些情况下崩溃。

然而,安全性测试的充分性难以衡量。我们无法确定测试到何种程度才算“安全”。代码覆盖率工具可以告诉你有多少代码被测试过,但这只是一个参考指标。

管理第三方依赖 🔄

现代软件开发严重依赖第三方库,这引入了额外的安全挑战。

关键点在于:即使你的代码是安全的,你所使用的库如果不安全,整个系统依然脆弱。 因此,保持所有依赖库的更新至关重要。当库作者发布安全补丁时,你必须及时应用这些更新。

这在实际操作中可能很麻烦(例如,系统频繁要求重启以更新),但却是必要的。忽略库更新会使你的系统暴露在已知漏洞之下。


本节课中我们一起学习了多种在内存不安全环境下构建安全系统的工具和方法:从运行时检查、行为监控、沙箱隔离,到自动化扫描、渗透测试和专门的安全测试,再到管理第三方库的更新。虽然这些方法不能根除所有漏洞,但它们能显著提高攻击门槛,并在攻击发生时有效遏制损害,是构建深度防御体系的重要组成部分。

064:漏洞利用缓解措施

在本节课中,我们将学习一类被称为“漏洞利用缓解措施”的防御技术。我们将探讨如何通过改变程序的编译和运行方式,来增加攻击者利用常见内存安全漏洞的难度。

概述

上一节我们介绍了一些保护系统免受内存安全漏洞攻击的通用理念。本节中,我们将深入探讨“漏洞利用缓解措施”这一类别。其核心思想是,即使我们被迫使用不安全的语言(如C语言)维护大型遗留代码库,也可以通过技术手段增加攻击成本,使常见攻击更难成功。

缓解措施的目标与理念

假设你刚入职一家公司,接手了一个用C语言编写的大型代码库。重写代码以使用内存安全语言(第一种防御方式)通常不现实。虽然存在一些辅助工具和防御性编程方法,但它们并不完美。

因此,我们的目标是在不得不使用不安全代码的场景下,尝试阻止一些常见攻击。这就是本节要讨论的“漏洞利用缓解措施”。我们通过改变编译器和运行时执行代码的方式,来增加漏洞利用的难度。

我们无法使攻击变得完全不可能。杜绝所有攻击的唯一途径是切换到内存安全语言,但在此场景下我们无法做到。然而,我们可以尽力增加常见攻击的难度,给攻击者制造麻烦。

一个常见的策略是:与其让攻击者成功利用程序,不如改变程序运行方式,使得攻击尝试最终导致程序崩溃。程序崩溃固然糟糕,但让攻击者执行任意代码显然更糟。因此,即使无法阻止攻击,有时能检测到攻击并让程序崩溃,也比任由攻击者为所欲为要好。

我们的目标是提高攻击者的攻击成本,使其更耗时、更费力,从而可能劝退一部分攻击者,或阻止那些技术不够娴熟的攻击者。

这类防御措施的一个普遍优点是成本低廉,通常不会显著降低程序性能或增加过多开销。虽然并非完全免费,但权衡之下,只需付出少量代价,就能阻止大量常见攻击,因此通常非常值得采用。

针对攻击链的防御

为了理解如何改变程序运行以阻止常见漏洞利用,我们需要回顾一下构建一次攻击所需的步骤。

以下是攻击者通常采取的步骤:

  1. 扫描代码,寻找内存安全漏洞(例如,发现调用了不安全的 gets 函数)。
  2. 将恶意代码(Shellcode)写入内存中一个已知的地址。
  3. 覆盖函数的返回地址(RIP),使其指向Shellcode的地址。
  4. 函数返回。
  5. 程序跳转到被覆盖的返回地址,开始执行恶意Shellcode。

对于第1步(漏洞本身)和第4步(函数返回),我们很难直接干预。漏洞一旦存在就存在,而函数返回是正常程序流程的一部分。

因此,我们的缓解措施将重点针对第2、3、5步。我们将分别研究三种缓解技术,每种技术旨在增加其中一个步骤的难度:

  • 增加将Shellcode写入已知内存地址的难度。
  • 增加用特定地址覆盖返回地址(RIP)的难度。
  • 增加执行Shellcode本身的难度。

通过实施这些缓解措施,我们可以使许多常见的漏洞利用变得更加困难。

总结

本节课我们一起学习了“漏洞利用缓解措施”的基本概念。我们了解到,在无法使用内存安全语言的情况下,可以通过技术手段针对攻击链的特定环节(如注入代码、篡改控制流、执行代码)设置障碍,从而显著增加攻击者的利用难度和成本。虽然这不能根除漏洞,但能有效提升软件的安全性基线,是一种性价比很高的防御策略。

065:非可执行页

在本节课中,我们将要学习一种名为“非可执行页”的内存安全缓解技术。我们将了解它的工作原理、如何设置内存权限,以及它为何能有效阻止某些类型的攻击。

攻击的第五步

上一节我们回顾了缓冲区溢出攻击的五个步骤。本节中,我们来看看如何针对其中的第五步——攻击者开始执行恶意shellcode——进行防御。我们的目标是让攻击者执行恶意代码变得非常困难。

我们将利用内存布局由四个独立区域组成这一事实来实现防御。这四个区域是:栈、堆、数据段和代码段。

内存区域的职责

以下是这四个内存区域在正常程序执行中的典型作用:

  • 代码段:存放程序的X86指令(即编译后的0和1序列),CPU读取并执行这些指令。
  • 数据段:存放全局变量。
  • :存放动态分配的数据。
  • :存放局部变量。

在正常执行中,只有代码段应该包含需要被CPU执行的程序指令。其他三个区域存放的是数据,而不是指令。

设置内存权限

基于上述职责,我们可以为每个内存区域设置权限位,规定其是否可执行或可写。

以下是关于可执行权限的分析:

  • 代码段:必须可执行,因为这里存放着需要运行的指令。
  • 数据段、堆、栈:不应可执行。这些区域存放的是数据,CPU不应尝试将此处的内容作为指令来执行。如果CPU试图执行这些区域的指令,操作系统应予以阻止。

以下是关于可写权限的分析:

  • 栈、堆、数据段:必须可写。程序需要在这些区域修改变量值或写入数据。
  • 代码段:应设为只读。程序在运行时通常不应修改自身的代码。你编写代码,然后运行它,而不是在运行时动态重写指令。

非可执行页的核心思想

综合以上权限设置,我们得到了“非可执行页”的核心思想:每个内存页可以被设置为可写或可执行,但不能同时具备这两种权限

具体来说:

  • 代码段:可执行,但不可写
  • 栈、堆、数据段:可写,但不可执行

这种权限分离是通过虚拟内存系统实现的。操作系统和硬件本就支持为内存页设置这些权限位,我们只需正确配置它们即可。这项技术有时也被称为 W^X(Write XOR Execute,即可写异或可执行)或 NX位

为何有效?

现在我们已经了解了非可执行页是什么,接下来看看它为何有效。让我们回到缓冲区溢出攻击的步骤。

攻击者的目标是:将shellcode写入内存,然后执行这段相同的代码

如果启用了非可执行页防御,攻击者将无法同时完成这两件事:

  1. 攻击者可以将shellcode写入内存(例如栈或堆),因为这些区域是可写的。
  2. 但是,当他们尝试跳转到该地址并执行时,操作系统会进行检查。由于这些区域被标记为不可执行,操作系统会阻止CPU执行其中的内容。

因此,即使攻击者成功植入了恶意代码,也无法执行它。这有效地阻断了攻击链的第五步。

总结

本节课中,我们一起学习了“非可执行页”这一内存安全缓解技术。我们了解到,通过合理设置内存区域的权限(代码段可执行不可写,栈、堆、数据段可写不可执行),可以阻止攻击者执行其注入的恶意shellcode。这项技术利用了操作系统已有的虚拟内存权限机制,能有效防御依赖于执行注入代码的攻击。

066:Return-to-libc 攻击概述 🛡️➡️📚

在本节课中,我们将要学习一种名为“Return-to-libc”的攻击技术。这种攻击旨在绕过“不可执行”内存页的保护机制。我们之前提到,不可执行页可以阻止某些内存安全攻击,但它并不能防御所有攻击。

上一节我们介绍了不可执行页如何阻止攻击者执行自己注入的代码。本节中我们来看看,如果攻击者利用内存中已经存在的代码,会发生什么。

攻击原理

程序运行时,内存中不仅包含程序自身的指令,还包含导入的库函数代码,例如C标准库。这些库代码位于标记为可执行的内存区域。因此,攻击者虽然无法执行自己写入的恶意代码,但可以尝试跳转并执行内存中已有的代码。

Return-to-libc攻击的核心思想正是利用这一点。攻击者不再注入自己的shellcode,而是通过篡改程序控制流,使其跳转到内存中已有的、具有危险功能的库函数(如system函数),并为其提供恶意参数。

以下是实现此攻击的两个关键步骤:

  1. 覆盖返回地址:通过缓冲区溢出等技术,覆盖栈上的返回地址,使其指向目标库函数(例如system)的地址。
  2. 布置函数参数:在栈上精心布置数据,使其在目标函数被调用时,被解释为该函数所需的恶意参数。

攻击示例分析

假设我们有一个存在缓冲区溢出漏洞的函数,并且系统启用了不可执行页保护。

void vulnerable_function() {
    char name[64];
    gets(name); // 危险函数,不检查输入长度
}

内存中已加载了C标准库,其中包含system函数。该函数接收一个字符串参数并执行它。

攻击者可以构造以下攻击载荷:

[ 填充64字节的垃圾数据 ][ 覆盖保存的帧指针 ][ system函数的地址 ][ 返回地址(任意值) ][ 参数字符串地址(指向"rm -rf /") ]

vulnerable_function返回时:

  • 程序会跳转到我们覆盖的返回地址,即system函数的地址。
  • system函数会从栈上读取其参数。根据调用约定,参数位于返回地址之后。因此,它会将我们布置的参数字符串地址(如指向"rm -rf /")作为命令执行。

这样,攻击者就成功地利用了内存中已有的system函数代码,执行了恶意命令,而无需注入任何可执行的shellcode。

总结

本节课中我们一起学习了Return-to-libc攻击。这种攻击通过覆盖返回地址指向内存中已有的库函数(如system),并在栈上布置恶意参数,从而绕过了不可执行内存页的保护。它揭示了仅依赖不可执行页并不足以保证内存安全,因为攻击者可以“借用”程序本身或库中的合法代码来达到恶意目的。在后续章节中,我们将看到在此思路上更复杂的攻击变种。

067:Return-to-libc攻击详解 🧠

在本节课中,我们将详细拆解一个名为“Return-to-libc”的攻击。这种攻击通过覆盖函数返回地址,劫持程序执行流,使其跳转到库函数(如 system)并执行恶意代码。我们将重点关注攻击的构造原理和栈布局的关键细节。

攻击概览 🎯

从高层次看,这次攻击的结构如下:

  • 我们覆盖了返回地址(RIP),使其指向 system 函数的地址。
  • 我们在栈上提供了一个恶意的参数。

攻击细节剖析 🔍

上一节我们介绍了攻击的整体思路,本节中我们来看看构造攻击时需要注意的两个关键细节。

细节一:参数传递方式

以下是关于参数传递的第一个细节:

  • 当传递参数时,我们传递的是一个字符串的地址,而不是字符串字面量本身。
  • 原因在于,在C语言中,字符串本质上是指向字符数组起始位置的指针。因此,我们不能直接传递 rm -rf 这些字符,而必须先将这些字符写入内存,然后传递指向该内存位置的地址。
  • 在攻击载荷中,这个蓝色的地址就是 system 函数将要寻找的参数。

细节二:栈上的“4个B”

第二个细节是栈上那四个字节的占位符(通常用 0x42424242BBBB 表示)。虽然它们不是攻击的核心,但经常被问到,因此我们在此解释。

为了理解其作用,我们需要回顾函数调用约定和栈帧变化。上一节我们介绍了参数传递,本节中我们来看看函数尾声(epilogue)和 system 函数的预期栈布局。

函数尾声与执行流劫持

首先,程序完成对 gets 的调用,ESP 上移,清理参数。接着进入被攻击函数的尾声,它包含三个标准指令:

  1. mov esp, ebp:将 ESP 上移到 EBP 的位置,销毁当前栈帧
  2. pop ebp:将栈顶的下一个值(保存的帧指针SFP)弹出到 EBP 中,ESP 随之再上移4字节。
  3. ret:这条指令的行为类似于 pop eip。它将栈顶的下一个值弹出,并将其作为地址开始执行代码

在我们的攻击中,ret 指令弹出的正是我们覆盖的返回地址——system 函数的地址。于是,EIP 指向 system,我们开始执行 system 的代码。

system 函数的预期

现在,我们站在 system 函数的角度思考。它认为自己是被正常调用的。根据函数调用约定,一个合法的 call system 指令会依次完成以下步骤:

以下是调用 system 函数时栈的预期布局:

  1. 调用者将参数压入栈中。
  2. 执行 call system 指令,该指令会将返回地址(RP) 压入栈中,然后跳转到 system 执行。

因此,system 函数期望在开始执行时,栈的布局是:先有参数,紧接着是返回地址(RP)

攻击造成的差异与“4个B”的作用

然而,我们的攻击并没有使用 call 指令。我们是通过覆盖返回地址直接跳转到 system 的。这导致栈的布局与 system 的预期不符:我们只提供了参数,却没有对应的返回地址。

为了让 system 函数能正确工作(即在其栈帧中找到正确位置的参数),我们需要“伪造”一个栈布局,使其符合它的预期。

这就是那“4个B”的作用:

  • 我们通过写入蓝色地址来“提供”参数。
  • 我们通过写入这四个字节(BBBB)来“伪造”一个返回地址(RP),从而欺骗 system 函数,让它以为栈上确实存在一个返回地址

这样,当 system 函数开始执行并建立自己的栈帧时,它就能在预期的位置(即“伪造的返回地址”之后)找到我们提供的参数地址。

总结 📝

本节课中我们一起学习了Return-to-libc攻击的构造细节。

  • 攻击的核心是覆盖返回地址使其指向库函数(如 system,并在栈上布置好对应的参数
  • 参数必须是指向字符串的指针地址
  • 栈上额外的“4个B”是为了满足被调用函数(system)对栈布局的预期,它伪造了一个不存在的返回地址,以确保函数能定位到我们提供的恶意参数。虽然这是一个重要细节,但请记住,攻击最本质的部分仍然是控制执行流和传递恶意参数。

068:ROP攻击概述 🧩

在本节课中,我们将要学习一种名为“面向返回的编程”的高级攻击技术。这是一种在存在不可执行内存页保护的情况下,通过复用程序中已有的代码片段来执行任意操作的方法。

从“返回到Libc”到ROP

上一节我们介绍了“返回到Libc”攻击,这是一种利用已存在于内存中的库函数(如 system)来绕过不可执行页保护的有效方法。因为它执行的是现成的代码,所以不受“不可写且不可执行”规则的限制。

然而,有时我们想要执行的恶意代码(shellcode)并非标准的C库函数。虽然像 systemexecve 这样的函数本身就很危险,但攻击者可能需要执行更复杂的、定制化的操作序列。这时,“返回到Libc”就显得能力有限了。

那么,如果我们想执行的代码不是任何现成的库函数,该怎么办呢?本节中我们来看看“面向返回的编程”。

什么是ROP?🔗

ROP可以看作是“返回到Libc”的升级版。其核心思想依然是跳转到库中并执行其中的指令,但不再是跳转到整个函数的开头(例如 system 函数的入口)并执行其全部逻辑。

相反,ROP攻击会跳转到库函数内部的许多不同位置,每次只执行一小段我们需要的指令序列,然后将这些片段像链条一样连接起来,最终组合成我们想要的完整shellcode。

换句话说,攻击者不需要自己向内存写入shellcode,而是从C库(或其他已加载的库)中“搜刮”出许多有用的小指令片段,并将它们组合起来。

核心概念:Gadget(代码片段)

这些从现有函数中“搜刮”出来的、有用的小指令片段,被称为 Gadget

  • 定义:一个Gadget就是一小段x86机器指令,通常只包含几条指令。
  • 关键特性:它们已经存在于内存中,因此攻击者无需写入,只需跳转到其地址即可执行。
  • 位置:Gadget通常位于某个函数的中间部分,而不是函数的开始。例如,你可能跳转到 system 函数中间的几条指令,执行完后再跳走。
  • 重要特征:Gadget通常以一条 ret 指令结尾。我们马上会看到 ret 指令为何如此关键。

简单来说,Gadget就是攻击者从程序现有代码中“淘”出来的、能完成特定微操作(如给寄存器赋值、进行算术运算、内存读写等)的代码块。

ROP的链条:ret指令的关键作用 🗝️

ROP攻击能够将多个Gadget串联起来执行,其核心机制依赖于 ret 指令的行为。在深入细节之前,我们先回顾一下 ret 指令的功能。

ret 指令的行为类似于 pop EIP。它的作用是:

  1. 从栈顶弹出一个值。
  2. 将这个值加载到指令指针寄存器(EIP)中。
  3. CPU随后开始从新的EIP地址处执行代码。

用公式描述其行为就是:

ret 等价于 pop EIP

执行后,ESP(栈指针)会增加,指向栈中的下一个位置。

ROP攻击链的工作原理

理解了 ret 之后,ROP的攻击链就清晰了:

  1. 攻击者通过缓冲区溢出等技术,完全控制栈的内容。
  2. 他们在栈上精心布置一系列数据,其中交替存放着Gadget的地址和一些供Gadget使用的参数
  3. 当程序执行到被覆盖的返回地址时,会第一次跳转到第一个Gadget的地址。
  4. 第一个Gadget执行其几条指令(例如,pop eax; ret),可能会从栈上“消费”掉一些数据作为参数。
  5. Gadget执行到最后,遇到 ret 指令。
  6. ret 指令会从当前栈顶弹出下一个值,而这个值正是攻击者预先放置的第二个Gadget的地址。于是,CPU跳转到第二个Gadget继续执行。
  7. 这个过程像“按按钮”一样重复:每个Gadget执行完毕时,都会通过 ret 指令自动从栈上取出并跳转到下一个Gadget的地址。

通过这种方式,攻击者就像编写了一个程序,但这个程序的“指令”是分散在内存各处的Gadget地址,“指令指针”的移动则由一连串的 ret 指令驱动。最终,这一连串Gadget的执行效果,就等同于攻击者想要的恶意shellcode。

总结

本节课中我们一起学习了面向返回编程(ROP)的基本概念。ROP是一种强大的代码复用攻击技术,它通过以下方式绕过现代操作系统的内存保护机制:

  • 核心:利用程序中已有的、以 ret 结尾的小段代码(Gadget)。
  • 机制:通过完全控制调用栈,将多个Gadget的地址像“返回地址”一样顺序排列在栈上。
  • 驱动:利用 ret 指令(pop EIP)的行为,自动、顺序地跳转到每一个Gadget并执行,从而将零散的代码片段串联成有逻辑的完整攻击载荷。

ROP攻击之所以有效,正是因为它巧妙地利用了程序自身的代码和正常的控制流指令(ret),完全避免了向内存注入任何新代码,从而绕过了数据执行保护(DEP/NX)。

069:面向返回编程示例 🧩

在本节课中,我们将学习面向返回编程的一个具体示例。我们将看到攻击者如何利用内存中已有的代码片段,在不可执行内存的保护机制下,组合执行他们想要的操作。

概述

当内存页被标记为不可执行时,攻击者无法直接写入并执行自己的恶意代码。面向返回编程是一种绕过此限制的技术。其核心思想是,攻击者不注入新代码,而是寻找内存中已存在的、以ret指令结尾的短代码序列,并将这些序列的地址按顺序排列在栈上,通过连续的ret指令跳转来“拼接”出预期的功能。

攻击目标与限制

假设我们的目标是执行一段由两条指令组成的“Shellcode”:

  1. mov 指令
  2. xor 指令

在不存在不可执行页保护的情况下,我们只需将这两条指令写入内存并跳转执行即可。但由于启用了不可执行页,我们无法同时将内存区域设置为可写和可执行。因此,我们不能直接写入并执行这些指令。

寻找可用的代码片段

为了解决这个问题,我们必须转向现有的代码库,例如C标准库函数。我们需要在其中找到已经存在于内存中的、我们需要的指令。

幸运的是,经过搜索,我们发现了以下情况:

  • 在函数F中,存在一个xor指令。
  • 在C库的bar函数中,存在一个mov指令。

这两个指令已经存在于内存中,因此它们所在的页面是可执行的。我们的目标就变成了:如何让程序先跳转到mov指令处执行,执行完毕后,再跳转到xor指令处执行。

构建ROP链

实现这一目标的方法是,在栈上构建一个地址链。关键在于,我们找到的每一个有用的小代码片段都必须以ret指令结尾。

ret指令的作用是:从栈顶弹出一个地址,并跳转到该地址执行。因此,如果每个代码片段都以ret结尾,那么当一个片段执行完ret时,它会自动从栈上取出下一个地址并跳转过去。

最终,我们的攻击载荷结构如下所示:

以下是攻击载荷在栈上的布局:

  • name字符数组(被溢出数据填充)
  • 旧的栈帧指针(被覆盖)
  • 返回地址:被覆盖为第一个想要执行的指令地址(即mov指令的地址)
  • 栈上更高地址处:存放第二个想要执行的指令地址(即xor指令的地址)
  • (以此类推,可以存放更多地址)

通过这种方式,我们不是跳转到自己写入的Shellcode,而是跳转到内存中已有的指令。在返回地址之上,我们按顺序写入后续想要执行的指令地址。

攻击过程演示

现在,让我们一步步跟踪程序的执行流程。

  1. 函数返回vulnerable函数执行完毕,开始执行其尾声代码。
  2. 恢复栈帧pop ebp指令执行,旧的ebp被恢复(实际上被我们覆盖的值填充)。
  3. 首次ret:执行ret指令。此时,由于我们覆盖了返回地址,栈顶的值是我们写入的mov指令的地址。ret指令会将该地址弹出并放入EIP,程序随即跳转到mov指令处执行。
  4. 执行第一个Gadgetmov指令执行,完成我们“Shellcode”的第一部分。紧接着,这个代码片段以ret指令结尾。
  5. 链式跳转:当ret指令执行时,它再次从栈顶取出下一个地址,这个地址正是我们预先放置的xor指令的地址。程序于是跳转到xor指令处。
  6. 执行第二个Gadgetxor指令执行,完成我们“Shellcode”的第二部分。

至此,我们成功地利用内存中已有的两个代码片段,按顺序执行了预期的操作。

扩展与注意事项

如果我们的目标“Shellcode”包含更多指令,原理完全一样。只需在栈上按顺序放置更多代码片段的地址即可。每个片段执行完毕后,其结尾的ret指令会自动将控制权传递给栈上下一个地址所指向的片段。

一个常见的问题是:如果找到的代码片段不以ret结尾怎么办?或者我们需要的指令本身不在一个以ret结尾的序列里怎么办?

确实存在更复杂的技术来处理这种情况,例如通过精心构造栈上数据来模拟参数传递或调整寄存器状态,但这超出了本课程的基础范围。需要了解的是,最基本的ROP攻击形式要求所有“gadget”都以ret结尾,这种形式在实践中很常见且有效。更复杂的变体虽然存在,但本课程要求掌握的是这种基础的、每个gadget都以ret结尾的版本。

总结

本节课中,我们一起学习了面向返回编程的一个基础示例。我们了解到,在不可执行内存的保护下,攻击者可以通过以下步骤实施攻击:

  1. 在现有可执行代码中寻找以ret结尾的、有用的短指令序列。
  2. 通过缓冲区溢出等手段,覆盖栈上的返回地址,将其指向第一个gadget的地址。
  3. 在栈上更高处,按执行顺序依次放置后续gadget的地址。
  4. 利用ret指令的“弹出-跳转”特性,实现一个gadget到另一个gadget的链式执行,从而组合出复杂的恶意功能。

这种方法的核心在于复用已有的代码片段,而非注入新代码,从而巧妙地绕过了不可执行内存的保护机制。

070:ROP攻击的影响与应对

在本节课中,我们将探讨不可执行页(NX)防御机制被绕过后的影响,特别是针对返回导向编程(ROP)攻击的讨论。我们将了解ROP攻击如何工作,以及为什么即使启用了NX保护,攻击者仍然可能执行恶意代码。

ROP攻击的基本原理

上一节我们介绍了不可执行页(NX)作为一种防御机制。本节中我们来看看当攻击者使用ROP技术时,这种防御如何被绕过。

ROP攻击的核心思想是:攻击者不直接注入并执行自己的代码,而是利用程序中已有的代码片段(称为“gadgets”),通过精心构造的返回地址链,让程序执行攻击者期望的操作。每个gadget通常以ret指令结尾,这使得攻击者可以像“编程”一样,将多个gadget串联起来。

攻击流程公式可概括为:

控制栈指针 -> 指向一系列gadget地址 -> 每个gadget执行后ret到下一个 -> 最终达成恶意目标

ROP攻击的可行性条件

以下是ROP攻击能够成功实施的关键条件:

  1. 存在足够多的gadgets:目标程序或其所链接的库需要包含大量短小的、以ret结尾的指令序列。代码库越庞大,存在可用gadgets的可能性就越高。
  2. 能够控制程序流:攻击者必须能够劫持控制流,例如通过缓冲区溢出覆盖返回地址。
  3. 能够操作栈内存:攻击者需要能够向栈中写入数据,以布置gadget地址链和必要的参数。

ROP攻击的自动化演变

最初,ROP是一种需要深入研究和手动构造的高级攻击技术。然而,安全社区的研究使其逐渐变得自动化。

如今,攻击者甚至可以在网上找到“ROP编译器”。这些工具的工作流程如下:

  1. 输入期望的恶意代码逻辑(shellcode)。
  2. 工具自动在目标程序的二进制文件中搜索可用的gadgets。
  3. 工具生成一个ROP链,即一串gadget的地址序列。
  4. 攻击者只需将这个地址链布置到栈上,即可实现自动化攻击。

对NX防御的评估与总结

综合以上讨论,我们可以对不可执行页(NX)防御机制做出如下评估:

  • 有效性:NX能成功阻止最常见的攻击形式,即攻击者直接将shellcode写入栈或堆并跳转执行。对于“懒惰的”攻击者,这是一道有效的屏障。
  • 局限性:NX无法阻止像ROP这样巧妙的攻击。攻击者通过复用程序已有的可执行代码,完全绕过了“页面不可执行”的限制。
  • 成本与收益:启用NX的代价很低(通常只需编译器或操作系统的一个标志位),并能阻挡一大批基础攻击,提升了攻击门槛。
  • 定位:因此,NX不能被视为完美的终极防御方案。它是一层有效的缓解措施,使得某些常见漏洞利用变得更为困难,但不能单独依赖它来保证绝对安全。

本节课中我们一起学习了ROP攻击如何绕过NX保护。我们了解到,当程序代码库庞大时,攻击者可以利用其中现成的代码片段(gadgets)组合出任意功能。尽管NX防御能阻止简单的代码注入攻击,但对于ROP这类高级技术,它只能增加攻击难度,而无法完全杜绝。这体现了深度防御思想的重要性,即需要组合多种安全机制来应对不同层次的威胁。

071:栈金丝雀类比 🐦

在本节课中,我们将学习第二种内存安全防护机制——栈金丝雀。这种机制旨在增加攻击者利用缓冲区溢出漏洞的难度,但并非完全杜绝攻击。我们将了解其名称的由来、核心思想以及它如何保护程序。

上一节我们介绍了不可执行页防护,它主要针对攻击链的第五步(执行恶意代码)。本节中,我们来看看栈金丝雀,它将目标对准了攻击的第三步——覆盖返回地址。

名称的由来 🏔️

在深入技术细节之前,我们先了解“栈金丝雀”这个名称背后的故事。据说,在很久以前的采矿时代,矿工们会在矿井中携带一只金丝雀。

金丝雀是一种会发出响亮叫声的小鸟。矿井中可能积聚有毒气体,这对矿工是致命的。由于金丝雀对有毒气体非常敏感,一旦气体开始积聚,金丝雀就会开始不安地鸣叫,甚至可能死亡。

矿工们工作时,如果发现金丝雀倒下,这就是一个明确的信号,表明有毒气体正在泄漏,所有矿工必须立即撤离。

在这个故事中,金丝雀本身并不被期望能存活下来。它是一个“牺牲品”,被带入矿井的目的不是让它生存,而是让它作为预警信号,保护更有价值的矿工生命。

栈金丝雀的核心思想 💡

我们将同样的思路应用到程序栈的保护上。在程序中,栈金丝雀是一个“牺牲值”。

以下是栈金丝雀的核心概念:

  • 它本身没有实际意义,程序逻辑不会使用它。
  • 我们并不关心它的具体值是什么。
  • 它的存在就像矿井中的金丝雀。如果这个值被意外或恶意地改变了(“倒下”了),那就发出了一个明确的警告信号,表明栈上的数据可能遭到了破坏(例如发生了缓冲区溢出)。
  • 这个警告给了程序一个机会,可以在造成更大损害(如执行攻击者代码)之前安全地崩溃或终止,从而保护真正有价值的数据和代码流。

虽然原故事有些令人伤感,但栈金丝雀技术本身是一种非常巧妙且有效的防护手段。接下来,我们将具体看看它在栈上是如何布局和工作的。

总结 📝

本节课我们一起学习了栈金丝雀防护机制。我们了解了其名称来源于矿工用金丝雀预警有毒气体的历史典故,并理解了其核心思想:在栈上放置一个无意义的“牺牲值”,通过检查该值在函数返回前是否被改变,来探测是否发生了缓冲区溢出攻击。这为目标攻击链的第三步(覆盖返回地址)增加了难度。下一节,我们将具体分析栈金丝雀在内存中的布局和工作原理。

072:金丝雀属性 🐤

在本节课中,我们将学习栈金丝雀(Stack Canary)的工作原理及其关键属性。栈金丝雀是一种内存安全防御机制,用于检测和防止缓冲区溢出攻击。我们将详细探讨它的位置、属性以及如何在实际程序中发挥作用。


栈金丝雀的工作原理

上一节我们介绍了栈金丝雀的基本概念,本节中我们来看看它在实际程序中的具体表现。

当构建一个新的栈帧时,也就是在调用新函数并创建包含返回地址(RIP)、保存的帧指针(SFP)等内容的栈帧时,会在栈上添加一个“牺牲值”。这个值不被程序使用,既不是需要关心的局部变量,也不是保存的指针值,而是一个完全牺牲性的值,理论上不应被任何人使用。

在函数调用时,系统会生成一个随机值(具体值无关紧要)并将其放置在栈上。随后,在函数返回或函数尾声(epilogue)阶段,系统会返回检查该值是否发生变化。如果值保持不变,则一切正常;如果值发生改变,则表明可能存在异常情况。

由于这个牺牲值本不应被程序触及,如果在函数尾声阶段观察到金丝雀值发生变化,就像“金丝雀倒下死亡”一样,意味着不应改变的内存部分被修改了。这是一个信号,表明可能有人正在干扰程序,此时系统应警告用户并终止程序,以防止进一步损害。

金丝雀值从不被使用,因此如果有人更改了它,系统很可能遇到了麻烦。关于如何检查值是否未改变,操作系统会在其内核中存储金丝雀的副本。如果感兴趣,可以学习操作系统课程以了解更多细节。在本课程中,我们只需关心将金丝雀值放在栈上,并在函数返回时检查其是否改变。如果改变,则表明发生了异常情况。


栈金丝雀的关键属性

以下是栈金丝雀值的一些重要属性:

  1. 随机生成:金丝雀值通常是随机生成的,具体值并不重要。不过,在本课程中,我们假设每个栈帧的金丝雀值相同。这意味着如果调用一系列嵌套函数,每个栈帧都会使用相同的金丝雀值。这主要是为了方便,但不同操作系统可能有不同的实现方式。基本上,在同一程序运行期间构建的所有栈帧都具有相同的金丝雀值。

  2. 每次运行变化:相比之下,如果程序运行10次,应该会看到10个不同的金丝雀值。这一点非常重要,因为如果每次使用相同的金丝雀值,攻击者只需运行一次程序记录下该值,第二次运行时就能知道这个秘密值,这样就不安全了。为了防止攻击者,必须在每次程序运行时更改金丝雀值。因此,如果尝试记录金丝雀值并在下次运行时使用,将不会成功,因为金丝雀值每次都会变化。

  3. 包含空字节:栈金丝雀的另一个有用属性是通常包含一个空字节(00)。这有助于缓解基于字符串的攻击。虽然这与金丝雀的原始目的(检查程序是否被篡改)不同,但人们经常出于完全不同的原因添加空字节。原因在于许多函数(如strcpy或使用%sprintf)处理字符串时,C语言通过检查空字节来确定字符串的结束。如果栈上没有空字节,当程序尝试以字符串形式读取或打印数据时,可能会一直读取到栈顶而无法停止,因为它从未遇到空字节。相反,如果在金丝雀中插入空字节,当从栈底向上逐行读取时,最终会遇到空字节,从而在造成更多损害之前停止字符串攻击。这不是金丝雀的主要目的,但如果你在金丝雀中看到空字节,通常是为了阻止基于字符串的缓冲区溢出或至少减少其损害。


栈金丝雀的位置示例

让我们通过一个简单的示例来了解栈金丝雀的位置。虽然不需要阅读具体代码,但本幻灯片上的图片显示了金丝雀在栈帧中的位置。

当构建新的栈帧时,构建方式与往常相同:压入参数(图中未显示)、压入返回地址(RIP)、压入保存的帧指针(SFP)。但在开始创建栈帧、添加局部变量并为新函数分配空间之前,会在栈上放置这个额外的值,即秘密的金丝雀值。

因此,函数序言(prologue)照常进行,但在创建名称缓冲区或新栈帧中的任何其他内容之前,会在栈上添加新的金丝雀值。它位于那里,保护程序免受攻击。

函数执行其所需操作后,在尾声阶段清理栈帧并将栈恢复到原来位置时,会检查该值是否发生变化。如果金丝雀值改变,则表明发生了异常情况,需要触发恐慌、终止程序并警告用户。如果金丝雀值保持不变,则一切正常,程序可以继续执行,未检测到攻击。

你可能会问,为什么不把金丝雀放在其他位置?例如,为什么不把它作为压栈的第一个元素,或者放在名称缓冲区附近?原因在于,将金丝雀放在局部变量和保存的寄存器之间,可以保护保存的寄存器。如果攻击者尝试经典的缓冲区溢出攻击,覆盖名称缓冲区、SFP和RIP,他们必须从局部变量开始写入,经过SFP,并覆盖金丝雀。通过将金丝雀放在局部变量和保存的寄存器之间,攻击者在尝试从局部变量写入保存的寄存器时必须跨越金丝雀,如果跨越金丝雀,它会被破坏并触发异常。因此,将金丝雀放在中间是故意的,有助于阻止我们迄今为止看到的经典缓冲区溢出攻击。


栈金丝雀的性能影响

对于所有内存安全防御措施,我们都会问一个问题:栈金丝雀的效率如何?如果启用这种额外防御,会对程序性能产生多大影响?

启用栈金丝雀确实会对性能产生一些影响,因为每次调用函数时都需要额外的工作:每次调用函数时都必须添加金丝雀,每次函数返回时都必须检查金丝雀。因此,确实存在一些额外的工作量。但说实话,在大多数应用程序中,这种影响并不明显。当你运行一段代码时,如果添加一行额外的代码,可能不会使程序明显变慢,尤其是当这行代码是写入随机值或检查随机值这样的操作时,这些并不是昂贵的指令。因此,虽然存在一些性能影响,但在几乎所有我能想到的应用程序中,你都不会注意到它。所以,这是一种在性能方面几乎免费的防御措施,但会使攻击者的工作变得更加困难,因此我们喜欢它。


总结

本节课中我们一起学习了栈金丝雀的工作原理及其关键属性。栈金丝雀通过在栈上放置一个随机生成的牺牲值,并在函数返回时检查其是否改变,来检测缓冲区溢出攻击。它的属性包括随机生成、每次运行变化以及通常包含空字节以缓解字符串攻击。金丝雀被放置在局部变量和保存的寄存器之间,以有效阻止经典缓冲区溢出攻击。虽然启用栈金丝雀会带来轻微的性能开销,但在大多数应用程序中这种影响可以忽略不计,因此它是一种高效且实用的内存安全防御机制。

073:绕过栈金丝雀防御 🛡️

在本节课中,我们将学习栈金丝雀(Stack Canary)这种防御机制。虽然它能有效阻止经典的缓冲区溢出攻击,但攻击者仍有多种方法可以绕过它。我们将逐一探讨三种主要的绕过策略:泄露金丝雀值、完全避开金丝雀以及暴力猜测金丝雀值。

上一节我们介绍了栈金丝雀如何防御经典的缓冲区溢出攻击。本节中,我们来看看攻击者如何设法绕过这一防御。

泄露金丝雀值 🔓

第一种策略是泄露金丝雀值。如果程序存在内存安全漏洞,允许攻击者访问本不应访问的内存区域,那么攻击者就有可能获知金丝雀的值。

例如,利用格式化字符串漏洞可以打印出栈上的值。如果攻击者能够打印栈上的内容,就可能打印出金丝雀值。攻击者可以这样做:先泄露金丝雀值,然后在构造攻击载荷写入内存时,当写到金丝雀所在位置时,不写入破坏性的数据(如AAAA),而是写入原始的金丝雀值。这样,程序检查金丝雀时会发现其值未被改变,从而绕过检测。

核心要点:这一切必须在程序的单次运行中完成。因为每次程序重启时,金丝雀值都会改变。你不能在一次运行中记下值,然后在另一次运行中使用。

避开金丝雀 🚷

第二种策略是避开金丝雀。栈金丝雀擅长阻止那些从低地址向高地址连续写入的攻击(例如使用getsstrcpy的函数)。因为在这类攻击中,要覆盖返回地址(RIP),就必须覆盖途中的金丝雀。

然而,并非所有攻击都需要连续写入内存。有些漏洞允许攻击者“绕过”金丝雀进行写入。

以下是此类攻击的例子:

  • 格式化字符串漏洞:使用%n格式化符可以向内存中任意指定地址写入数据,完全不需要覆盖金丝雀。
  • 堆溢出:如果在堆上进行溢出,根本不会触及栈上的金丝雀。即使金丝雀开启,攻击者仍可能覆盖堆上的关键变量(例如一个认证标志)。

这表明,栈金丝雀能防御常见的连续溢出攻击,但对于更精巧的、能够绕过金丝雀写入的攻击则无能为力。

猜测金丝雀值 🎯

最后一种策略是最直接的:猜测金丝雀值。如果金丝雀是随机值,你可以不断尝试猜测。虽然第一次猜中的概率很低,但尝试足够多次后,就有可能成功。

攻击方式如下:在构造攻击载荷时,当写到金丝雀位置时,填入你的猜测值,然后继续写入后续内容。如果猜对了,程序会认为金丝雀未被篡改。

那么,猜测的可行性如何?这完全取决于威胁模型

可行性分析

  • 32位系统:金丝雀通常为32位(4字节),其中8位是固定的“哨兵字节”(nul byte),剩下24位是随机的。猜中的概率约为 1/(2^24),即大约1600万分之一。
  • 64位系统:金丝雀为64位(8字节),固定8位后,剩下56位随机位,猜中的难度极大。

环境因素

  • 本地环境:在自己的电脑上可以无限制地尝试。
  • 远程服务器:服务器可能会检测到大量异常请求(如尝试1600万次)并阻止你,例如断开连接或封禁IP。

增强防御:程序可以实施反制措施,极大增加猜测难度:

  • 超时机制:每次验证失败后强制等待一段时间。
    • 举例:若无超时,假设每秒可尝试1万次,破解24位金丝雀约需30分钟。若每次失败后增加仅0.1秒超时,破解时间将延长至3周
  • 递增超时:每次失败后等待时间翻倍,这将使攻击在实际上变得不可能完成。

正如课程所提及,在配套的实践项目(Project 1)中,你并没有足够的时间去暴力猜测金丝雀值。


本节课总结:我们一起学习了三种绕过栈金丝雀防御的方法:泄露避开猜测。栈金丝雀是一种有效的防御手段,但并非无懈可击。其有效性高度依赖于具体的漏洞类型、系统架构(32/64位)以及运行环境(本地/远程)。安全始终是一个攻防对抗的动态过程,理解攻击原理是构建更坚固防御的第一步。

074:指针认证 🔐

在本节课中,我们将要学习第三种内存安全防御机制——指针认证。我们将了解它如何工作,它与栈金丝雀的相似之处,以及它为何能提供更强的保护。

从栈金丝雀到指针认证

上一节我们介绍了栈金丝雀,本节中我们来看看一种更强大的机制:指针认证。它的核心思想与栈金丝雀类似,都是通过检测内存中的秘密值是否被篡改来防御攻击,但实现方式更加巧妙和强大。

利用64位系统的地址空间

首先,我们需要理解现代64位系统的一个关键特性。在32位系统中,地址长度为32位,可寻址约4GB内存。而在64位系统中,地址长度为64位,理论上可寻址高达180亿GB的内存。

然而,现实中没有任何计算机拥有如此巨大的物理内存。这意味着,在64位地址中,高位的许多比特(例如最高的22位)在实际使用中几乎总是为零。即使访问系统可寻址的最高内存地址,其高位也由零填充。

指针认证的核心思想

既然这些高位比特未被使用,我们为何不利用它们呢?指针认证正是基于这个想法。它的工作原理如下:

  1. 嵌入秘密值:当程序将一个地址(指针)存入内存(栈、堆或静态内存)时,系统会将该地址中未使用的高位比特(例如22位)替换为一个秘密的认证码。
  2. 验证秘密值:当程序后续尝试使用这个地址(例如解引用或跳转)时,系统会检查这些高位比特中的认证码是否有效。
  3. 执行或崩溃:如果认证码有效,系统会将这些比特重置为零,然后正常访问该地址。如果认证码无效(表明指针可能被攻击者篡改),程序会立即崩溃。

本质上,指针认证为内存中的每一个指针都配备了一个“金丝雀”,而栈金丝雀仅为每个函数配备一个。这使得防御粒度更细,覆盖范围更广。

指针认证的优势

以下是指针认证的一些关键特性,使其比简单的栈金丝雀更强大:

  • 每个指针拥有唯一认证码:系统可以为不同的地址生成不同的秘密认证码。这意味着攻击者无法从一个指针“窃取”认证码并用于伪造另一个指针。
  • 基于密码学:认证码的生成通常基于密码学原理(如消息认证码),使得攻击者在不知道系统主密钥的情况下,几乎不可能伪造有效的认证码。
  • 广泛的覆盖范围:此机制可应用于内存中任何地方的指针,而不仅仅是栈上的返回地址。

指针认证的局限性与现状

尽管非常强大,指针认证也并非完美无缺:

  • 可能的信息泄露:理论上,攻击者可能通过某些手段诱骗程序泄露特定地址的认证码,或推断出生成认证码的主密钥。
  • 架构依赖性:目前,指针认证主要在现代ARM架构(如苹果的芯片)中得到硬件支持。x86和RISC-V等架构在制作本课程时尚未原生支持此功能。因此,它的应用受到处理器架构的限制。
  • 实现复杂度:它需要在汇编语言或编译器层面进行支持,并非所有编程环境都能直接使用。

尽管如此,与之前讨论的简单防御相比,绕过指针认证的难度极大,它代表了当前防御缓冲区溢出等内存安全漏洞的先进技术。

总结

本节课中我们一起学习了指针认证机制。我们了解到它如何巧妙地利用64位地址中未使用的高位比特来存储秘密认证码,从而为内存中的每一个指针提供保护。虽然它存在一定的架构依赖性和理论上的攻击面,但指针认证无疑是一种极其强大的内存安全防御工具,能显著增加攻击者实施缓冲区溢出攻击的难度。

075:ASLR概述

在本节课中,我们将要学习最后一种内存安全缓解技术——地址空间布局随机化(ASLR)。我们将了解ASLR如何通过随机化内存布局来增加攻击者利用缓冲区溢出漏洞的难度。

攻击步骤回顾

上一节我们介绍了通过栈金丝雀来防止返回地址被覆盖。本节中,我们来看看如何针对攻击流程中的第二步——攻击者将Shellcode写入已知地址——进行防御。

下图展示了典型的攻击步骤:

  1. 攻击者将Shellcode写入内存中的某个位置。
  2. 攻击者需要知道这个位置的地址。
  3. 攻击者用这个地址覆盖返回指令指针(RIP)。
  4. 程序执行流跳转到Shellcode并执行。

ASLR的目标是让攻击者无法准确预测Shellcode的存放地址,从而破坏第二步。

内存布局的现状

为了理解ASLR的动机,让我们先看看程序在内存中的实际布局。我们通常将内存划分为四个部分:栈、堆、数据段和代码段。

然而,在现实中,程序并不会使用全部的地址空间。例如,在32位系统上,总地址空间为4GB,但程序实际使用的内存远小于这个值。在64位系统上,未使用的空间更是巨大。因此,大部分地址空间实际上是空白的。

这种现状带来了一个关键问题:既然程序没有使用这些空白空间,我们是否可以改变栈、堆、数据段和代码段在地址空间中的位置?

ASLR的核心思想

答案是肯定的,这正是ASLR的核心思想。ASLR利用了大量地址空间未被使用的事实,在每次程序运行时,随机地调整各个内存段(栈、堆、数据段、代码段)的起始地址。

以下是ASLR可能产生的几种内存布局示例:

  • 布局A:所有段紧密排列在低地址区域。
  • 布局B:所有段整体向上移动。
  • 布局C:各个段被随机打散放置在不同的地址区域。

只要保证栈仍能向下增长、堆仍能向上增长,程序的运行就不会受到影响。但关键的变化是,所有内存地址都变得不可预测了

ASLR如何阻止攻击

ASLR使得经典的缓冲区溢出攻击难以奏效。回顾最初的攻击方式:攻击者将Shellcode写入缓冲区,然后用Shellcode的地址覆盖RIP。

在启用ASLR后,Shellcode在内存中的地址每次运行都会改变。攻击者无法知道本次运行中应该使用哪个地址来覆盖RIP,从而使得攻击失效。

需要强调的是,ASLR随机化的是整个内存段的起始地址,而不是段内部数据的相对位置。例如,栈帧的结构(RIP在上,然后是SFP,最后是局部变量)保持不变,只是整个栈区域被平移到了一个随机的基地址上。内部的相对偏移关系是稳定的,否则程序将无法正常运行。

性能影响

我们照例分析一下ASLR的性能开销。ASLR的性能开销非常低,几乎可以忽略不计。

这主要是因为现代操作系统的虚拟内存管理机制本身就在做类似的事情(例如将内存页映射到物理内存的不同位置)。启用ASLR只是在此基础上增加了一层随机化,并不会引入显著的额外负担。因此,通常建议始终开启ASLR以获得安全收益。

总结

本节课中我们一起学习了地址空间布局随机化(ASLR)。ASLR通过每次程序运行时随机化内存中栈、堆、数据段和代码段的基地址,使得攻击者难以预测特定数据(如Shellcode)的绝对地址,从而有效防御了依赖于固定地址的经典缓冲区溢出攻击。同时,由于它与操作系统底层机制协同工作,其性能开销极小,是一种高效且重要的安全缓解措施。

076:绕过ASLR

在本节课中,我们将学习地址空间布局随机化(ASLR)的两种主要绕过方法:地址猜测与地址泄露。我们将通过具体的例子来理解攻击者如何利用这些技术来定位内存中的关键数据。

概述

上一节我们介绍了ASLR,它通过随机化内存段的绝对地址来增加攻击难度。然而,其相对地址保持不变。本节中我们来看看攻击者如何通过“猜测”或“泄露”地址来绕过ASLR的保护。

绕过方法一:地址猜测

第一种方法是直接猜测目标地址。虽然ASLR随机化了地址,但攻击者可以进行多次尝试。

以下是地址猜测可行的几个原因:

  • 地址对齐:内存地址通常以特定边界对齐。例如,返回地址(RIP)通常是4字节的倍数。这减少了需要猜测的地址空间。
  • 页面对齐:从操作系统层面看,地址也常与内存页边界对齐,这进一步缩小了猜测范围。

尽管如此,猜测仍然需要大量尝试。在32位系统上,可能需要猜测大约220次;在64位系统上,猜测次数可能高达236次。其可行性取决于具体的威胁模型。

绕过方法二:地址泄露

第二种更有效的方法是泄露一个已知的栈地址。由于ASLR只改变绝对地址而不改变相对布局,一旦获得一个地址,就可以推算出栈上其他所有位置的地址。

例如,如果泄露了某个栈帧的帧指针(SFP)地址,那么:

  • 返回地址(RIP)通常位于 SFP地址 + 4
  • 局部变量可能位于 SFP地址 - N(N取决于变量大小和布局)。

地址泄露实例分析

为了更好地理解地址泄露,让我们看一个具体的代码漏洞例子。

以下是一段存在漏洞的C代码示例:

void vulnerable() {
    char buff[64];
    // 假设攻击者可以控制buff的内容
    printf(buff); // 危险!格式化字符串漏洞
}

![](https://github.com/OpenDocCN/sec-notes-zh/raw/master/docs/ucb-cs161-comp-sec-25/img/9e561039072a8cb3027a4df1bc81e3a1_1.png)

int main() {
    vulnerable();
    return 0;
}

这段代码的问题在于,printf 的第一个参数(格式化字符串)直接使用了用户控制的 buff。这导致了格式化字符串漏洞。

攻击者可以输入 %x 作为 buff 的内容。printf 会将其解释为格式说明符,并尝试从栈上读取本应是下一个参数的数据进行打印。

假设在调用 printf(buff) 时,栈布局简化如下:

栈地址(示例) 内容
... ...
0xbfff040c 返回地址 (RIP)
0xbfff0408 上一个栈帧的帧指针 (SFP)
0xbfff0404 局部变量 secret
0xbfff0400 buff 数组起始地址
... ...

当攻击者输入 %x 时,printf 会打印出栈上“第一个参数”位置的值,即 0xbfff0408 地址处存储的内容(也就是SFP的值)。假设打印出的值是 0xbfff0408

通过这个泄露的地址,攻击者可以进行如下推算:

  • 泄露的地址是:0xbfff0408(SFP的地址)
  • 因此,返回地址(RIP)的地址大约是:0xbfff0408 + 4 = 0xbfff040c
  • 局部变量 secret 的地址大约是:0xbfff0408 - 4 = 0xbfff0404
  • 缓冲区 buff 的地址可以根据偏移量进一步计算。

即使程序下次运行时栈的基地址变成了 0xcfff0000,这些相对偏移关系依然保持不变。攻击者只需泄露一个地址,就能计算出所有关键数据的当前位置。

总结

本节课中我们一起学习了绕过ASLR的两种主要技术。

  1. 地址猜测:依赖于地址对齐特性进行暴力尝试,成功率受限于地址空间大小。
  2. 地址泄露:利用如格式化字符串之类的漏洞,获取栈上的一个已知地址,进而利用固定的相对偏移推算出所有目标地址。这是一种更精确、更常用的方法。

理解这些绕过技术有助于我们认识到,ASLR虽然是一项强大的防御措施,但并非无懈可击,需要与其他安全机制结合使用。

077:组合防御措施

概述

在本节课中,我们将要学习如何组合使用多种内存安全防御措施,以增强程序的安全性。我们将探讨不同防御措施如何协同工作,以及为什么组合使用它们能产生比单独使用更强的保护效果。


组合防御措施的优势

上一节我们介绍了多种独立的内存安全防御机制。本节中我们来看看如何将它们组合使用。

组合多种防御措施能产生协同效应。这意味着整体防护效果大于各部分效果之和。启用一种防御措施很好,启用另一种也很好,但将它们结合起来会更好。

其根本原因在于,攻击者现在必须同时突破所有启用的防御措施,才能成功利用程序漏洞。这体现了纵深防御的安全原则,即针对同一攻击部署多层防御。

以下是组合防御措施的核心优势:

  • 攻击者需要克服的障碍成倍增加。
  • 单一防御措施的弱点可能被其他措施弥补。
  • 显著提高了攻击的复杂性和成本。

实例分析:ASLR 与非可执行页的组合

让我们通过一个具体例子来理解组合防御的威力:同时启用地址空间布局随机化(ASLR)和非可执行页(NX)。

如果只启用非可执行页,它会阻止攻击者写入并执行自己的shellcode。攻击者可能会转而尝试返回至libc攻击,利用内存中已存在的代码。

然而,如果同时启用了ASLR,攻击者将无法获知C库函数在内存中的具体地址,因为ASLR将这些地址随机化了。

因此,攻击者必须同时做到以下两点才能成功:

  1. 通过某种方式绕过ASLR,获取地址随机化的信息。
  2. 使用如返回至libc或面向返回编程等攻击技术来绕过非可执行页

与没有任何防御措施的系统相比,攻击者面临的难度大大增加。


现代系统中的实践

现代操作系统默认启用了多种防御措施。例如,Apple iOS 就同时开启了多项缓解措施。

这意味着一个能在当今系统上成功的漏洞利用,往往需要利用Safari浏览器的一个漏洞,再连锁利用另一个漏洞,如此层层递进。这表明,当多种防御措施组合使用时,现代攻击必须非常复杂精巧才能绕过所有防护。

核心要点:组合防御措施极大地增加了攻击者的难度,但并非不可能。漏洞利用仍然存在,只是门槛变得极高。


重要提醒:启用你的防御措施

在结束本节之前,有一个看似简单却至关重要的提醒:如果你拥有这些强大的防御机制,请务必确保它们已被启用。

这一点听起来显而易见,但实践中容易被忽略。例如,在某些系统上,你需要手动为编译器设置标志来启用栈金丝雀和ASLR。

gcc -fstack-protector -pie -fPIE your_program.c -o your_program

如果开发者不了解这些缓解措施,可能会忘记启用它们,从而使所有安全设计形同虚设。

考虑到人为因素,许多现代系统已将这些防御措施设为默认启用。这样,即使是不太了解底层安全机制的开发者,其程序也能自动获得基础保护。

请记住:务必确认并启用你的防御措施,否则我们讨论的所有内容都将失去意义。


总结

本节课中我们一起学习了内存安全防御措施的最终章——组合使用。

我们首先回顾了内存安全问题存在的根源是历史遗留代码。然后探讨了在必须使用C等非内存安全语言时,如何通过严谨推理、防御性编程等方法来编写安全代码,但这高度依赖开发者的自律。

接着,我们介绍了构建安全系统的工具,如自动代码分析、补丁管理和测试。

之后,我们深入讨论了四种主要防御措施:

  1. 非可执行页:阻止写入并执行shellcode,但可通过返回至libc等技术绕过。
  2. 栈金丝雀:在栈上插入随机值检测缓冲区溢出,但可能被绕过、泄露或猜测。
  3. 指针认证:类似于为每个指针加上“金丝雀”,利用64位系统中的空闲位,是栈金丝雀的强化版。
  4. 地址空间布局随机化:随机化内存布局,使攻击者难以预测地址,但可能通过地址泄露或猜测被绕过。

最后,我们重点讲解了组合使用这些防御措施的理念。通过实施纵深防御,可以产生协同效应,使攻击者必须同时突破所有防线,从而将攻击难度提升数个数量级。

关于内存安全的全部内容到此结束。接下来,我们将开始进入密码学的新领域。

078:课程介绍与警告

在本节课中,我们将开始学习密码学,这是本课程的第二个主要单元。我们将从基本定义和历史背景入手,为后续学习奠定基础。

什么是密码学?🔐

上一节我们介绍了本单元的主题,本节中我们来看看密码学的核心定义。

密码学是研究在不安全信道上进行安全通信的学科。具体来说,当通信双方(例如Alice和Bob)需要通过一个可能被攻击者监听或篡改的渠道进行交流时,密码学提供了确保消息机密性完整性的方法。

课程内容与数学要求 📐

在深入细节之前,我们需要了解本部分课程的特点。

本单元可能是整个课程中数学内容最多的部分。我们会涉及一些数学概念,但会尽力回顾所有必要的前提知识。如果你是数学爱好者,这部分内容可能会让你特别感兴趣。

重要警告:请勿自行尝试 🚫

在介绍第一个密码学基础知识之前,我们必须发出一个重要警告:请不要在家尝试(自行实现密码学协议)

本课程的目标是让你成为密码学的“明智消费者”,而非“创造者”。以下是具体原因:

  • 目标:我们将教你足够的知识,以便你能理解相关讨论、评估现有工具、了解它们的能力与局限。
  • 风险:密码学涉及精密且脆弱的数学。即使一个微小的错误也可能导致整个系统崩溃,代码变得完全不安全。
  • 现实建议:在实际工作中,除非你从事密码学研究,否则你不应该自己编写密码协议。你应该使用他人构建的、经过充分验证的库,因为这些库的开发者已经考虑了所有边界情况和安全隐患。

为了说明自行实现密码学的风险,这里有一个真实的故事:

几年前,本课程的一些学生在疫情期间尝试为在线考试等项目编写自己的密码学代码。他们在代码中引入了一个微小的错误,导致考试题目可能被提前查看。尽管他们在学习时也看到了类似的警告幻灯片,但仍然选择了自行编写。现在,他们的经历成为了警示后来者的案例。

因此,请记住:使用经过严格审查的现有库,不要编写自己的密码学代码。我们教授的知识不足以让你安全地做到这一点。


本节课中,我们一起学习了密码学的基本定义、本单元的内容特点,以及一个至关重要的安全警告:永远不要尝试自行实现密码学协议,而应依赖成熟、经过验证的库。

079:定义与柯克霍夫原则

在本节课中,我们将学习密码学中的一些基本定义和核心原则。我们将认识密码学中常见的角色,理解保密性、完整性和真实性这三个核心目标,并深入探讨对称密钥与非对称密钥模型。最后,我们将学习一个至关重要的安全原则——柯克霍夫原则,它指导我们如何构建健壮的密码系统。

密码学中的角色

在密码学论文和讨论中,我们通常使用一些约定俗成的角色名称来描述不同的场景。了解这些角色有助于我们清晰地讨论安全模型。

以下是这些核心角色及其作用:

  • 爱丽丝和鲍勃:他们是主角,通常的目标是通过不安全的通信渠道相互发送消息。
  • 夏娃:她是窃听者,可以读取在信道上发送的数据。
  • 马洛里:她不仅是窃听者,还能拦截并修改传输中的数据。

通常,我们的目标是让爱丽丝和鲍勃在面对夏娃或马洛里(或两者兼具)的威胁时,仍能实现安全通信。

密码学的核心目标

上一节我们介绍了通信中的各方角色,本节中我们来看看密码学旨在保护数据的三个核心目标。这些目标定义了安全的通信应具备哪些特性。

密码学旨在实现以下三个主要目标:

  • 保密性:像夏娃和马洛里这样的对手无法读取我们的消息。
  • 完整性:对手无法在未被察觉的情况下更改我们的消息。如果消息被更改,应有某种机制能向我们发出警报。
  • 真实性:消息的原始发送者能够证明消息确实来自他们,而非冒名顶替者。

保密性关乎保护秘密信息不被读取,而完整性和真实性则关乎验证消息的来源和是否被篡改。在某些威胁模型中,完整性和真实性紧密相关。

密钥:密码学的基础构件

在明确了安全目标之后,我们需要工具来实现它们。任何密码方案中最基本的构建块就是密钥

密钥是一段秘密数据(例如一串0和1),用于保护我们的消息安全。根据密钥的使用方式,主要分为两种模型:

  • 对称密钥模型:爱丽丝和鲍勃共享一个只有他们知道的秘密密钥。加密和解密使用同一个密钥。
    # 概念示例:使用同一个密钥K进行加密和解密
    ciphertext = encrypt(plaintext, key_K)
    plaintext = decrypt(ciphertext, key_K)
    
  • 非对称密钥模型:每个人都拥有一对密钥:一个公开的公钥和一个私密的私钥。我们将在后续课程中详细讨论。

柯克霍夫原则

了解了密钥的重要性后,我们来看一个指导密码系统设计的核心安全原则。这个原则强调了密钥作为唯一秘密的重要性。

柯克霍夫原则指出:一个密码系统应该是安全的,即使攻击者完全了解系统的所有工作细节。系统中唯一需要保密的就是密钥本身。

这意味着,即使攻击者知道你的加密算法、代码实现等一切信息,只要他不知道密钥,就无法攻破系统。这样做的好处是:

  1. 如果系统设计遵循此原则,即使代码泄露,也只需更换密钥即可恢复安全,成本很低。
  2. 如果依赖“隐匿式安全”(即希望攻击者不知道系统如何工作),一旦系统细节泄露,就必须从头重写整个系统,代价高昂。

因此,遵循柯克霍夫原则意味着将所有安全性都建立在密钥的保密性之上,而不是算法的保密性上。


本节课中我们一起学习了密码学的基本框架。我们认识了爱丽丝、鲍勃、夏娃和马洛里这些经典角色,明确了保密性、完整性和真实性三大安全目标,并了解了对称与非对称密钥模型的基础概念。最后,我们深入理解了柯克霍夫原则,它教导我们构建密码系统时应将安全性完全寄托于密钥的保密,而非算法的隐匿。这是设计健壮、可维护的密码系统的基石。

080:实现机密性、完整性与真实性

在本节课中,我们将学习如何在实际中实现密码学系统的三个核心属性:机密性、完整性与真实性。我们将通过物理类比来理解这些概念,并探讨它们在代码中的具体表现形式。

实现机密性 🔒

上一节我们介绍了密码学系统的三个目标属性。本节中,我们来看看如何实现其中的第一个——机密性。

机密性的定义是:攻击者无法读取我们的秘密消息。一个物理类比是,将消息放入一个盒子并上锁。在对称密钥模型中,Alice和Bob拥有一把其他人都没有的密钥。Alice会将她的消息放入盒子,用这把密钥锁上盒子。这样,消息就被安全地保护在盒子里了。

Alice可以通过不安全的信道发送这个盒子。任何攻击者(如Eve)都无法打开它,因为她没有钥匙。当Bob收到盒子后,他可以使用解锁操作,用自己的密钥打开盒子,取出里面的消息。因此,通过物理类比,实现机密性的所有秘密都来自于密钥。

在实际代码中,我们无法使用真实的盒子,而是通过加密消息来实现。Alice将她的消息和密钥作为两个输入,运行一个加密算法。这是一个接收消息和密钥两个参数,并输出一个经过“打乱”的消息版本(即密文)的代码片段。攻击者Eve即使看到这个加密后的消息,也无法理解其内容,因为她没有密钥。

当Bob收到加密消息后,他会将密文和密钥作为两个参数,提供给一个解密算法。这段代码会接收密文和密钥,并输出原始消息。这类似于我们刚才描述的盒子操作,但使用的是实际的代码。作为密码学协议的设计者,我们需要设计的就是加密算法和解密算法这两个代码片段。

以下是实现机密性的核心流程:

  • 加密过程密文 = 加密算法(明文, 密钥)
  • 解密过程明文 = 解密算法(密文, 密钥)

这里再补充一个术语:加密前的原始消息有时被称为明文,加密后被打乱的消息有时被称为密文

实现完整性与真实性 🔐

接下来,我们探讨第二个定义:完整性与真实性。这个属性是:攻击者无法在未被察觉的情况下更改消息内容

我们将通过为消息创建“封印”来实现这一点。物理类比是,在信件上贴上一个特殊的封条。只有Alice能用她的特殊“胶带”生成这个封条,其他人无法伪造。当Alice发送消息时,消息不仅包含内容,还附有这个封条。

Bob收到带有封条的消息后,可以打开它并检查封条是否完好。如果攻击者Mallory试图篡改消息,她就必须破坏这个封条。当Bob检查时,会发现封条已被破坏,从而得知消息已被更改。

在实际代码中,Alice拥有消息和密钥。她不是生成一个实体的封条,而是运行一个算法来为消息创建一个标签。这个算法以密钥和消息为输入,输出一个能标识该消息来自Alice的唯一代码(即标签)。然后,她将消息和这个标签一起通过不安全信道发送出去。

Bob收到消息和标签后,除了能看到消息本身,还可以使用标签和自己的密钥来检查标签是否被篡改。我们需要编写的这段检查代码,以密钥和标签为输入,输出一个布尔值(真或假),以指示该消息是否被篡改过,或者它是否仍是原始的有效消息。

因此,我们需要编写两个算法:一个用于创建标签,另一个用于检查标签的有效性。所有旨在确保完整性的算法大致都遵循这种模式。

以下是实现完整性与真实性的核心流程:

  • 标签生成标签 = 生成算法(消息, 密钥)
  • 标签验证验证结果 = 验证算法(消息, 标签, 密钥) (结果为真或假)

总结 📝

本节课中,我们一起学习了如何实现密码学系统的三个核心属性。

我们首先通过“上锁的盒子”这一类比,理解了如何利用加密和解密算法来实现机密性,确保消息内容不被未授权者读取。

接着,我们通过“信件封条”的类比,探讨了如何利用标签生成和验证算法来实现完整性与真实性,确保消息在传输过程中未被篡改,并能验证其来源。

理解这些基本模型和算法结构,是进一步学习具体加密方案(如AES)和完整性验证方案(如HMAC)的重要基础。

081:威胁模型(选择明文攻击)🔐

在本节课中,我们将学习密码学中的威胁模型,特别是针对保密性的攻击模型。我们将重点介绍选择明文攻击,并理解攻击者Eve可能具备的额外能力。


概述

之前我们介绍了两个虚构的攻击者:窃听者Eve和篡改者Mallory。他们分别能够窃听和篡改消息。然而,现实中的攻击者可能具备更多能力。为了更准确地模拟这些攻击者,我们需要建立更全面的威胁模型。本节课将探讨四种不同的威胁模型,并详细讲解其中一种——选择明文攻击模型。


威胁模型的重要性

Eve和Mallory是虚构人物,用于模拟现实世界中的攻击者。因为现实中的攻击者除了窃听和篡改外,还可能进行其他操作,所以我们需要赋予这些虚构角色更多能力,以便更真实地反映潜在威胁。


攻击者的额外能力

我们可以根据威胁模型,赋予Eve两种额外的能力:

  1. 诱骗Alice加密指定消息:Eve能否诱骗Alice使用其密钥加密Eve选择的消息?例如,Eve能否让Alice加密单词“potato”?
  2. 诱骗Bob解密指定密文:Eve能否诱骗Bob使用其密钥解密Eve提供的密文?

根据Eve是否具备这两种能力,我们可以组合出四种不同的威胁模型。


四种威胁模型

以下是四种威胁模型及其名称:

  • 唯密文攻击:Eve仅能窃听密文,既不能诱骗Alice加密,也不能诱骗Bob解密。
  • 选择明文攻击:Eve可以诱骗Alice加密其选择的明文,但不能诱骗Bob解密。
  • 选择密文攻击:Eve不能诱骗Alice加密,但可以诱骗Bob解密其提供的密文。
  • 选择明文与选择密文攻击:Eve既能诱骗Alice加密,也能诱骗Bob解密。这是四种模型中最强大的一种。

在本课程中,我们将重点讨论选择明文攻击模型。


选择明文攻击详解

在选择明文攻击模型中,Eve能够诱骗Alice使用其密钥加密Eve选择的任何消息。Eve本身并不知道密钥,但她能利用Alice的加密操作来协助自己。然而,Eve不能诱骗Bob解密消息。

核心概念可以用以下方式描述:

  • 攻击者能力:Eve → Alice: Encrypt(Key_Alice, Plaintext_Eve)
  • 攻击者限制:Eve → Bob: Decrypt(Key_Bob, Ciphertext_Eve) ❌ (不允许)

在实际应用中,密码学家通常以最强大的模型(即选择明文与选择密文攻击)为目标来设计协议。因为如果能防御最强大的攻击者,那么协议自然也能防御能力较弱的攻击者。但为了课程学习的循序渐进,我们在此先聚焦于选择明文攻击。


一个重要的补充说明

关于诱骗Bob解密的能力,有一个细微但重要的限制:Eve不能诱骗Bob去解密她自己刚刚诱骗Alice加密得到的那个密文。如果允许这样做,那么整个加密的目的就被完全破坏了,因为Eve可以直接通过Bob得到她选择的明文。


总结

本节课我们一起学习了密码学中的威胁模型。我们了解到,为了模拟现实世界中复杂的攻击者,需要为虚构角色Eve定义不同的能力组合,从而形成四种威胁模型:唯密文攻击、选择明文攻击、选择密文攻击,以及最强大的选择明文与选择密文攻击。我们重点探讨了选择明文攻击,即攻击者可以获取指定明文的加密结果,但无法获取解密密文的能力。理解这些模型是设计和评估加密方案安全性的基础。

082:密码学路线图 🗺️

在本节课中,我们将学习密码学方案的分类框架,即“密码学路线图”。这个框架通过两个核心问题,帮助我们系统地理解不同类型的密码学方案及其目标。

概述

这张图展示了我们可以设计出的四种不同组合的密码学方案。理解这个分类框架是学习后续具体方案的基础。

方案分类的两个维度

分类基于两个核心问题,由此形成一个2x2的表格。

第一个维度:密钥类型

第一个问题关乎方案中使用的密钥类型:是对称密钥方案还是非对称密钥方案

  • 对称密钥方案:Alice和Bob共享一个只有他们知道秘密密钥。公式可以表示为:K_AB = K_BA,其中K是共享的密钥。
  • 非对称密钥方案:Alice、Bob以及其他人各自拥有一对公私钥。例如,Alice拥有私钥SK_A和公钥PK_A

根据你的选择,方案会落入表格的这两列之一。

第二个维度:安全目标

第二个问题关乎方案提供的安全属性:是提供机密性,还是提供完整性/认证

  • 机密性:确保信息内容不被未授权方读取。
  • 完整性/认证:确保信息在传输过程中未被篡改,并能验证发送者的身份。

大多数方案主要提供其中一种属性,而非同时提供两者。若需同时满足,则需要组合多种方案。根据你的方案目标,它会落入表格的这两行之一。

探索四个象限

我们将逐一探讨这个表格的四个象限。

  1. 首先,我们将学习提供机密性的对称密钥方案。
  2. 接着,我们将转向提供完整性和认证的对称密钥方案。
  3. 然后,我们会研究提供机密性的非对称密钥方案。
  4. 最后,我们将探讨提供完整性和认证的非对称密钥方案。

在学习过程中,我们还会看到一些用于构建这些方案的辅助工具。例如,后面会讲到的HMAC方案,就依赖于我们将要详细讨论的哈希函数

总结

本节课我们一起学习了密码学方案的分类路线图。我们了解到,可以通过密钥类型(对称/非对称)和安全目标(机密性/完整性认证)这两个维度,将密码学方案系统地分为四类。这为我们后续深入学习具体的协议和方案提供了一个清晰的框架。

083:对称密钥加密 🔐

在本节课中,我们将要学习密码学中的第一种加密方案——对称密钥加密。我们将了解其基本概念、组成部分以及所需的关键属性。


上一节我们介绍了密码学的整体路线图。现在,我们进入路线图的左上角象限,即对称密钥模型。目前,我们只关注如何提供机密性,暂不涉及完整性和身份验证。

对称密钥加密方案可以拆解为两个词来理解:

  • 加密:意味着我们关心机密性,而不一定是完整性或身份验证。
  • 对称密钥:意味着爱丽丝和鲍勃共享同一个秘密密钥。爱丽丝知道它,鲍勃知道它,其他人都不知道。

目前,我们暂不考虑他们如何获得这个共享密钥。你可以假设密码学之神从天而降,赐予了爱丽丝和鲍勃一个共享密钥。稍后我们将看到他们实际上是如何获得这样一个密钥的。

此外,在本密码学单元的这一部分,我们假设所有消息都表示为比特串,即一长串的1和0。这是一个合理的假设,因为任何你想要发送的数据,无论是文本、图像还是视频,都可以在加密前编码成一堆1和0。因此,我们不关心用户编码的是什么,我们只将其视为任意的1和0序列,我们的目标就是安全地将这个序列通过信道传输。


上一节我们回顾了对称密钥加密的定义。本节中,我们来看看作为密码方案设计者,我们的具体任务是什么。

我们的目标是设计两个算法来填充这两个方框:一个加密算法和一个解密算法

  • 加密算法 接收两个参数:密钥 K 和明文 M。它应该输出密文 C,即明文经过打乱后的版本,攻击者无法读取。
  • 解密算法 接收密钥 K 和密文 C。它应该输出明文 M,即原始的、未打乱的消息。

因此,我们作为密码方案设计者的工作就是设计这两个算法,从而为用户提供一个可用的方案。有时人们还会定义密钥生成方案,说明爱丽丝和鲍勃最初是如何获得这两个密钥的。但今天,我们假设他们已经拥有了一个他人不知道的秘密密钥,因此我们将注意力集中在加密和解密上。


我们已经明确了需要设计的组件。那么,我们对加密和解密算法有哪些属性要求呢?我认为我们关心以下三点:

以下是三个关键属性:

  1. 正确性:它必须能正常工作。这意味着,如果你用某个密钥加密一条消息,然后鲍勃用同一个密钥解密,你应该能得到原始消息。如果鲍勃解密密文后得到的是不同的消息,那就非常荒谬了。用数学公式表达就是:Decrypt(K, Encrypt(K, M)) = M。这表示无论使用什么密钥,如果你用该密钥加密一条消息,然后用同一个密钥解密,你应该得到原始的明文。
  2. 效率:我们需要这些算法具有合理的速度,以便用户实际使用它们。请记住要考虑人为因素:如果我们构建的加密和解密算法速度极慢,用户根本就不会使用它。因此我们需要一定程度的效率。我们不会对此进行过于严格的定义,但我们不能构建那些慢得离谱、用户不愿使用的东西。
  3. 安全性:我们已经说过,这里的定义是机密性。在不安全信道中、不知道秘密密钥的攻击者,应该无法弄清楚明文是什么。

以上就是构成一个良好加密方案的三个属性。


本节课中,我们一起学习了对称密钥加密的基本框架。我们明确了其核心是使用共享密钥,并定义了设计者需要构建的加密和解密算法。同时,我们确立了衡量一个对称密钥加密方案是否合格的三个关键标准:正确性效率安全性(机密性)

084:IND-CPA安全游戏

在本节课中,我们将学习一个更精确的保密性定义,并了解如何通过一个称为“安全游戏”的模型来形式化地测试一个加密方案是否安全。我们将重点介绍IND-CPA(不可区分的选择明文攻击)安全游戏。

概述

我们之前对保密性的定义(攻击者无法读取消息)较为模糊,存在许多边界情况。例如,攻击者能否读取一半消息?能否知道前两个词?如果攻击者事先知道部分信息呢?为了回答这些问题,我们需要一个更严谨的数学定义。

更精确的保密性定义

上一节我们指出了原有定义的模糊性,本节中我们来看看一个更精确的定义。

攻击者不应能获取关于明文的、超出其已有知识的任何额外信息。这个定义考虑到了攻击者可能拥有的先验知识,并且要求攻击者无法获取任何新信息,哪怕是一个词或一半消息。

为了测试一个加密方案是否符合此定义并提供保密性,我们将此定义建模为一个“安全游戏”。

引入威胁模型

在构建定义时,我们还需要纳入之前讨论的威胁模型。我们假设攻击者伊芙(Eve)不仅能够窃听不安全的信道,还能“欺骗”爱丽丝(Alice)为其加密任意消息。这意味着伊芙可以走到爱丽丝面前说:“请用你的密钥加密这条消息。”爱丽丝会用密钥加密并返回密文。

因此,我们的实验和定义也必须考虑这种威胁模型,因为这是我们关心的攻击者类型。

综上所述,完整的定义是:即使伊芙拥有这种额外能力(可以欺骗爱丽丝加密消息),她仍然无法获取超出其已知信息的任何额外信息。这个定义被称为 IND-CPA

IND-CPA安全游戏

为了更直观地理解这个定义,我们将其构建成一个安全游戏。这个游戏在伊芙和爱丽丝之间进行,其结果将告诉我们方案是否安全。

游戏的基本思想是:如果伊芙经常获胜,则表明方案不安全,因为她能获取消息的信息。反之,如果爱丽丝经常获胜,则表明方案是安全的。

以下是游戏的具体步骤:

第一步:伊芙的“练习”阶段

在游戏正式开始前,我们允许伊芙使用她的特殊能力。她可以向爱丽丝发送任意数量的消息,爱丽丝会忠实地用密钥加密并返回密文。伊芙可以反复进行此操作。

这个阶段的目的是让伊芙有机会试探和了解加密方案,看看是否能泄露关于密钥的某些信息或找到方案的弱点。

第二步:发起挑战

最终,伊芙厌倦了发送消息,并自信地说:“我想我知道如何破解你的方案了,爱丽丝。”于是她准备接受挑战。

以下是挑战的具体流程:

  1. 伊芙选择明文:伊芙选择两个任意的明文,记为 M0M1。例如,她可以选择“狗”(dog)和“猫”(cat)。她将这两个明文发送给爱丽丝。
  2. 爱丽丝随机加密:爱丽丝背过身去,抛一枚均匀的硬币。
    • 如果硬币正面朝上,她加密 M0(即“狗”)。
    • 如果硬币反面朝上,她加密 M1(即“猫”)。
      爱丽丝将加密后的密文(即 Encrypt(key, M0)Encrypt(key, M1))发送给伊芙。伊芙不知道爱丽丝加密的是哪一个。

第三步:伊芙的第二次“练习”机会

在收到挑战密文后,我们再次给伊芙一个机会使用她的能力。她可以继续向爱丽丝发送其他消息进行加密,并观察结果。这给了她更多时间来分析和尝试破解。

第四步:伊芙做出猜测

最终,伊芙必须做出最终决定。她需要猜测爱丽丝加密的究竟是 M0(狗)还是 M1(猫)。她输出她的猜测。

游戏结果判定

  • 如果伊芙猜对了,那么她是赢家。这表明她通过游戏过程学到了关于方案的信息,从而能够区分两个明文的加密结果。
  • 如果伊芙猜错了,那么爱丽丝是赢家。这表明即使伊芙拥有特殊能力并看到了挑战密文,她也无法判断加密的是哪个明文,因此方案是安全的。

游戏如何体现定义

这个游戏巧妙地体现了我们之前提出的所有要求:

  1. 伊芙的特殊能力:游戏中绿色的“练习”阶段明确赋予了伊芙进行选择明文攻击(CPA)的能力。
  2. 无法获取额外信息:游戏明确设定了伊芙的先验知识——她知道明文要么是狗,要么是猫(因为这是她自己选的)。游戏的目标就是测试她能否利用加密方案获取任何额外信息来区分这两者。如果她无法可靠地猜对(即获胜概率不显著高于50%的随机猜测),则说明方案满足“无法获取额外信息”的要求。

通过这种具体的游戏形式,我们实现了之前的文字定义:即使赋予伊芙选择明文攻击的能力,她仍然无法获取超出其已知信息(明文是狗或猫之一)的任何额外信息来推断出具体是哪一个。

总结

本节课中我们一起学习了如何形式化地定义加密方案的保密性。我们引入了 IND-CPA 安全概念,它要求即使攻击者能够选择明文并获取其密文,也无法区分两个特定明文的加密结果。我们通过一个分步骤的 安全游戏 来建模和测试这一概念。理解这个游戏是分析现代加密方案安全性的基础。在后续课程中,我们将使用这个框架来评估各种加密算法。

085:使用IND-CPA证明安全性/不安全性 🔐

在本节课中,我们将学习如何将IND-CPA游戏形式化,以精确匹配我们对保密性的定义。我们将探讨攻击者“可靠地猜测”意味着什么,并理解如何通过这个游戏来证明一个加密方案是安全的还是不安全的。


上一节我们直观地介绍了IND-CPA游戏。本节中,我们来看看如何使其更加数学化和严谨。

在之前的直观讨论中,我们说如果Eve能可靠地猜出加密的是“猫”还是“狗”,她就赢了;如果Eve无法猜出,则Alice赢。现在,让我们更具体地定义“可靠地猜测”在数学上意味着什么。

如果我们回到游戏中,Eve能采取的最愚蠢的策略是什么?最愚蠢的策略就是随机猜测0或1,比如抛硬币:正面猜0,反面猜1。这个策略聪明吗?不。它能破坏加密吗?不能。这就是Eve能采取的最愚蠢的策略。

如果Eve只使用这种毫不费力的愚蠢策略,她猜对的频率是多少?大约一半的时间。她在抛硬币,一半时间她会猜对Alice加密的消息,一半时间会猜错。

因此,当我们说“可靠地猜测”时,我们真正的意思是Eve的猜测应该比随机猜测更好。如果她多次玩这个游戏,使用许多不同的单词(可能这次是猫和狗,下次是其他动物),并且她能够以大于1/2的概率可靠地赢得游戏,那就意味着她的表现优于随机猜测。如果她的表现优于随机猜测,就意味着她一定从这个加密方案中了解到了一些本不该知道的信息。这个游戏一定不太安全。她一定能够利用她的能力观察密文,并意识到“这看起来像狗”或“这看起来像猫”。如果她能做到这一点,就意味着这个游戏不安全。因此,Eve开始以高概率获胜。

相反,如果Eve永远无法超过50%的正确率,无论她尝试什么策略——她可以问ChatGPT哪个被加密了,可以做任何她想做的事——如果她的猜测总是以50%的概率正确,并且她玩了很长时间却从未赢得很多,那么这就是方案安全的一个标志。无论Eve做什么,她一定没有很好地破坏方案,因为她的猜测根本不准确,和随机猜测一样好。

因此,为了形式化这个方案,我们应该说:即使Eve能够行使她的权力并诱使Alice加密消息,如果她只能以1/2的概率猜对,那么我们就说该方案是安全的。她没有了解到关于加密方案或密钥的任何信息,她的猜测不比随机猜测更好。相反,如果她能做得比随机猜测更好,比如以70%、80%的准确率猜测,那一定意味着这个方案的某个地方被破坏了,而Eve正在利用这一点以高概率获胜。

这就是为什么我们在这里使用概率1/2:它模拟了即使是最愚蠢的攻击者也能以一半概率猜对的事实。因此,对于Eve来说,要真正获胜,她应该以大于一半的概率猜对。这就是游戏的样子。请记住,我们进行这个游戏的原因是为了推理一个方案是否安全。Eve获胜意味着它不安全,因为她能频繁且正确地猜测。Alice获胜意味着它是安全的,因为Eve的猜测不比随机猜测更好。


关于IND-CPA,最后还有一个有趣的注意事项。到目前为止,我们讨论了如果Eve能够猜对,她就赢了。如果她能以大于一半的概率获胜,她的表现就优于随机猜测,这意味着密文泄露了信息。

我认为这个方案有趣的一点是,它不限制Eve使用任何特定的策略。Eve实际上可以做任何她想做的事。她可以上网搜索被加密的消息是什么,可以查看比特位并进行重组,可以尝试任何算法或策略来破解这个游戏,甚至可以尝试尚未发明的策略。

如果你能证明某个方案是IND-CPA安全的,那么你就是在说,任何攻击者或Eve使用的任何策略,都无法做得比随机猜测更好。因此,要让我们的方案安全,它不能仅仅阻止一个攻击者,它必须阻止所有攻击者。如果一个方案只能阻止非常愚蠢的攻击者,而更聪明的攻击者能够破解它,那么这个方案就不够好。

因此,当我们证明安全性时,我们试图证明的是:任何攻击者,无论是使用非常愚蠢策略的Eve,还是使用非常聪明策略的Eve,或是使用我们甚至不知道、尚未发明的策略的Eve,都无关紧要。为了证明安全性,我们必须说,世界上所有的Eve都无法做得比随机猜测更好。这就是一个方案安全的含义:无论Eve做什么,该方案都是安全的。

这真正酷的地方在于,这意味着你正在防御甚至尚未发明的攻击。因此,即使Eve使用了你从未听说过的攻击,如果你能证明该方案是安全的,你就是在说你甚至不了解的Eve仍然无法做得比随机猜测更好。我觉得这很有趣:你可以证明方案的安全性,甚至能对抗你从未见过的攻击。这就是使用证明来检查方案是否安全的力量。


本节课中,我们一起学习了如何形式化IND-CPA游戏,理解了“可靠猜测”的数学含义(即优于1/2的概率),并认识到一个安全的加密方案必须能够抵御所有可能的攻击策略,包括未知的策略。IND-CPA安全性的证明提供了强大的保证,确保方案在面对当前和未来的攻击时都能保持保密性。

086:IND-CPA边界情况 🧩

在本节课中,我们将学习IND-CPA(不可区分的选择明文攻击)安全定义的三个边界情况。这些情况对于构建一个数学上严谨的定义至关重要,它们帮助我们明确哪些攻击是实际需要关注的,哪些可以忽略不计。

上一节我们介绍了IND-CPA的基本定义和游戏。本节中,我们将看看为了使这个定义在数学上更严谨,需要考虑的三个边界情况。

边界情况一:消息长度泄露 📏

在现实中,密码方案通常被允许泄露消息的长度。这是因为完全隐藏长度会导致效率极低。例如,如果要求所有密文长度相同,那么发送短消息时就需要填充大量无用数据,这会浪费大量带宽和存储空间。

因此,在IND-CPA安全模型中,我们允许方案泄露消息长度。为了在安全游戏中体现这一点,我们要求攻击者伊芙(Eve)选择的两条挑战消息 M0M1 必须长度相等。

以下是此规则的核心要点:

  • 允许泄露:密码方案可以泄露消息的长度信息。
  • 游戏调整:在IND-CPA游戏中,伊芙提交的两个消息 M0M1 必须满足 len(M0) == len(M1)
  • 攻击要求:要证明一个方案不安全,伊芙必须利用长度以外的信息来区分密文。

边界情况二:攻击运行时间限制 ⏱️

理论上存在一些攻击,虽然能成功,但需要运行数百万年甚至更久才能完成。在实际威胁模型中,我们并不关心这种不切实际的攻击者。

因此,在IND-CPA定义中,我们限制攻击者伊芙的算法必须在多项式时间内运行。这意味着她的计算步骤增长不能超过输入规模的多项式函数。

以下是关于运行时间限制的要点:

  • 关注实际攻击:我们只关心在合理时间内能完成的攻击。
  • 形式化定义:攻击者伊芙的所有算法必须是多项式时间算法。
  • 复杂度示例:像 O(n^2)O(n^3) 这样的复杂度是可接受的,而像 O(2^n) 这样的指数时间则不被允许。

边界情况三:优势必须不可忽略 🎯

有些攻击策略虽然能使伊芙获胜的概率略高于50%(例如50.00000001%),但这种优势微乎其微,无法转化为任何实际有效的攻击。我们只关心那些能带来“不可忽略”优势的攻击。

因此,在IND-CPA安全中,我们要求如果伊芙能攻破方案,她的获胜优势必须是显著且不可忽略的,而不仅仅是比随机猜测好一点点。

以下是关于攻击优势的要点:

  • 忽略微小优势:像 1/2^128 这样极小的优势被认为是可忽略的,不予考虑。
  • 要求显著优势:攻击必须带来显著的、非微不足道的优势,例如概率显著高于50%。
  • 实际例子:单纯“猜测密钥”的策略虽然理论上概率略高于1/2,但由于成功概率极低(如 1/2^128),我们不认为这是一个实际威胁。

本节课中我们一起学习了完善IND-CPA安全定义的三个关键边界情况:允许泄露消息长度、限制攻击者在多项式时间内运行、以及要求攻击优势必须是不可忽略的。这些调整确保了我们的安全定义既数学严谨,又贴合实际,只关注那些真正有威胁的攻击者。

087:IND-CPA 安全模型总结 🔐

在本节课中,我们将要学习一个核心的密码学安全概念——IND-CPA(不可区分的选择明文攻击)。我们将通过一个“游戏”模型来理解如何判断一个加密方案是否安全。

游戏模型概述

上一节我们介绍了安全模型的基本思想,本节中我们来看看IND-CPA安全模型的具体流程。这个游戏帮助我们建模一个加密方案是否安全。

游戏流程详解

以下是IND-CPA安全游戏的具体步骤:

  1. 学习阶段:攻击者夏娃(Eve)首先有机会运用她的能力。她可能利用这段时间来了解加密方案。她发送一条消息,爱丽丝(Alice)会忠实地用她的密钥进行加密。夏娃可以在多项式时间内重复此过程多次,但她不能无限次地进行。

  2. 发起挑战:最终,夏娃感到足够自信,认为她获得了一些优势,于是发起挑战。发起挑战时,她发送两条长度相同的明文,例如“cat”和“dog”,给爱丽丝。

  3. 加密挑战:爱丽丝转过身(夏娃看不到爱丽丝的操作),她抛一枚硬币。如果硬币正面朝上,她加密“cat”;如果反面朝上,她加密“dog”。然后,她将加密后的密文发送回给夏娃,而夏娃不知道加密的是哪条消息。

  4. 第二轮学习:此时,夏娃获得第二次运用能力的机会。她可以请求爱丽丝加密她选择的任何消息。爱丽丝会忠实地加密它们。

  5. 做出猜测:一旦夏娃对自己的猜测有信心,她将做出承诺,说出爱丽丝加密的是“cat”还是“dog”。

安全判定标准

如果夏娃能够以优于 1/2 + 可忽略概率 的正确率猜中,则意味着夏娃正在攻破这个方案,她正在了解关于密钥或方案工作原理的信息,那么这个方案就是不安全的。

相反,如果夏娃永远无法以优于50%的概率猜中,那么这个方案就是安全的。

总结

本节课中我们一起学习了IND-CPA安全模型。简而言之,这就是IND-CPA安全游戏,它允许我们分析加密方案的安全性和保密性。该模型的核心在于,即使攻击者能够选择明文并获取对应的密文,她也无法区分两个不同明文的加密结果。

088:历史 - 凯撒密码

在本节课中,我们将学习密码学的早期历史,并重点分析一个非常古老的加密方案——凯撒密码。我们将运用之前学过的定义来分析其安全性,看看它如何抵御攻击,以及它为何最终被证明是不安全的。


上一节我们介绍了密码学的基本定义和分析框架,本节中我们来看看历史上第一个著名的加密方案。

凯撒密码的定义

凯撒密码是一个非常简单的加密方案。根据我们的定义,一个加密方案需要包含密钥生成算法、加密函数和解密函数。

以下是该方案的形式化描述:

密钥生成算法 (Gen):
Alice和Bob需要协商一个密钥。他们只需共同选择一个介于0到25之间的整数。这个数字就是密钥。
例如,他们可以约定密钥 k = 13k = 24

加密函数 (Enc):
加密函数接收两个参数:密钥 k(0到25之间的整数)和明文 m(我们暂时假设它是由英文字母组成的单词)。
加密过程是:将明文中的每个字母,在字母表中向后移动 k 个位置。
例如,如果明文是 dog,密钥 k = 3

  • d -> 向后移动3位 -> g
  • o -> 向后移动3位 -> r
  • g -> 向后移动3位 -> j
    因此,密文 c = grj

用公式表示,对于单个字母 p(其字母序号为 aa=0对应aa=25对应z),加密过程为:
c = (a + k) mod 26

解密函数 (Dec):
解密是加密的逆过程。接收密钥 k 和密文 c,将密文中的每个字母在字母表中向前移动 k 个位置。
例如,对于密文 grj 和密钥 k = 3

  • g -> 向前移动3位 -> d
  • r -> 向前移动3位 -> o
  • j -> 向前移动3位 -> g
    因此,恢复出明文 dog

用公式表示,对于密文字母 c(其字母序号为 b),解密过程为:
p = (b - k) mod 26


了解了凯撒密码的工作原理后,我们来看看攻击者Eve如何破解它。

对凯撒密码的攻击

暴力破解攻击

假设攻击者Eve截获了一段密文,例如 jbegnq。她不知道密钥是什么。

一种最简单的攻击方法是尝试所有可能的密钥。因为密钥空间只有26种可能(0到25),所以Eve可以轻松地尝试每一种。

以下是可能的尝试结果:

  • 用密钥 k=1 解密:得到 iafdmp
  • 用密钥 k=2 解密:得到 hzecol
  • ...
  • 用密钥 k=10 解密:得到 attack

当Eve尝试到 k=10 时,她得到了一个有意义的英文单词 attack。结合上下文和常识,她可以推断出这就是原始明文,并且密钥是10。这种尝试所有可能密钥的方法称为暴力破解。由于凯撒密码的密钥空间极小,它无法抵抗这种攻击。

选择明文攻击

现在,让我们考虑一个更强的攻击模型——选择明文攻击。在这个模型下,Eve可以诱使Alice加密任何Eve选择的明文。

假设Eve请求Alice加密明文 aaa。Alice用她的秘密密钥(假设是 k=2)加密后,将密文 ccc 返回给Eve。

Eve现在知道 aaa 被加密成了 ccc。她可以轻易推断出:从 ac 需要移动2位,因此密钥 k = 2。一旦Eve知道了密钥,她就可以解密任何截获的密文。

这个例子说明了为什么选择明文攻击是危险的:一个不安全的方案可能会通过加密特定明文而直接泄露密钥。


本节课中我们一起学习了凯撒密码,这是一个基于字母移位的古老加密方案。我们使用形式化的定义描述了它的密钥生成、加密和解密过程。通过分析,我们发现它非常不安全:其极小的密钥空间使其无法抵抗暴力破解;甚至在选择明文攻击下,密钥会直接泄露。这为我们理解现代密码学为何需要巨大的密钥空间和抵抗更强攻击的能力奠定了基础。

089:历史 - 替换密码

在本节课中,我们将学习凯撒密码的演进版本——替换密码。我们将了解其工作原理、密钥生成方式、加密解密过程,并分析其安全性,特别是面对不同攻击时的表现。

上一节我们介绍了凯撒密码,它是一种简单的移位加密。本节中我们来看看一种更复杂的加密方法:替换密码。

密钥生成

在凯撒密码中,密钥是一个简单的数字(如移位量2)。替换密码的密钥则完全不同。它不再是一个数字,而是一个完整的映射表。这个表定义了每个字母随机映射到另一个字母的规则。

密钥公式表示K = { (A→N), (B→Q), (C→L), ..., (Z→?) }

其中,映射是完全随机的,没有顺序规律。例如,A映射到N,B映射到Q,C映射到L,这些目标字母的选取是随机的。Alice和Bob各需持有此密钥的一份副本,而世界上的其他人都不能拥有。

加密过程

加密时,你使用密钥表,并按照正向查找字母进行替换。

以下是加密步骤:

  1. 获取明文消息。
  2. 对于明文中的每个字母,在密钥表中查找其对应的密文字母。
  3. 将所有密文字母组合成密文。

例如,使用上述密钥,单词“dog”的加密过程为:

  • d → Z
  • o → P
  • g → V
    因此,密文为“ZPV”。

解密过程

解密是加密的逆过程,需要使用相同的密钥,并在密钥表中进行反向查找。

以下是解密步骤:

  1. 获取密文消息和密钥。
  2. 对于密文中的每个字母,在密钥表中查找其对应的明文字母(即找到映射为该密文字母的原始字母)。
  3. 将所有明文字母组合成明文。

例如,对于密文“ZPV”:

  • V → g
  • P → o
  • Z → d
    因此,解密后得到原始明文“dog”。

安全性分析

现在我们来分析替换密码面对不同攻击时的安全性。

暴力攻击

攻击者Eve可以尝试所有可能的密钥来破解密文。

密钥空间计算:第一个明文字母有26种可能的密文映射,第二个有25种,以此类推。总的可能密钥数量是26的阶乘(26!),这大约相当于2^88种可能性。尝试所有密钥需要极其漫长的时间,可能超过太阳系的寿命。因此,虽然理论上可行,但实践中暴力攻击并不可行。

选择明文攻击

如果Eve能够发起选择明文攻击,即可以诱使Alice加密她选择的特定消息,那么情况就不同了。

以下是攻击步骤:

  1. Eve请求Alice加密一个包含所有字母的消息,例如“ABCDEF...Z”。
  2. Alice使用她的密钥加密此消息,输出结果(例如“NQLZKR...”)。
  3. 这个输出直接就是密钥的完整映射表。Eve通过对比她选择的明文(字母表)和得到的密文,就能完全重建出密钥。

因此,替换密码无法抵御选择明文攻击。在实际历史中,密码分析者还会利用语言统计特性(例如,字母‘E’的出现频率很高)来辅助分析,但这门课程不会深入探讨这些古典密码分析技术。

本节课中我们一起学习了替换密码。它是一种通过随机字母映射表进行加密的方法,相比凯撒密码有更大的密钥空间。我们明确了其加密和解密都依赖于同一个密钥表。虽然它能抵抗低效的暴力攻击,但在选择明文攻击面前非常脆弱。这为我们理解现代密码学需要满足的安全属性奠定了基础。

090:历史 - 恩尼格玛密码机

在本节课中,我们将要学习密码学发展史上一个重要的里程碑——恩尼格玛密码机。我们将了解它的工作原理、设计目标,以及最终被破译的原因和过程。这段历史不仅有趣,也深刻影响了现代密码学的安全原则。


上一节我们回顾了从古代到20世纪初的密码学发展。现在,让我们将时间快进到20世纪40年代。

这种机器在一战期间被使用,它被称为恩尼格玛密码机。这是一个非常有趣的故事,其简短版本是:德国人创造了这台机器,并希望它能实现一种无法破译的密码。

至此,我们已经从可以手工破解的替换密码,发展到了使用实际机器的密码学。恩尼格玛就是一台他们为构建“不可破译的密码”而制造的物理机器。

以下是它的基本操作方式:

  • 你按下与想要加密的字母相对应的按钮。
  • 然后,其中一个灯会亮起,告诉你对应的密文字母是什么。
  • 例如,你按下 A,然后 C 灯亮起,这表示 A 被映射到了 C

真正有趣的是机器里这些小小的转子。每次你按下一个按钮,转子就会转动一点。这意味着,如果你反复按下同一个按钮,它并不总是映射到同一个字母。

它不像之前的算法,如果你加密 DDD,你会得到 ZZZ。在这台机器上,如果你按 D 十次,你可能会得到各种不同的字母,因为设置每次都在改变。

此外,在战争后期,他们实际上对它进行了升级,增加了一个巨大的插线板,你需要将字母插接到其他字母上。例如,这可能会交换 AC 的映射。这里最重要的是,这是一台试图实现密码学的机器。


上一节我们介绍了恩尼格玛的基本操作。本节中我们来看看它的密钥和加解密过程。

那么,如何生成一个密钥呢?你必须将这些转子设置到正确的位置,你必须指定它们的起始点,你还必须在插线板上插好所有这些电线。事实证明,如果你这样做,可能的密钥数量将是一个天文数字。因此,任何试图暴力破解的人,在世界毁灭之前都无法完成。

以下是加解密过程:

  • 加密:你按下键盘上的按钮,对应的密文字母灯会亮起。
  • 解密:你实际上做同样的事情,只是按下密文按钮,对应的明文字母灯会亮起。

这就是他们设计的机器,它是我们密码的机器版本,我们仍然可以用我们的加密和解密方案来描述它。


然而,真正有趣的是他们是如何破译它的。事实证明,尽管德国人认为它不可破译,但这东西还是被破译了。

以下是盟军使用的一些策略,其中一些与我们讨论过的安全定义相关。

首先,德国人依赖的一个使其“不可破译”的事实是:盟军甚至不知道这台机器长什么样。他们不知道有转子和插线板。如果他们连机器是什么样子都不知道,又如何开始破解这个算法呢?这是一个关键错误,因为他们违反了柯克霍夫原则。唯一应该保密的是密钥,而不是算法本身。

为什么这个原则存在?因为猜猜发生了什么?一些人把机器遗失了,盟军捡到了它们。现在他们有了机器的副本,并且知道它是如何工作的。他们可以拆解它并了解其工作原理。所以,德国人依赖的是通过隐匿实现安全,他们试图隐藏算法,这是一个错误。本应只依赖密钥的安全性。这是第一个错误。

第二个错误是存在一些已知明文攻击。这意味着有时消息是可预测的。例如,他们每天早上都会发送天气报告,我们都知道他们在发送天气报告,因此我们可以将密文映射到当天的天气报告,即使我们不知道如何解密它。

但更有趣的是,这也是我们关心的一个定义:你实际上可以对德国人进行选择明文攻击。例如,你可以这样做:你可以去找他们的一个间谍,告诉他“这里有一个雷区”。然后他们会做什么?他们会去恩尼格玛机前,输入雷区的位置并发送消息。基本上,你所做的就是诱骗他们发送了一条你选择的消息。所以,如果你认为选择明文攻击不是一个现实的威胁模型,事实证明它确实是。这里有一个例子,你可以诱骗别人加密一条你选择的消息。

他们用来破解它的最终工具(当然还有其他工具)就是暴力破解。德国人没有预料到的是,盟军会建造这些庞大的机器。这些机器实际上就是模拟了恩尼格玛的转子。他们基本上建造了这些转子,但他们建造了很多个,以同时尝试所有不同的组合。通过足够的工作,他们能够开始破解这个算法并解密消息。


所以,除了这些安全定义之外,这个故事的寓意在于:这是密码学对现实世界产生重大影响的一个例子。一些人认为,破译恩尼格玛实际上缩短了战争,挽救了许多生命。建造破译机器的人之一是艾伦·图灵,他是计算机科学界的巨擘,这也是他成名的原因之一。

本节课中我们一起学习了恩尼格玛密码机的历史。虽然不要求你了解恩尼格玛故事的确切细节,但有趣的是,导致其易受攻击的一些因素,正是我们今天需要防御的,例如柯克霍夫原则选择明文攻击

091:课程总结 🎯

在本节课中,我们将回顾现代密码学的基本概念和定义,为后续学习具体的加密方案打下基础。我们将从历史背景出发,逐步介绍现代密码学的核心思想、关键角色、安全属性以及威胁模型,最后总结CPA安全性的定义及其重要性。

概述

密码学的核心目标是在不安全的信道上实现安全通信。随着计算机技术的发展,现代密码学已完全由软件和代码实现,不再依赖手工加密。本节将系统梳理之前介绍的所有密码学基础定义。

从历史到现代

上一节我们探讨了二战时期的密码设备(如转子机和插线板)。本节中,我们来看看现代密码学的开端。1976年,Diffie和Hellman发表了一篇开创性论文,标志着现代密码学时代的来临,他们也因此获得了图灵奖。尽管历史上的加密方法很有趣,但我们将重点关注在计算机上用代码实现的软件密码学。

核心原则与警告

在深入细节之前,必须强调一个关键原则:绝对不要尝试自己编写密码算法。密码学极其复杂,本课程所授知识不足以让你创建安全的加密系统。一个微小的错误就可能导致整个系统崩溃。因此,请务必使用经过严格审查的现有密码库。

关键角色与安全目标

以下是密码学场景中常见的角色:

  • Alice 和 Bob:希望安全通信的双方。
  • Eve:窃听者,试图截获并阅读机密信息。
  • Mallory:主动攻击者,不仅能窃听,还能篡改消息。

密码学主要保障三个安全属性:

  1. 机密性:防止未授权方读取信息。通过加密实现。
  2. 完整性:确保信息在传输过程中未被篡改。
  3. 真实性:确认信息确实来自声称的发送者。后两者通常通过为消息附加标签(如MAC)来实现。

柯克霍夫原则与威胁模型

一个基本原则是柯克霍夫原则:必须假设攻击者了解整个加密系统的所有细节和代码。所有的安全性必须仅依赖于密钥的保密性。这是现代密码学设计的基石。

我们讨论了多种威胁模型,并重点聚焦于选择明文攻击。在这种模型下,攻击者Eve能够诱使Alice加密Eve自己选择的消息(类似于一战中的“诱饵”消息)。这是衡量加密方案强度的一个重要标准。

CPA安全性详解

前面我们介绍了各种攻击模型,本节中我们重点剖析选择明文攻击下的不可区分性安全定义,即CPA安全性。这是今天最核心的内容。

我们可以用一场“游戏”来定义CPA安全:

  1. Eve可以不断请求Alice为她加密任何她选择的消息(模拟CPA能力)。
  2. 随后,Eve提交两个等长的消息 M0M1 给Alice。
  3. Alice随机选择其中一个(比如 Mb, 其中 b 是随机比特0或1)进行加密,并将密文返回给Eve。
  4. Eve的目标是猜出 b 的值,即哪个消息被加密了。

CPA安全的定义是:即使Eve拥有选择明文攻击的能力,在上述游戏中,她猜中 b 的概率也不会比随机猜测(50%)更好。用公式表示,她的优势 Advantage(Eve) 是可忽略的:

Advantage(Eve) = | Pr[Eve guesses correctly] - 1/2 | ≈ 0

如果Eve的猜测准确率显著高于随机猜测,则说明该加密方案泄露了信息,是不安全的。这个定义的精妙之处在于,它要求方案能抵御任何可能的攻击策略,包括尚未发明的策略。

定义的重要边界条件

为了使CPA安全定义严谨且实用,我们需要记住三个边界条件:

  • 消息长度:CPA安全方案可能泄露消息的长度。因此,在挑战阶段,Eve提交的两个消息 M0M1 必须长度相等。
  • 计算时间:Eve被限制在多项式时间内运行。我们不关心那些需要宇宙寿命才能完成的攻击。
  • 优势值:Eve的优势必须是不可忽略的,这样才能转化为实际有效的攻击。

总结

本节课中,我们一起学习了现代密码学的基础框架。我们回顾了密码学的核心目标——在不安全信道上安全通信,并强调了使用现有成熟密码库的重要性。我们明确了Alice、Bob、Eve和Mallory等关键角色,以及机密性、完整性和真实性三大安全目标。我们深入理解了柯克霍夫原则和选择明文攻击威胁模型。最后,我们详细探讨了CPA安全性的形式化定义及其三个关键边界条件,为后续分析和评估具体的对称加密方案做好了准备。下节课,我们将开始探讨第一个具体的对称密码方案。

092:一次性密码本定义

在本节课中,我们将继续密码学的学习旅程,首先介绍一次性密码本,然后讨论分组密码。我们将从一次性密码本开始,这是你将看到的第一个加密解密方案。

在开始之前,请记住,根据我们的学习路线图,我们仍处于左上角的象限。我们正在研究专门提供机密性的方案,并且是在对称密钥的背景下进行。这意味着爱丽丝和鲍勃共享一个其他人不知道的密钥。现在,我们将看到众多可能的对称密钥方案之一,它能够提供机密性。

异或运算回顾

在介绍一次性密码本之前,需要回顾一个称为异或的按位运算。它接收两个比特,输出一个比特。具体来说,如果两个输入相同,则输出0;如果两个输入不同,则输出1。

以下是异或运算的一些有用性质:

  • 0 XOR 0 = 0
  • 0 XOR 1 = 1
  • 1 XOR 0 = 1
  • 1 XOR 1 = 0

一个特别有用的性质是:(X XOR Y) XOR X = Y。你可以这样理解:如果你将一个比特与自身进行异或,结果为0;而0与任何比特异或,该比特保持不变。因此,如果你有一个由两个比特异或得到的值,再与其中一个比特进行异或,那个比特就会“抵消”,只剩下另一个比特。这个性质在后面会用到。

此外,你也可以用异或进行“代数”运算。例如,给定方程 1 XOR Y = 0,要求解Y。你只需在等式两边同时异或1:(1 XOR Y) XOR 1 = 0 XOR 1。由于 1 XOR 1 = 0,左边抵消后剩下Y,右边 0 XOR 1 = 1,因此得到 Y = 1。这个例子很简单,但更复杂的方程也可以这样处理。以上是对异或运算的回顾。

密钥生成

现在我们已经了解了异或运算,接下来看看一次性密码本是如何工作的。首先需要生成密钥。爱丽丝和鲍勃都需要拥有一个其他人不知道的共享密钥。

生成方法很简单:抛掷一系列硬币,得到一串随机的1和0,这就是我们的密钥。回想一下凯撒密码的密钥是1到26之间的一个数字,而替换密码的密钥是字母的某种映射。在一次性密码本中,密钥是一个随机选择的比特串,即通过抛硬币得到的一串1和0。爱丽丝知道它,鲍勃知道它,其他人都不知道。

加密过程

那么如何加密呢?记住,加密函数接收两个参数:密钥K和明文消息M,然后输出一个东西:密文C

加密算法很简单:使用异或运算来扰乱消息M的比特,保密性来自于密钥K。它的工作原理符合直觉:对消息的每一个比特进行异或操作。

以下是加密过程的示例:

  • 明文 M: 1011
  • 密钥 K: 0101
  • 密文 C = M XOR K = 1110

具体操作是:对于每一个比特,取对应的密钥比特和消息比特进行异或,得到对应的密文比特。对每一个输入比特和密钥比特都执行此操作,就得到了对应的密文。直观上看,这个密文被完全打乱了。不知道密钥的人无法得知原始消息是什么,他们只能看到这些混乱的比特。这就是加密过程。

然后,密文C通过不安全的信道发送给鲍勃。在这个过程中,攻击者无法读取这个被打乱的密文。

解密过程

鲍勃如何解密呢?解密函数接收两个参数:之前相同的密钥K和刚刚收到的密文C。我们的解密算法需要在这两个输入之间进行一些运算,以输出原始消息M。它接收两个输入,输出原始消息。

事实证明,解密只需要再进行一次按位异或运算。取密钥的比特和密文的比特,对每一个对应的比特进行异或,得到的输出就是原始的明文。

以下是解密过程的示例:

  • 密文 C: 1110
  • 密钥 K: 0101
  • 明文 M = C XOR K = 1011

这就是鲍勃解密消息的方法。

一次性密码本定义总结

以上就是一次性密码本的定义。

用文字总结如下:

  • 密钥生成:随机生成一个密钥K。与我们将要看到的后续方案不同的一点是,每条消息都需要一个不同的密钥。每次你想加密新内容时,爱丽丝和鲍勃都必须回到这个算法,重新生成一个之前从未使用过的新密钥。这仅对一次性密码本成立,原因我们稍后会解释。
  • 加密算法C = M XOR K。你只需对两个输入进行按位异或。
  • 解密算法M = C XOR K。同样对两个输入进行按位异或。

本节课中,我们一起学习了密码学中的一个基础概念——一次性密码本。我们首先回顾了异或运算的关键性质,然后详细介绍了该方案的三个核心组成部分:随机生成密钥、通过异或进行加密以及通过异或进行解密。理解这个简单的方案是学习更复杂加密技术的重要基础。

093:一次性密码本的正确性与安全性 🔐

在本节课中,我们将学习一次性密码本(One-Time Pad)加密方案。我们将探讨其加密和解密过程如何正确工作,并分析其安全性。课程将包含代数证明和安全性论证,以帮助初学者理解这一经典加密方案的核心原理。


正确性验证 🔍

上一节我们介绍了加密的基本概念,本节中我们来看看一次性密码本加密和解密过程的正确性。一个自然的问题是:如果我加密一条消息,然后再解密它,这个过程真的有效吗?一点代数知识可以帮助我们确信它是有效的。

加密时,你将密钥 K 与消息 M 进行异或运算:
C = K XOR M

解密时,你取原始的密文 C(即 K XOR M),并再次与密钥 K 进行异或运算:
M' = C XOR K = (K XOR M) XOR K

记住之前提到的那个方便的性质:如果你将某个值与自身进行异或,它们会相互抵消。这个性质正好在这里发生作用。

利用这个性质,这两个 K 相互抵消,你就得到了原始消息 M
M' = M XOR (K XOR K) = M XOR 0 = M

这个小代数证明帮助我们确信,一次性密码本能正确地完成工作。


安全性分析 🛡️

接下来我们应该问的问题是:一次性密码本是否提供安全性?这个问题有点微妙。原因在于,我们上次花了很多时间提出的IND-CPA(选择明文攻击下的不可区分性)定义,并不能完全适用于一次性密码本。因为在IND-CPA游戏中,我们通常在整个游戏中使用同一个密钥,以便攻击者Eve可以说“加密这个”,然后Alice忠实地用该密钥加密。而在一次性密码本中,由于我们每次都在更换密钥,游戏需要稍作修改才能适用。但我们仍然可以证明一次性密码本提供某种程度的安全性。

让我们尝试证明这一点。我将用一种与IND-CPA游戏略有不同的方式来证明一次性密码本的安全性,因为密钥每次都在变化。你现在不必过多思考IND-CPA的细节。这里有一个数学证明,向你展示无论Eve尝试做什么,这个方案都是安全的。Eve可以发明我们从未听说过的新方法,这个方案仍然是安全的。

其安全性的原因在于,我们将考虑我们可能处于的两种不同“世界”。假设Eve知道(类似于IND-CPA游戏),被加密的消息要么是 M0,要么是 M1。Alice要么加密了“猫”(cat),要么加密了“狗”(dog),但我们不知道她加密了哪一个。

换句话说,我们处于两个可能的宇宙之一:

  • 在宇宙1中,加密的消息是“猫”,发送出去的消息是 K XOR cat
  • 在另一个宇宙中,加密的消息是“狗”,发送出去的消息是 K XOR dog

Eve看到的密文 CK XOR catK XOR dog,但她不知道自己处于哪个世界。

让我们来思考Eve在两个世界中应该做什么。

假设我们在世界0。如果我们在世界0,那么 M0 必须是“猫”。Eve知道这一点,并且她知道密文 C,因为那是通过信道发送的。如果她做一点代数运算,她可以看到:
K = C XOR M0 = C XOR cat
这就是密钥。所以,如果我们在世界0,并且Eve知道我们在世界0,她可以推导出这个密钥。

另一方面,如果我们在世界1,使用完全相同的技巧,Eve知道密文 C,她知道 M1(在这个世界里是“狗”)。所以她可以知道 K 的值:
K = C XOR M1 = C XOR dog

因此,如果Eve知道她处于哪个世界,她实际上可以得出那个世界的密钥

不幸的是,Eve并不知道我们当前处于哪个世界。所以事实证明,Eve必须猜测:我是在 K 等于这个值的世界,还是在 K 等于那个值的世界?而她并不知道 K。所以这两个可能的密钥都是可能的,Eve无法知道使用了哪个密钥。这两个密钥都是看似合理的密钥,因为我们随机生成了密钥,我们可能拥有来自世界0的密钥,也可能拥有来自世界1的密钥,我们不知道。

在这种情况下,Eve的策略是取密文 C(它是这两个值之一),然后:
以下是Eve可能进行的操作:

  • 与“猫”进行异或,得到一个可能的密钥。
  • 与“狗”进行异或,得到另一个可能的密钥。

然后她就陷入了困境。这两个密钥看起来都合理。无法判断哪一个比另一个更可能。换句话说,Eve完全不知道加密的是哪一个:是猫还是狗?我们无法知道。试图找出密钥对我们没有任何帮助,因为这两个密钥的可能性完全相同。

这个巧妙的小安全证明基本上向我们展示了,这个方案不会泄露信息。如果Eve不知道密钥,并且她收到的是“猫”或“狗”的加密结果,她在学习哪个被加密方面并没有变得更好。她没有获得关于密钥或哪个消息被加密的任何额外信息。这两个世界是同等可能的。

这就是证明一次性密码本提供完美安全性(在我们将看到的某些约束条件下)的小安全证明。


总结 📝

本节课中我们一起学习了一次性密码本加密方案。我们首先通过代数运算验证了其加密和解密过程的正确性,核心在于异或运算的自反性质:(K XOR M) XOR K = M。接着,我们深入分析了其安全性。通过构建一个“两个世界”的模型,我们证明了即使攻击者Eve截获了密文,并且知道明文只能是两个选项之一,她也无法确定实际加密了哪个消息,因为两个可能的密钥都同样合理。这论证了一次性密码本在理想条件下(如密钥真随机、与消息等长、且只使用一次)能够提供完美的保密性。

094:一次性密码本的问题与局限性 🔐

在本节课中,我们将要学习一次性密码本(One-Time Pad)加密方案在实际应用中面临的约束和问题。我们将探讨为什么重复使用密钥会破坏其完美的安全性,并分析该方案在现实世界中不常被使用的两个主要原因。

一次性密码本的约束条件

上一节我们介绍了,一次性密码本在特定约束下是完美安全的。本节中我们来看看这些约束具体是什么。

在使用一次性密码本方案时,你必须为加密的每一条消息使用不同的密钥。你加密一条消息,就为它生成一个密钥。如果你之后想加密另一条不同的消息,你必须生成另一个不同的密钥。

你可能会想,如果我偷懒呢?如果我不想为两条独立的消息生成两个独立的密钥,而是想偷懒,直接重复使用我已经在用的同一个密钥呢?

如果你这样做,就会遇到非常危险的情况。让我们来看看具体会发生什么。

重复使用密钥的危险性

以下是重复使用密钥导致的问题:

  • 攻击者获得两条密文:假设你的第一条消息是 M0,你用密钥 K 通过异或(XOR)加密算法加密它,得到密文 C0 = K ⊕ M0。攻击者伊芙(Eve)可以看到 C0。之后,你取消息 M1,因为偷懒而使用同一个密钥 K 加密,得到密文 C1 = K ⊕ M1。攻击者伊芙同样可以看到 C1
  • 安全性证明失效:现在我们遇到了问题。因为伊芙同时看到了 C0C1,我们之前的安全性证明不再适用。她实际上可以了解到关于 M0M1 的一些信息。
  • 攻击者能推导出信息:伊芙可以这样做:她将两条密文进行异或操作:C0 ⊕ C1 = (K ⊕ M0) ⊕ (K ⊕ M1)。由于异或运算中相同的 K 会相互抵消,结果变为 M0 ⊕ M1。这意味着伊芙现在知道了 M0M1 的异或值。这很危险,这是她之前不知道但现在知道的信息。

所以,这是一个如果你两次使用相同密钥的情况。这不像之前我们有两个平行宇宙的假设,这是一个宇宙中爱丽丝(Alice)用同一个密钥先加密 M0,后来又加密 M1。如果爱丽丝这样做两次,我们就有大问题了,因为伊芙了解到了她之前不知道的关于这两条消息的信息。

最初,她对 M0M1 一无所知。现在,她知道了两条消息的异或值。这很危险。我们给了伊芙本不该拥有的信息。

泄露信息的后果

以下是泄露信息可能带来的具体后果:

  • 获取部分信息:在英文文本中,M0 ⊕ M1 的结果告诉伊芙 M0M1 中哪些比特位是相同的。例如,如果这个比特串的第15位是0,那就意味着 M0 的第15位和 M1 的第15位必须相同。这是她本不该学到的信息。或者,如果你发现第30位在这个异或结果是1,那就意味着 M0 的第30位和 M1 的第30位必须不同。这也很危险。
  • 完全破解另一条消息:如果伊芙因为某些原因(例如泄露或线索)知道了 M0,她可以直接用 C1M0 进行异或:C1 ⊕ M0 = (K ⊕ M1) ⊕ M0。由于 M0 与自身异或的部分会抵消(假设她知道完整的 M0,并能推导出 K,进而得到 M1),她就能知道 M1。因此,有了这些部分信息,如果伊芙知道一个值,她就能猜出另一个,这同样非常糟糕。

所有这些都说明,泄露任何形式的信息,即使是像异或值这样的信息,也是危险的,我们不应该这样做。

因此,从这一切中得出的结论是:如果你使用一次性密码本,你必须每次都生成一个全新的密钥。如果你偷懒,两次使用同一个密钥,你就在向伊芙泄露信息,这破坏了你加密方案的机密性。

所以,一次性密码本只有在你遵循其名称,一次性使用每个密钥时才是完美的。这就是为什么它叫“一次性”密码本,而不是“两次性”密码本,因为那样不安全。

一次性密码本在现实中的局限性

在完全离开一次性密码本这个话题之前,让我们简要谈谈为什么它们没有在现实生活中被广泛使用,尽管我们已经证明了它们是安全的。我们说过,只要你每次都使用不同的密钥,即为每次加密重新生成密钥,我们就证明了其安全性。那么,为什么人们不在现实生活中使用一次性密码本呢?为什么我们不就此打住,不再设计更多的加密方案呢?

以下是关于一次性密码本的两个主要问题:

  1. 密钥生成成本高:每次加密都必须生成一个新密钥。这实际上并非没有成本。爱丽丝或鲍勃(Bob)必须去抛硬币(或使用其他随机源)来生成1和0。而事实证明,生成真正随机的1和0并不像看起来那么容易。所以一个问题就是生成密钥是昂贵的,而且你每次都必须这样做。
  2. 密钥分发困难:如果爱丽丝和鲍勃都有一个密钥,到目前为止,我们假设密码学之神已经赐予他们一个没有其他人知道的相同密钥。但在现实生活中,这不会发生。在现实生活中,你必须将密钥传达给另一个人。如果爱丽丝生成了一个密钥,她必须安全地把它交给鲍勃,这就引出了一个有点可笑的问题:如果爱丽丝和鲍勃已经费尽周折来安全地传递这个秘密密钥,并且他们拥有这种安全传递密钥的方式,那他们为什么不直接用那个方式来传递实际的消息呢?这是一个有点傻的观点,但如果你已经在生成密钥,并且费尽周折安全地发送密钥,为什么不直接安全地发送消息呢?谁还需要一次性密码本?因此,事实证明密钥分发是昂贵的。事实上,它如此昂贵,以至于直接用你的密钥分发通道来发送消息效果是完全一样的。

这两个问题基本上都归结为一个事实:你每次加密消息时都必须生成一个密钥,而这成本太高了。

一次性密码本的有限用途

就是这样。一次性密码本有一些有限的用途。我们不会再看到它们,但在现实生活中,你可以想象有一些用途。我能想到的一个用途是:也许存在一种情况,你当前有能力安全地交换密钥,但之后你将失去那个安全通道。那么你可以做的是,趁你现在还有那个安全通道时,交换大量密钥。爱丽丝和鲍勃约定好1000个密钥,然后后来当安全通道中断时,他们不再有安全通道来发送密钥或消息,但他们有1000个预先生成的密钥。所以现在他们可以使用一次性密码本来交换多达1000条消息,而且你实际上可以手工完成这个操作。

所以这不是一次性密码本非常实际的用途,但在现实生活中你可能会看到它。我不知道这在今天还有多现实,但你可以想象,比如一个间谍,也许他们做的是:当他们在自己国家家里时,生成一本密钥簿。他们自己有一本副本,国内总部的人也有一本副本。然后一旦他们外出在敌国执行间谍任务,就可以使用他们之前生成的秘密密钥来加密消息。

除非你是间谍,否则你不需要这样做,但这可能是说明一次性密码本有用处的一个例子。所以对计算机来说不是最实用的,但也许如果你是一个在丛林中手工加密东西的间谍,一次性密码本可能是你想用的。我想显然这在第一次世界大战中被使用过,但如果你好奇的话,可以去查查。

总结

本节课中我们一起学习了关于一次性密码本的总结。它是一种对称加密方案,因为爱丽丝和鲍勃共享一个秘密密钥。我们看到了加密和解密方案。我们说过,如果你每次都使用不同的密钥,就不会泄露任何信息;然而,如果你重复使用密钥,你就在向伊芙泄露信息。因此,你必须每次都使用不同的密钥。所以,结果是,这对于现实世界的使用来说并不实用,因为你每次想要加密某些东西时都必须重新生成密钥——除非你是一个间谍。😊

095:分组密码定义 🔐

在本节课中,我们将要学习分组密码的基本定义。我们将了解它如何工作,以及它与一次性密码本的区别。

上一节我们介绍了对称密钥方案中的一次性密码本及其局限性。本节中我们来看看一种更实用的替代方案:分组密码。

分组密码是什么?🔍

分组密码是一种加密方案,它接收两个参数:一个密钥 K 和一个消息 M,并输出一个密文字符串 C。其函数形式可以表示为:

C = E_K(M)

其中 E 是加密函数,K 是密钥,M 是明文,C 是密文。

理解分组密码的一种直观方式是将其视为一个“箭头图”。我们可以列出所有可能的明文和密文,然后用箭头表示哪个明文映射到哪个密文。例如,如果我想加密比特串 01,箭头会告诉我它加密为 00;如果我想加密 10,箭头会告诉我对应的密文是 11

作为函数族的分组密码 👨‍👩‍👧‍👦

分组密码实际上可以看作是一个函数族。E_K(M) 中的下标 K 表示,分组密码是一个庞大的函数集合。你可以将其视为一个接收两个参数(密钥和消息)并输出密文的函数,就像我们见过的其他加密方案一样。

另一种有用的思考方式是:存在一个包含许多不同“箭头图”的大家族,每个图都展示了输入到输出的映射。你通过固定密钥 K 的值,从这个大家族中选定一个具体的函数。一旦你选择了 K,例如 K=1,就意味着你选定了这个函数族中的第一个具体函数。这个具体的函数会告诉你每个可能的明文对应的确切输出。

因此,分组密码是一个庞大的函数族,统称为 E。一旦你选定一个密钥 K,范围就缩小到其中一个具体的、具有唯一映射关系的函数。

正确性:双射的要求 ✅

现在我们知道分组密码是什么了,接下来应该问它是否正确。事实证明,有些分组密码是正确的,有些则不是,这取决于你如何实现 E 和绘制这些箭头。

以下是两种情况的例子:

  • 无效的分组密码(非双射):在这个例子中,两个不同的输入(例如 1011)可能映射到同一个输出(例如 11)。当接收方鲍勃收到密文 11 时,他无法唯一确定它解密后是 10 还是 11。我们称这种函数不是双射的。构建分组密码时必须避免这种情况。
  • 有效的分组密码(双射):在这个例子中,每个箭头都指向一个唯一的输出。如果鲍勃收到 11,他可以唯一地将其解密回 10;如果收到 10,则可以唯一地解密回 11。右侧的图是我们想要的。一个有效的分组密码应该被设计成:对于每个可能的密钥(K=1K=2K=1000 等),其映射图都看起来像这样,即每个输入都唯一地映射到一个输出,并且每个输出也只有一个输入对应。

此外,分组密码定义在固定的输入长度上。如果 n=2,则每个图都有 2 比特的输入和 2 比特的输出。如果 n=30,你就需要为每个可能的密钥绘制 2³⁰ 个输入到 2³⁰ 个输出的映射图。

在现实中,设计这些算法时,没有人会真的写出一个包含 2³⁰ 列的大表格。他们实际上是编写一段代码,可以根据需要为每个密钥生成这些映射关系。

数学定义与属性 📐

让我们用更数学化的方式再看一下分组密码的定义。

加密函数 E 是一段有人编写的代码,它接收一个 n 比特的明文 M 和一个 k 比特的密钥 K,并输出一个 n 比特的密文 C。其中 nk 都是预先确定的固定值。

C = E(K, M)

解密函数 D 则执行预期的操作:它接收密文 C 和密钥 K,并输出对应的明文 M

M = D(K, C)

如果你打开 ED 查看内部,它确实是一段代码。但你可以认为这段代码的功能就是维护着所有这些带有箭头的表格,展示每个 n 比特输入如何映射到一个 n 比特输出。每个密钥对应一张表格,一旦你固定了密钥,你就确定了使用哪一张具体的映射表。

分组密码的一些重要属性包括:

  1. 正确性:正如之前讨论的,加密函数必须是一个排列(双射)。每个箭头必须指向恰好一个输出,并且该盒子内的所有箭头都必须满足这一点。
  2. 效率:希望这个算法执行速度相对较快,这样人们才愿意使用它。
  3. 安全性:我们将在接下来的课程中讨论安全性,但安全性是分组密码设计的核心目标。

本节课中我们一起学习了分组密码的定义。我们了解到分组密码是一个函数族,通过密钥选择其中一个具体的、具有双射(一一对应)映射关系的函数。我们还探讨了正确性的要求,即加密必须是可逆的排列,并简要提及了其数学表示和核心属性。下一节,我们将深入探讨分组密码的安全性。

096:分组密码的安全性 🔐

在本节课中,我们将要学习分组密码的安全性。我们将探讨如何定义分组密码的安全性,并通过一个“区分游戏”来直观理解这个概念。之后,我们会分析针对分组密码的一种基本攻击方式——暴力破解,并解释为什么现代密钥长度足以抵御此类攻击。


分组密码的安全性定义

上一节我们介绍了分组密码及其作为置换的特性。本节中,我们来看看如何定义它的安全性。

分组密码的安全性基于一个核心思想:攻击者伊芙不知道密钥K。如果她不知道K,就不应该能够解密她截获的任何消息,也不应该能够了解原始输入的任何信息。

我们通过一个“游戏”来形式化地检验分组密码是否安全。数学上的定义是:一个分组密码是安全的,当且仅当它的行为类似于一个随机置换

这听起来有些抽象和数学化。因此,一个“区分游戏”能让事情变得更清晰。


区分游戏 🎮

现在,我们来玩一个区分游戏。假设你是攻击者伊芙。我会给你两个盒子,它们对应两个不同的“宇宙”。

  • 其中一个盒子里,我放置了使用秘密密钥K通过加密算法E生成的映射关系。这个映射就是由选定密钥所确定的那个唯一的输入-输出对应表。
  • 另一个盒子里,我则是完全随机地画出了输入到输出的箭头,没有任何算法或密钥参与。

我的挑战是:你需要告诉我哪个盒子是使用分组密码和密钥生成的,哪个盒子是纯粹随机生成的。

你认为你能区分出来吗?希望你不能,因为我也不能。这正是分组密码方案安全的关键

当我们说“它的行为像一个随机置换”时,我们的意思是:即使你把一个由真实分组密码生成的结果和一个完全随机生成的结果摆在伊芙面前,她也无法区分两者。她最多只能靠抛硬币来猜。

核心概念:一旦你通过密钥选定了一个特定的映射表(从众多可能的表中),在攻击者伊芙看来,这个表与一个完全随机生成的表没有任何区别。因此,伊芙完全不知道哪个表正在被使用(因为她不知道K),也就无法推断出任何输入到输出的映射关系。这就是安全性的证明。


针对分组密码的攻击

既然我们知道了分组密码如何被定义为安全,现在让我们开始思考针对它的各种攻击方式。

一种可能的攻击是暴力破解攻击。这类似于我们之前讨论凯撒密码时看到的攻击方式。

攻击者想要做的就是尝试每一个可能的密钥。记住,对于每一个选定的密钥,都会得到一个唯一的n位输入到m位输出的映射。攻击者可以尝试用每个密钥去解密消息,看看哪个结果是有意义的。

你可以尝试这样做。但是,如果我们使用的密钥是128位长,这将花费你极其漫长的时间。

以下是基于128位密钥的计算:

  • 密钥空间大小:2^128(这是一个非常非常大的数字)。
  • 即使使用非常非常快的计算机进行粗略估算,破解也需要大约30万亿年

所以,你可以尝试暴力破解我的加密消息,但在你成功之前,宇宙可能早已不复存在。如果使用256位密钥,所需时间更是天文数字,并行堆叠的计算机数量甚至无法容纳在太阳系内。

结论:对于足够长的密钥(如128位或256位),暴力破解所需的时间长得不切实际,因此可以视为是安全的。当然,前提是没有人知道你的密钥。


本节课中,我们一起学习了分组密码安全性的核心定义——其行为应类似于随机置换,并通过区分游戏加深了理解。随后,我们分析了暴力破解攻击,并认识到只要使用足够长的密钥,这种攻击在实践中就是不可行的。这确保了分组密码在实际应用中的安全性。

097:分组密码效率 🔐

在本节课中,我们将要学习分组密码的效率特性。我们已经讨论了分组密码的安全性,现在可以探讨其效率方面。

从“箭头”到代码

上一节我们介绍了分组密码的安全性模型。本节中我们来看看其效率实现。

尽管我们习惯用“箭头”来直观表示分组密码的映射关系,但必须记住,其本质是一段代码。这段代码接收输入 n 和密钥 K,通过运算决定具体的映射关系。虽然“箭头”模型有助于我们理解安全游戏,但实际执行的是代码。

高效的操作

分组密码的代码执行现代计算机擅长的高速操作。

以下是其核心操作类型:

  • 异或运算:例如 A XOR B
  • 位移操作:例如 bits << 2
  • 查表操作:例如 value = lookup_table[index]

这些操作不涉及复杂的数学运算,如求幂或除法。代码主要通过移动和组合比特位来生成之前提到的映射“箭头”。因此,分组密码的运行速度通常非常快。

硬件优化

事实上,现代CPU被专门设计来高效处理分组密码。

因为分组密码被广泛使用,所以CPU制造商有意优化其架构以加速相关操作。这不仅意味着计算机天生擅长这些操作,更意味着我们正在专门为分组密码构建更高效的计算机。这是一个良性循环:分组密码因其高速而被鼓励使用,而用户在实际加密时几乎不会察觉到时间消耗。

算法背后的故事

那么,分组密码的具体代码是如何诞生的呢?这里有一个背景故事。

分组密码的算法需要由人来设计。在20世纪90年代末和21世纪初,曾举办了一场竞赛来选拔最佳的分组密码设计。

以下是一些关于这场竞赛的细节:

  • 竞赛目的是选出将成为标准的分组密码算法。
  • Rijndael 算法最终赢得了这场竞赛。
  • 本课程上学期的讲师 David Wagner 教授设计了一个进入前五名的算法。

在另一个可能的世界里,我们使用的可能是David Wagner的算法。但在现实中,Rijndael 算法被采纳为标准。这意味着,现在当我们需要加密和解密信息时,公认使用的是Rijndael算法(即AES)。那些可以生成映射“箭头”的不同代码方案中,Rijndael成为了被装入“黑盒”的标准选择。

总结

本节课中我们一起学习了分组密码的效率特性。我们了解到,分组密码通过执行异或、位移和查表等计算机擅长的高速操作来实现高效加密。现代CPU甚至为此进行了专门的硬件优化。此外,我们知道了当前广泛使用的AES(Rijndael)算法是通过公开竞赛选拔出的标准。高效与安全的结合,使得分组密码成为现代加密的基石。

098:分组密码不具备IND-CPA安全性

在本节课中,我们将探讨分组密码在适应性选择明文攻击(IND-CPA)模型下的安全性。我们将通过一个互动游戏来演示,为什么确定性的加密方案(包括分组密码)无法满足IND-CPA安全性的要求。

上一节我们证明了分组密码的行为类似于随机置换,但这并未证明其在面对主动攻击者时的安全性。本节中,我们来看看如何通过一个游戏来检验其IND-CPA安全性。

进行IND-CPA游戏

我们将共同进行IND-CPA安全游戏。在这个游戏中,我将扮演诚实的参与者Alice,使用一个带有秘密密钥的分组密码。你将扮演攻击者Eve,目标是破解我的加密方案。

我的加密方案基于一个分组密码,其密钥K对你保密。从你的视角看,由于我们之前证明的性质,加密过程看起来是随机的。但实际上,对于给定的输入,加密输出是确定的,由密钥K唯一决定。

现在,游戏开始。根据IND-CPA游戏规则,你需要首先提供两个等长的明文消息供我加密。

以下是游戏步骤:

  1. 你选择两个等长的消息M0M1
  2. 我随机选择其中一个消息(M0M1)进行加密,并将密文C返回给你。
  3. 你可以利用“选择明文攻击”的能力,要求我加密任何你选择的消息(除了M0M1本身),我会忠实使用密钥K进行加密并返回结果。
  4. 最后,你需要判断我最初加密的密文C对应的是M0还是M1

游戏过程演示

假设你选择的消息是:

  • M0 = dog
  • M1 = cat

我随机选择其中一个(比如M1,即cat)进行加密。加密过程可以表示为:
C = Encrypt(K, cat)
我得到密文C并交给你。此时,你无法从C直接看出它对应的是dog还是cat

接下来,你利用选择明文攻击的权力。你可以要求我加密任何消息。

你要求我加密dog。我执行加密:
C1 = Encrypt(K, dog)
并将C1返回给你。

你又要求我加密cat。我执行加密:
C2 = Encrypt(K, cat)
并将C2返回给你。

现在,你收到了三个密文:最初挑战的密文C,以及你请求得到的C1dog的加密)和C2cat的加密)。

由于分组密码是确定性的,即用相同密钥加密相同明文总会得到相同密文。因此,你可以简单地比较CC1C2

你发现CC2完全相同,而与C1不同。由此,你可以100%确定地推断出,我最初加密的消息是cat(即M1)。

你赢得了游戏。

结论与总结

本节课中我们一起学习了IND-CPA安全游戏,并通过实践演示证明了分组密码不具备IND-CPA安全性。

核心原因在于分组密码是确定性的。确定性意味着:
Encrypt(K, M) == Encrypt(K, M) 总是成立。
攻击者可以利用选择明文攻击获得任意明文的密文。当挑战密文与某个已知明文的密文匹配时,攻击者就能立即识别出加密的原始消息。

因此,我们得出一个更普遍的结论:任何确定性的加密方案都不是IND-CPA安全的。这是一个重要的洞察,因为它告诉我们,为了达到IND-CPA安全,加密方案必须引入随机性(或不可预测性),使得每次加密相同明文都会产生不同的密文。

虽然分组密码本身是确定性的且不满足IND-CPA安全,但它们是构建安全加密方案(如我们后面将看到的加密模式)的重要基石。理解其局限性是正确使用它们的第一步。

099:分组密码的实现与问题 🔐

在本节课中,我们将要学习分组密码在现实世界中的具体实现,特别是AES算法,并探讨其存在的两个主要问题:安全性和可用性限制。


分组密码的实际应用:AES

上一节我们介绍了分组密码的基本概念。本节中我们来看看现实中实际使用的分组密码标准。

现实中广泛使用的分组密码是AES(高级加密标准)。它基于Rijndael算法。当时也可以选择Twofish算法,但最终选择了AES。

以下是关于AES的关键信息:

  • 密钥长度:AES支持不同的密钥大小。根据密钥大小的不同,有AES-128、AES-192和AES-256。每种版本的工作原理大致相同,但输入的密钥长度不同。如今,大多数人使用AES-256,因为它最长,最难被暴力破解。但即使是AES-128,暴力破解也极其困难。
  • 分组大小:分组大小固定为128位。这意味着 n = 128
  • 工作原理:一旦选定一个特定密钥,你就得到了一个从所有128位输入到所有128位输出的映射。在这个映射图中,有 2^128 条箭头。我们并不真的绘制这个图,而是用代码来确定箭头指向哪里。其核心思想是:一个 2^128 个输入到 2^128 个输出的映射。

你不需要了解AES内部的具体运作,只需要知道它进行了一系列比特位重排操作。你需要了解的是其输入输出规格,以及这些是普遍接受的常用大小。AES是现实中人们真正使用的算法。


AES的内部机制(简要了解)

如果你真的想了解AES内部发生了什么,这里有一些额外的幻灯片(内容不详细讲解)。但你可以看到,它进行了大量计算机擅长的比特操作,例如减法、移位、混合列、移动数据、交换字节等。它进行所有这些重排操作,但最终的行为正如我们之前展示的:它是一个置换,每个输入恰好对应一个输出,但攻击者不知道具体对应哪个输出。看起来就像随机撒下了一堆箭头。

它进行了大量的混合操作来扰乱输入,使得攻击者无法得知原始信息。这是一个很长的算法。

所以,你不需要知道算法的具体细节,只需要知道它进行了大量移位和混合操作即可。


AES的安全性证明

那么,是否有人真正证明了AES的行为就像一个随机置换?是否有人证明过攻击者夏娃无法区分两个盒子?答案实际上是否定的。

没有人真正证明过夏娃无法区分这两个盒子。可能存在某种攻击,让夏娃能够辨别出其中一个盒子来自AES,例如通过非常仔细地检查代码,发现代码生成的箭头集合比另一种更常见。但就我们所知,AES已经存在20年了,尚未被攻破。因此,它被认为是足够安全的。

尽管没有形式化的证明,但基本上所有人都在使用它:银行、美国政府、以及你我。这就是现代分组密码算法的通用标准。


分组密码存在的问题

在我们完全结束分组密码的讨论之前,让我们谈谈为什么我们不能就此止步,宣布胜利并说我们已经设计好了密码方案。

我们不能就此止步的原因在于我们已经看到的一些问题。

第一个问题非常严重:分组密码不具备NCPA安全性。

它们的行为确实像随机的,这意味着攻击者不知道箭头指向哪里。但是,由于你使用同一个密钥多次加密消息,它们不具备NCPA安全性。对于之前看到的攻击(要求我加密两次相同的内容,我得到相同的输出),它是确定性的。

直观上,这并不安全。因为作为攻击者,你可以检测到某人何时发送了两次相同的消息。如果爱丽丝发送了很多消息,你注意到其中两个密文相同,你现在就有了线索:爱丽丝两次发送了那个东西。你学到了本不该知道的信息。NCPA游戏反映了这种模型:你不希望这种情况发生,它迫使爱丽丝在方案是确定性的场景中输掉。游戏反映了现实:我们不希望人们知道内容是否被加密了两次。

因此,分组密码不具备NCPA安全性。我们需要寻找更好的、具备NCPA安全性的东西。

第二个问题我们之前有所暗示,但尚未真正解决:输入和输出是固定大小的。

实际上,现实中的AES只能加密128位的消息。如果你的消息更短,你无法将其输入算法,算法不理解你的输入。如果你的输入太长,你也无法将其输入算法,算法同样不理解。你看到的所有那些比特移位和混合操作,如果输入不是恰好128位,就无法工作。

所以,如果你想加密更长或更短的内容,你不能直接使用分组密码。


总结与展望

本节课中我们一起学习了分组密码的两个主要问题:

  1. 安全性问题:由于确定性加密,分组密码不具备NCPA安全性。
  2. 可用性问题:分组密码只能处理固定长度(如128位)的消息。

为了解决这两个问题,我们实际上将把分组密码作为构建模块,用来构建更强大的方案。这个新方案将具备NCPA安全性,并能解决消息长度限制的问题。这就是我们接下来要学习的内容。

100:总结 🧩

在本节课中,我们将总结块密码(Block Cipher)的核心概念、工作原理及其局限性,并引出后续需要解决的问题。

块密码的工作原理

上一节我们介绍了块密码的基本概念,本节中我们来看看它的具体工作机制。块密码是一个函数。它是一段代码,但最好将其视为一组将n位明文映射到n位密文的排列。

这些映射的数量非常多。每个可能的密钥都对应一张映射表。一旦你选择了一个K位密钥,你就锁定并承诺使用其中一张表,该表将每个输入唯一地映射到一个输出。

为了使该方案正确,映射必须是双射的。每个输入必须恰好映射到一个输出。如果有两个输入映射到同一个输出,你将无法唯一地解密。

以下是块密码正确性的核心要求:

  • 双射性:每个明文输入必须唯一对应一个密文输出,反之亦然。

块密码的安全性

对于安全性,我们说过块密码在计算上应与随机排列不可区分。这意味着攻击者面对两个盒子:一个装有使用未知密钥K的块密码生成的箭头,另一个装有完全随机绘制的箭头,他无法区分。

攻击者不知道哪个盒子是哪个,这正是块密码安全的原因。我们看到,暴力破解攻击需要天文数字般长的时间。

不要尝试暴力破解。😊

块密码的效率与实现

它们之所以高效,是因为使用了大量计算机擅长执行的XOR和位移操作。

我们使用AES(高级加密标准)来实现它们,这是现代的标准算法。

块密码的局限性

但是,我们不能就此止步的原因是,尽管它们具有这种“与随机排列不可区分”的安全特性,但它们不具备我们想要的CPA(选择明文攻击)安全性。

因此,我们必须解决这个问题。同时,我们还需要解决它只能加密固定长度(n位)消息的事实。

总结与过渡

本节课中,我们一起学习了块密码的核心原理、安全模型及其在效率与实现上的优势。然而,我们也明确了其两大局限:缺乏CPA安全性和对消息长度的限制。

现在你已经掌握了块密码的基础,是时候使用它们来构建更强大的加密方案了。在接下来的课程中,我们将探讨如何通过操作模式(如块密码链接模式)来克服这些限制。

101:分组密码回顾 🧩

在本节课中,我们将回顾分组密码的基本概念,并探讨其在实际应用中面临的两个主要问题。我们将从一次性密码本开始,逐步过渡到分组密码,并理解其安全定义和局限性。


上一节我们介绍了对称加密的基本假设,即通信双方共享一个秘密密钥。现在,我们来回顾一下具体的加密方案。

一次性密码本

在对称加密中,假设爱丽丝和鲍勃共享一个其他人不知道的密钥。加密过程是按位异或操作,解密过程同样是按位异或操作。如果我们每次使用不同的密钥,这个方案是安全的。但每次使用不同的密钥是不切实际的。

分组密码的引入

这引导我们提出了下一个概念:分组密码。分组密码接收一个 K 位密钥和一个 M 位明文,并输出一个 M 位密文。我们可以将其理解为:密钥 K 决定了我们想要使用的从明文到密文的映射关系。

以下是分组密码的核心工作方式:

  • 存在许多不同的从明文到密文的映射,每个密钥对应一个映射。
  • 一旦选定一个密钥,就确定了该特定密钥下所有明文到密文的映射。
  • 加密时,只需将明文映射到对应的密文。
  • 解密时,将密文映射回对应的明文。

为了使这个过程正确,所绘制的映射箭头必须构成一个双射,即每个明文应恰好对应一个密文。

分组密码的安全性定义

我们定义,如果一个分组密码看起来像一个随机排列,那么它就是安全的。具体来说,如果给攻击者两个排列:一个是所有箭头完全随机绘制的排列,另一个是通过未知密钥运行分组密码代码生成的排列,攻击者无法区分哪个来自分组密码,哪个是随机生成的。这就是我们使用的安全定义。

我们认为分组密码相对高效,因为它涉及大量计算机擅长的异或和位移操作。现代标准是 AES,这是实际运行的代码。

分组密码的局限性

然而,我们不能止步于此,因为分组密码存在两个主要问题:

  1. 分组密码不是 IND-CPA 安全的。它不符合特定的机密性定义,因为它是确定性的。
  2. 它仅限于加密长度恰好为 M 位的消息。

本节课中,我们一起回顾了从一次性密码本到分组密码的发展,明确了分组密码通过模拟随机排列来定义安全性,同时也指出了其确定性加密和固定长度限制两大缺陷。在接下来的课程中,我们将构建更好的方案来解决这些问题。

102:分组密码工作模式之ECB模式 🔐

在本节课中,我们将学习如何将基础的分组密码(如AES)扩展为能够加密任意长度消息的通用加密方案。我们将从最简单的模式——ECB模式开始,并理解其工作原理与安全缺陷。

从分组密码到长消息加密

上一节我们介绍了基础的分组密码,它只能加密固定长度(例如128位)的消息。本节中我们来看看,当我们需要加密更长的消息时,该如何设计。

基础的分组密码可以表示为:

C = E(K, P)

其中 E 是加密函数,K 是密钥,P 是128位明文,C 是128位密文。

它的一个主要问题是只能处理固定长度的输入。如果消息长度不是128位,该函数便无法工作。

ECB模式的设计思路

为了解决长消息加密的问题,一个直观的想法是将长消息分割成多个128位的块,然后对每个块独立地使用相同的密钥进行加密。

以下是实现此想法的具体步骤:

  1. 将长消息 M 分割成若干个128位的明文块:P1, P2, ..., Pn
  2. 对每个明文块 Pi,使用相同的密钥 K 和相同的加密算法 E 进行加密,得到密文块 Ci = E(K, Pi)
  3. 将所有密文块按顺序连接起来,形成最终的密文 C = C1 || C2 || ... || Cn

这种模式被称为ECB(Electronic Codebook,电子密码本)模式。它成功地将分组密码的应用范围扩展到了任意长度的消息。

ECB模式的安全性分析

虽然ECB模式解决了加密长消息的问题,但我们仍需检验它是否满足我们期望的IND-CPA(不可区分的选择明文攻击)安全。

ECB模式的核心问题在于它是确定性的。对于相同的明文块,无论加密多少次,都会产生完全相同的密文块。

这种确定性导致了严重的安全漏洞。攻击者可以通过观察密文中重复出现的模式,来推断明文中哪些部分是相同的。一个著名的例子是“ECB企鹅”图:当一张图片的每个像素被当作一个独立块用ECB模式加密后,虽然单个像素的颜色被改变了,但相同颜色的像素会被加密成相同的密文,导致原始图片的轮廓依然清晰可见。

因此,攻击者可以在IND-CPA游戏中轻松获胜:只需提交两个仅在部分块有差异的明文,观察密文中哪些块发生了变化,即可判断加密的是哪一个消息。这证明ECB模式无法提供我们所需的保密性。

课程总结

本节课中我们一起学习了第一个分组密码工作模式——ECB模式。

  • 我们了解了如何通过分块加密来扩展分组密码的功能,以加密任意长度的消息。
  • 我们分析了ECB模式的工作原理,并认识到其确定性的本质是导致其不安全的关键原因。
  • 通过“ECB企鹅”的例子,我们直观地看到了确定性加密如何泄露明文中的模式信息,从而无法达到IND-CPA安全标准。

ECB模式虽然简单,但不安全,不应在实际需要保密性的场景中使用。在接下来的课程中,我们将探索更安全的工作模式。

103:CBC模式设计 🔐

在本节课中,我们将学习如何设计一种比ECB模式更安全的加密模式。我们将探讨ECB模式存在的问题,并介绍如何通过引入随机性来构建一种称为“密码块链接”(CBC)模式的新方案。我们将详细讲解其工作原理、加密与解密过程,并解释其如何提升安全性。


上一节我们介绍了ECB模式,它虽然能加密长明文,但存在安全性问题。本节中,我们来看看如何设计一种更好的方案,以提供更强的安全性。

ECB模式不满足IND-CPA安全性的主要原因是其确定性。加密相同内容多次会得到相同的输出。为了使其变得非确定性,我们开始引入一些随机性。

如果在某个地方加入一些随机比特,也许就能使得多次加密相同内容时,输出不再每次都相同。这得益于我们为每次加密消息添加的不同随机性。

尝试加入一些随机性。也许不是直接加密明文本身,而是将明文与一些随机性混合后再加密。

现在,输入到分组密码的不仅仅是明文,而是明文与某个随机字符串进行异或的结果。这个小符号代表异或操作。这意味着我们将明文与所谓的“初始化向量”(IV)进行异或,IV是每次加密都不同的随机比特串。

如果将其通过加密块,密文每次看起来都应该不同。如果对相同的明文块加密10次,虽然传入的明文相同,但IV会不同10次。因此,密文输出也应该不同。这正在逐步解决之前企鹅图片暴露的问题。

这很好。现在,我的第一个密文块每次都会不同。但我还没有解决其他问题。如果第二个明文块相同,其密文仍然相同。我仅为消息的第一部分添加了随机性,但后续部分没有。

我应该继续扩展。将刚刚学到的思路扩展到使其他块也变得随机。一个可能的想法是添加更多的初始化向量。为每个块都生成一个随机值。这虽然可行,但需要大量的随机性。

因此,我将采用一个更巧妙的方法。我意识到这个密文块本身就是随机的。其输入是随机的,分组密码对其进行了扰乱。所以这是一个不可预测的随机值。

与其生成一个新的随机比特串作为第二个初始化向量,不如直接使用这个密文块。这是一个不可预测的随机值,为什么不用它来打乱和随机化第二个块呢?

对于这个明文块,不是直接传入,而是与前一个密文块进行异或。你可以将其视为我们用来与第二个明文块进行异或的另一个随机比特串。

现在,这个密文输出也应该不可预测,因为前一个密文块每次不同,所以这个输入每次不同,因此输出每次也应不同。

也许你已经猜到接下来的步骤。我们对后续的块重复此过程。这个块也需要被打乱。我们只需将前一个密文块的输出连接到这个块的输入,依此类推。

如果我们这样不断地链接下去,现在所有的输出都应该是不可预测和随机的。我们的目标只是说明ECB模式不是IND-CPA安全的,因为它是确定性的。我希望在整个加密过程中添加随机性。所以我从一个随机值开始,然后将其传播到整个加密过程中。现在,一切都应该是随机的。

我还没有向你证明这是正确的或有效的。我只是添加了一些随机性。事实证明,这确实有效。它有一个名字,叫做“密码块链接”模式,因为你将前一个块链接到当前块。

我们可以将其写成一个公式。你可以问,如何写出第i个密文块?你正在加密某个东西,它是分组密码的输出。那么我在加密什么?我在加密第i个明文块与前一个密文块的异或结果。这只是将图片转换为公式,如果你更喜欢公式的话。

为了简化,我们说第0个密文块就是这个IV。你不必这样做,但这使得符号表示更简洁,当你将其技术上称为C0时。

用文字描述,如何加密?首先,获取你的消息,它现在可以是任意长度,不必是128比特。将其分割成一系列连续的明文块,每个块128比特。计算这个随机IV。抛一些硬币,选择这个随机值。然后根据这张图计算密文。

现在我们有了一个加密方案。我们引入了一些随机性。请注意,随机性并不能保证安全性,但至少这不再是确定性的。所以我们有了一个方案。

现在,让我们思考这个方案的加密、安全性以及其他属性。


上一节我们介绍了CBC模式的加密过程。本节中,我们来看看如何解密,以恢复原始消息。

我们关心的一个属性是,如何实际取回原始消息。如果我这样加密一堆消息,并加入所有这些随机性,原始消息是否还能被输出,这一点似乎并不立即明确。

为了向自己证明原始消息可以从密文中恢复,有几种方法可以解决这个问题。一种方法是使用图片。

如果你喜欢图片,我们可以尝试看图。让我们思考一下密文。它就在那里。你可以访问它。有一件事我没有明确提到,但确实是事实:你也会将IV作为密文的一部分发送。当Bob收到消息时,他收到所有密文块加上IV。他得到全部。

那么Bob如何解密呢?他可以通过某种方式反向运行这个图。那会是什么样子?我会取密文,然后通过的不是加密(因为我要反向操作),而是解密。这就是我们在解密图中看到的。你取密文,通过分组密码解密块。这相当于在这个图中反向操作。

解密后,你仍然得到明文与IV异或的结果。但我想抵消掉IV。我不想要那个,我想要原始明文。所以一旦解密,你再与IV进行一次异或。记住我们方便的异或属性:如果你将这个值与IV异或,IV会抵消,我就得到了原始明文。

因此,推导这个解密图的一种方法就是反向运行这个加密图。对于后续的块也是如此。对于第二个块,我从密文开始,通过反向的分组密码解密块运行它。这就是解密。然后我仍然需要异或掉一些随机性。这里的随机性是前一个密文块。所以当我得到这个分组密码解密的结果时,我将其与前一个密文块异或。前一个密文块抵消了,我就得到了原始明文。

所以,得到这个解密图的一种方法是查看加密图,并思考如何反向工作。

现在,如果你不喜欢图片,我们也可以用一些代数来解决它。对于喜欢数学的你,另一种找出解密方程的方法是写出加密方程,然后解出明文。

这是我们之前看到的方程,它代表了加密图。它告诉我们,给定一些消息和之前的密文,如何得到下一个密文块。

现在,如果你想解密,你需要解出M。所以我们只需要做一些代数运算来解出M。我们该怎么做?我们可以对两边应用操作。所以我在两边都应用D_K。记住,当你加密某物然后解密它时,这两个操作会抵消。

所以我得到了这个方程。很好。但我想分离出M_i。所以我将两边都与C_{i-1}进行异或。就是这样,利用我们方便的异或属性,这两项抵消了,我得到了M_i的方程。

这告诉我如何根据给定的密文进行解密。所以,如果你不喜欢图片,代数是解决这个问题的另一种方法。


在本节课中,我们一起学习了如何设计CBC加密模式。我们首先指出了ECB模式因确定性而存在的安全问题。接着,我们通过引入初始化向量(IV)和将前一个密文块链接到当前块的处理,构建了CBC模式。我们详细讲解了其加密过程,公式表示为:C_i = E_K(M_i ⊕ C_{i-1}),其中C_0 = IV。然后,我们探讨了解密过程,可以通过反向运行加密图或通过代数求解实现,解密公式为:M_i = D_K(C_i) ⊕ C_{i-1}。CBC模式通过在整个加密过程中传播随机性,解决了ECB模式的确定性缺陷,为构建更安全的加密方案奠定了基础。

104:CBC模式效率分析 🔍

在本节课中,我们将要学习CBC(密码块链接)模式的效率特性,特别是其加密和解密过程是否能够并行化处理。理解这些特性对于评估和优化加密算法的实际应用性能至关重要。

上一节我们介绍了CBC模式的基本原理以及加密和解密流程。本节中我们来看看CBC模式在效率方面的表现。

并行化分析 🚀

一个我们关心的问题是效率。我们希望分组密码能为用户相对快速地运行。因此,我们可能会问:能否并行运行此模式?如果能,那将非常理想。这里的“并行”指的是可以同时加密所有数据块。

如果能够并行化,当你拥有10个数据块时,你可以同时加密所有10个块,完成速度将快10倍。如果不能并行化,则意味着你必须一次加密一个块,这仍然有效,只是速度稍慢。这里我们分析的是性能,而非正确性。

加密过程的并行性

首先的问题是,我们能否并行化该算法的加密部分?

观察加密流程。假设我想加密第三个数据块。在并行化场景下,我希望将不同的块分配给计算机的不同部分处理。那么,是否有人可以仅凭自身、无需其他信息就加密第三个块?

他们需要明文。我们有明文,因为它是输入。我们也有密钥,它也是加密的输入之一。

但是,加密前需要与上一个密文进行异或操作的这个值呢?我没有这个值。我必须等待前一个块的处理者完成,才能获得他们的密文输出,而在一开始我并没有这个值。

因此,不幸的是,加密无法并行化,因为每个块都必须排队等待前一个块完成,才能计算自己的块。例如,第三个块必须等到第二个块输出其密文后才能运行。所以,加密过程无法并行化,你必须等待前面的块完成。

解密过程的并行性

那么解密呢?再次观察图示,你可能会因为这些箭头而倾向于认为它无法并行化,但我们必须仔细分析。

让我们看看解密算法的输入。有密文,我们有吗?我们有。密文是解密的输入。我们有密钥吗?我们有。密钥是解密的输入之一。那么这个值(指前一个密文)呢?这个值是前一个密文。我们有前一个密文吗?是的,我们有。

请记住解密的定义:它接收一个密文和密钥,然后进行解密。因此,在算法一开始,你就拥有了所有的密文。所以,你需要当前的密文,你拥有它;你需要前一个密文,你也拥有它。你拥有所需的一切,无需等待前面的块完成。

因此,所有这些解密块实际上可以并行运行,这相当不错。

总结 📝

本节课中我们一起分析了CBC模式的效率特性。

我们分析了加密是否可以并行化,结论是不可以。因为每个块的加密都依赖于前一个块的密文输出,形成了顺序依赖。

我们也分析了解密是否可以并行化,结论是可以。因为在解密开始时,所有密文块都已作为输入可用,每个块的解密操作是独立的。

这种加密与解密在并行性上的不对称性,是CBC模式的一个重要效率特征。

105:CBC模式填充

在本节课中,我们将要学习在CBC(密码块链接)加密模式中,当消息长度不是块大小的整数倍时,如何处理消息填充问题。我们将探讨为什么需要填充、填充不当可能带来的风险,以及一种常见的解决方案。

概述

上一节我们介绍了CBC模式的基本工作原理。本节中我们来看看当待加密消息的长度不是块大小的整数倍时,会发生什么情况,以及如何通过“填充”来解决这个问题。

消息长度问题

CBC模式要求每个明文块的大小必须与加密算法(如AES)的块大小完全一致。例如,AES的块大小是128位(16字节)。如果消息长度恰好是128位的整数倍(如256字节),那么一切正常。你可以将消息分成两个完整的块进行加密。

但如果消息长度是257位呢?那么前两个块各占128位,最后会剩下一个孤立的位。这就引出了一个关键问题:当消息长度不是块大小的整数倍时,加密过程还能正常工作吗?

答案是不能。加密协议在这种情况下没有定义。具体来说:

  1. 在XOR操作中,你需要将128位的密文块与不足128位的明文块进行异或,这是未定义的。
  2. 块加密函数(如AES加密)要求输入恰好是一个完整块(128位),你无法向其输入一个不完整的块。

因此,我们需要一种方法使所有消息的长度都变为块大小的整数倍。

填充的引入

解决方案是在消息末尾添加“填充字节”。这些是虚拟字节,不属于实际消息内容,只是为了将消息长度扩展到下一个块大小的整数倍。

例如,如果有一个257位的消息,最后一个块只有1位。你可以添加127个填充字节,使最后一个块也变成完整的128位。这样,整个消息就变成了384位(3个完整的块),可以进行正常的CBC加密。

填充过程可以用以下伪代码描述:

def pad_message(message, block_size):
    # 计算需要填充的字节数
    padding_length = block_size - (len(message) % block_size)
    # 如果消息长度恰好是块大小的整数倍,则填充一个完整的块
    if padding_length == 0:
        padding_length = block_size
    # 创建填充字节并附加到消息末尾
    padded_message = message + bytes([padding_length] * padding_length)
    return padded_message

填充方案的选择

现在的问题是:我们应该用什么值来填充呢?这需要非常小心,因为存在一些容易掉入的陷阱。

以下是几种可能但存在问题的方案:

  • 全部填充零:在消息末尾添加零直到达到块大小。
    • 问题:如果原始消息本身就以零结尾(例如“支付100美元”),解密方无法区分哪些零是消息内容,哪些是填充。错误地移除填充可能导致“100”变成“1”。
  • 全部填充一:在消息末尾添加一。
    • 问题:与填充零类似,如果消息以“1”结尾(例如“支付211美元”),同样会产生歧义。

填充任何恒定值都会导致“歧义”问题。解密方无法明确知道消息的结束位置和填充的开始位置。

一种可行的填充方案

为了避免歧义,需要更巧妙的方案。一种常见且有效的方法是:用填充的字节数量本身作为填充值

例如,如果需要填充5个字节,就用5个值为0x05的字节进行填充。如果需要填充一个完整的块(16字节),就用16个值为0x10的字节填充。

这种方案(如PKCS#7)之所以有效,是因为最后一个字节的值明确告诉了解密方需要移除多少填充字节。即使消息末尾的字节恰好与填充值相同,解密算法也能通过检查倒数第二个、第三个字节等来正确解析。

以下是一个示例:

原始消息: "Hello" (5字节)
块大小: 8字节
需要填充: 3字节
填充后消息: "Hello" + 0x03 + 0x03 + 0x03

解密后,读取最后一个字节0x03,就知道需要移除最后3个字节。

总结

本节课中我们一起学习了CBC模式中的填充问题。我们了解到,由于块加密算法要求输入长度固定,当消息长度不是块大小的整数倍时,必须在末尾添加填充字节。简单地用零或恒定值填充会导致歧义,使解密方无法正确恢复原始消息。一种稳健的解决方案是使用填充长度本身作为填充值,这能确保填充可以被明确识别和移除。在后续作业中,你将有机会更深入地探索和实践不同的填充方案。

106:CBC模式安全性 🔐

在本节课中,我们将要学习CBC(密码块链接)模式的安全性。我们将探讨其安全性的前提条件,特别是初始化向量(IV)的重要性,并分析如果错误使用IV会如何导致信息泄露。最后,我们会将CBC模式与之前学过的ECB模式进行对比。


CBC模式的安全性要求

上一节我们介绍了CBC模式的工作原理和效率特性。本节中我们来看看其安全性。

通过深入分析可以证明,在特定条件下,CBC模式是IND-CPA(不可区分的选择明文攻击)安全的。这个方案安全的一个非常重要的前提是:必须随机生成初始化向量(IV)。这是CBC模式满足安全要求的必要条件。

如果IV不是随机生成的,例如每次都使用一个固定的值,那么当相同的明文被加密时,就会产生相同的密文。加密过程就变回了确定性的,这将导致我们在IND-CPA游戏中失败。

需要提醒的是,仅仅在加密方案中加入随机性,并不能保证它就是IND-CPA安全的。例如,一个非常愚蠢的方案:先使用ECB模式加密,然后在末尾附加一些随机比特。即使加入了随机性,这个方案仍然是不安全的。

以下是两个关键点:

  • 确保IV是随机生成的。
  • 随机性本身并不能保证安全,但它是实现安全的一个必要条件。

重复使用IV的后果

我们已经知道,如果两次使用相同的IV,并且两次的明文也相同,那么产生的密文输出也会相同。这就像“企鹅图”案例一样,会导致信息泄露。但情况可能更糟。

考虑两个不同的消息,但它们开头有一些相同的词。例如,两条消息都以“Dear Bob”开头。那么,它们的前两个明文块可能是相同的。

假设我们用相同的IV和密钥加密这两条消息。让我们逐步分析:

  1. 加密第一条消息的第一个块(橙色)。输入是橙色明文块、红色IV和密钥,输出得到某个密文块 C1
  2. 加密第二条消息的第一个块(同样是橙色)。输入是相同的橙色明文块、相同的红色IV和相同的密钥。输出将得到完全相同的密文块 C1

攻击者看到这两个相同的 C1,就能推断出这两条消息是以相同的第一个块开始的。

情况会变得更糟。让我们看第二个块:

  1. 加密第一条消息的第二个块(蓝色)。输入是蓝色明文块和上一个密文 C1
  2. 加密第二条消息的第二个块(蓝色)。输入也是蓝色明文块和上一个密文 C1

由于输入完全相同,第二个密文块 C2 也必然相同。现在,攻击者知道这两条消息的前两个块都相同。

最终,当加密到第一个出现差异的块时(例如,第三条消息块一个是紫色,一个是绿色),密文才会开始变得不同。但在此之前,我们已经泄露了本不该泄露的信息:这两条消息开头有相同的几个词。

因此,重复使用IV不仅会使方案变成确定性的(相同明文产生相同密文),还会泄露“相似”消息的信息——这里“相似”指的是消息开头有相同的几个块。我们必须非常小心,避免两次使用相同的IV。


总结与证明思路

让我们回顾一下CBC模式及其安全性和工作原理。

加密方案流程如下:

  1. 将消息分割成块。
  2. 选择一个随机IV(不要只是选0)。
  3. 根据图示计算密文。

在保证不重复使用IV的前提下,CBC模式是IND-CPA安全的。如果你想证明这一点,会用到一种称为“归约证明”的方法。其思路大致是:如果一个攻击者能够攻破CBC模式,那么他也能攻破底层的分组密码。如果我们假设底层的分组密码是安全的,那么我们就可以认为CBC模式也是安全的。


CBC与ECB的视觉对比

在我们结束CBC模式的讨论前,最后再看一次“企鹅图”。这次我们将使用CBC模式来加密它。

这意味着对于每个像素块,我们不再使用ECB模式,而是使用CBC模式进行加密。现在,你完全看不到企鹅的轮廓了。它仍然隐藏在密文中(可以被解密),但已被完全伪装起来。这比ECB模式中企鹅轮廓依稀可见的情况要好得多。

CBC加密效果:
(图像显示为随机噪声,无可见图案)

ECB加密效果(对比):
(图像显示依稀可见的企鹅轮廓)


本节课中我们一起学习了CBC模式安全性的核心要求,即必须使用随机且唯一的IV。我们分析了重复使用IV会导致确定性加密和部分明文信息泄露的风险。最后,通过图像加密的直观对比,我们看到了CBC模式在隐藏数据模式上相对于ECB模式的巨大优势。记住,正确使用随机性是构建安全加密方案的关键。

107:CTR模式设计 🧩

在本节课中,我们将一起学习并设计第二种加密方案——CTR模式。我们将逐步构建它,并分析其所有特性。

概述

CTR模式的设计灵感并非直接源于分组密码,而是更多地借鉴了一次性密码本的思想。我们的目标是模仿一次性密码本的安全行为,但避免每次加密都需要生成新密钥的繁琐过程。为此,我们将结合一次性密码本和分组密码的特性来构建这个新方案。

设计思路

上一节我们回顾了一次性密码本,本节我们来看看如何改进它。一次性密码本在密钥不重复使用的情况下是安全的。每次加密使用不同的密钥与明文进行异或运算,就能得到密文。我们的目标是模仿这种行为,但无需每次都生成新密钥。

为了实现这个目标,我们需要引入第二个关键想法:分组密码。回想一下,如果攻击者不知道密钥,分组密码的输出看起来是随机的。一个安全的分组密码,其输出与随机排列是不可区分的。这意味着,从攻击者的视角看,这个输出值就像随机生成的一样好。

那么,如果我们利用这个“看起来随机”的输出,并结合一次性密码本的思想呢?这样我们就不必每次都生成新密钥了。这就像是将一次性密码本和分组密码的伪随机性融合在一起。

构建CTR模式

我们将融合上述两个想法,构建一个基于分组密码、但形似一次性密码本的加密方案。

我们可能需要不止一个分组的随机性,以便加密更长的消息或多个消息。因此,我们需要多次运行分组密码加密。只要攻击者不知道密钥,所有这些输出在攻击者看来都如同随机比特。我们可以生成足够多的这种伪随机输出,然后将它们用作一次性密码本的密钥流。

具体操作如下:我们生成伪随机输出,然后将其与明文进行异或运算以得到密文。如果还有更多明文,我们就生成更多伪随机输出,继续异或加密。因此,这个方案的上半部分是利用分组密码生成伪随机输出,下半部分则是之前的一次性密码本加密过程。

关键组件:随机数与计数器

这个方案还缺少一个关键部分:我们输入到分组密码里的是什么?目前方案是确定性的,如果多次运行相同的加密,会得到相同的伪随机输出,这缺少了随机性。

因此,最后的拼图是将一些随机性作为分组密码的输入。我们选择一个随机数,称为Nonce,并将其输入分组密码。用密钥加密后,结果应该是随机的。

然而,如果每次都用相同的Nonce,那么每次的输出都会相同,这相当于重复使用一次性密码本的密钥,是不安全的。为了解决这个问题,我们引入一个计数器

以下是具体步骤:

  1. 取Nonce加上0,输入分组密码,得到第一个伪随机输出块。
  2. 取Nonce加上1,输入分组密码,得到第二个伪随机输出块。
  3. 取Nonce加上2,输入分组密码,得到第三个伪随机输出块,依此类推。

这样,所有的输出都会不同。因为分组密码表现得像随机排列,即使输入只改变一个比特(例如从0变为1),产生的输出也会完全不同且不可预测。这让我们能获得大量看似随机的输出来用作一次性密码本的密钥。

CTR模式工作原理

我们将两个想法结合,设计出了称为CTR(计数器)模式的方案。请注意,计数器必须为每个分组递增,以确保每个分组密码的输出都不同。

其工作流程如下:

  1. 选择一个随机生成的Nonce。每次加密只需生成一次Nonce
  2. 取Nonce,后面附加(或加上)0,用你的密钥进行分组密码加密,得到第一个伪随机输出块,这就是你的一次性密码本密钥。
  3. 将此密钥与第一段明文进行异或运算,得到第一段密文。
  4. 如果还有更多明文,取Nonce加上1,用相同的密钥进行分组密码加密,得到下一个伪随机输出块。
  5. 将其与下一段明文异或,得到下一段密文。
  6. 重复此过程,直到整个消息加密完毕。

在整个过程中,你使用的是同一个密钥。这正是我们为了避免每次使用不同密钥而设定的目标。所有输出之所以不同,是因为计数器在不断变化。密钥保持不变且攻击者未知,计数器确保了输出的差异性。

用文字概括就是:你将消息分割,生成看似随机的输出块,然后将每个明文块与对应的分组密码输出块进行异或加密。

总结

本节课中,我们一起学习了CTR模式的设计。我们从一次性密码本的安全特性出发,结合了分组密码能产生伪随机输出的能力,通过引入Nonce和计数器,构建了一个既能保证安全、又无需每次加密都更换密钥的高效加密方案。CTR模式的核心在于使用相同的密钥,但通过递增的计数器输入来生成独一无二的密钥流,从而实现对明文的加密。

108:CTR模式解密 🔐

在本节课中,我们将学习计数器模式(CTR)的解密过程。我们将通过图解和公式两种方式来理解解密是如何进行的,并解释为什么解密时需要使用与加密相同的正向块密码操作。


概述

上一节我们介绍了CTR模式的加密过程。本节中,我们来看看如何对CTR模式加密的密文进行解密。解密的核心思想与一次性密码本(One-Time Pad)的解密原理相同。


解密原理:图解方式

观察CTR模式的加密图,我们可以将其视为一种一次性密码本。加密时,明文与一个由块密码生成的“垫片”(pad)进行异或运算,得到密文。

以下是加密过程的图示:

那么,一次性密码本是如何解密的呢?解密时,你需要将密文与相同的“垫片”再次进行异或运算,即可恢复出原始明文。

因此,接收者Bob需要做的是:

  1. 使用与发送者Alice完全相同的算法(相同的密钥、Nonce和计数器值)重新生成那个“垫片”。
  2. 将收到的密文与此“垫片”进行异或运算。

解密过程的图示如下:


关键点:使用块密码加密模式

这里有一个容易令人困惑的关键点:在解密过程中,为了重新生成“垫片”,我们必须使用块密码的加密模式,而不是解密模式。

原因在于,块密码在CTR模式中的作用仅仅是生成一串看似随机的比特流(即“垫片”)。为了在解密端得到完全相同的比特流,我们必须以完全相同的方向运行相同的块密码算法。在加密过程中,这个方向就是加密,因此在解密时,我们也必须使用加密操作。

虽然解密时使用加密块看起来有违直觉,但这是必要的,以确保双方生成的“垫片”一致。生成“垫片”后,只需将其与密文异或即可得到明文。

这个过程可以总结为以下伪代码:

# 解密过程伪代码
def ctr_decrypt(ciphertext, key, nonce):
    plaintext = b""
    for i, block in enumerate(ciphertext):
        # 使用加密模式生成垫片
        counter = nonce + i
        pad = block_cipher_encrypt(counter, key)
        # 将密文块与垫片异或
        plaintext_block = xor(block, pad)
        plaintext += plaintext_block
    return plaintext

解密原理:公式推导

我们也可以通过公式来理解这个过程。假设对于第 i 个块,加密过程为:

Cᵢ = Pᵢ ⊕ E(K, Nonce || i)

其中:

  • Cᵢ 是密文块
  • Pᵢ 是明文块
  • E 是块密码加密算法
  • K 是密钥
  • 表示异或运算

那么解密过程,即求解 Pᵢ,可以通过将等式两边同时与垫片 E(K, Nonce || i) 进行异或来完成:

Pᵢ = Cᵢ ⊕ E(K, Nonce || i)

可以看到,公式中使用的仍然是加密函数 E。这再次印证了,解密时需要运行与加密时完全相同的块密码加密操作来生成垫片。


总结

本节课中,我们一起学习了CTR模式的解密。

  1. 核心思想:CTR模式的解密原理与一次性密码本相同,即 密文 ⊕ 垫片 = 明文
  2. 关键步骤:解密者必须使用与加密者完全相同的参数(密钥、Nonce、计数器)和相同的块密码加密算法来重新生成“垫片”。
  3. 重要提示:解密过程中不需要使用块密码的解密功能。块密码在CTR模式中仅用作伪随机数生成器,因此始终运行在加密方向。

理解这一点,就掌握了CTR模式对称加解密的全过程。

109:CTR模式特性 🔐

在本节课中,我们将探讨CTR(计数器)模式的各种特性,包括其效率、并行化能力、填充需求以及安全性。我们将与之前讨论过的CBC模式进行对比,以更好地理解CTR模式的优势和潜在风险。

并行化能力 ⚙️

上一节我们介绍了CTR模式的基本工作原理,本节中我们来看看它的并行化能力。CTR模式在加密和解密过程中都支持并行处理。

以下是CTR模式支持并行化的原因:

  • 加密并行化:每个明文块的加密过程是独立的。要加密第30个块,只需计算 E(k, nonce+30) 生成密钥流,然后与第30个明文块进行异或。此过程不依赖于任何其他块。
  • 解密并行化:解密过程同理。要解密密文块,只需使用相同的 nonce 和计数器值重新生成密钥流,然后与密文块异或即可恢复明文。每个块的处理也是独立的。

因此,CTR模式在加密和解密两个方向上都支持高效的并行计算。

填充处理 📦

与CBC模式不同,CTR模式在处理明文长度不是分组长度的整数倍时,无需进行填充。

以下是CTR模式无需填充的原理:

  • 关键区别:在CTR模式中,明文从不直接输入分组密码进行加密。需要加密的是计数器值。
  • 处理最后一块:对于最后一块不完整的明文(例如只有1字节),算法仍会使用对应的计数器值生成完整的128位(16字节)密钥流。
  • 截断操作:然后,只取密钥流的前1字节(与不完整明文的长度相同)与明文进行异或,生成密文。剩余的127位密钥流被直接丢弃。
  • 解密过程:解密时,接收方用相同的 nonce 和计数器重新生成相同的128位密钥流,同样只取前1字节与密文异或,即可恢复原始明文。

由于分组密码的输入(计数器)始终是完整的块,因此没有填充要求。这种通过截断密钥流来适配明文长度的方式,是CTR模式的一个巧妙特性。

安全性分析 🔒

现在我们来分析CTR模式的安全性。与CBC模式类似,CTR模式的安全性也依赖于一个关键前提。

以下是CTR模式安全性的核心要点:

  • 安全性证明:在密码学中,可以证明(通过归约证明),只要每次加密都使用一个新的随机数(nonce),AES-CTR模式是IND-CPA安全的(即在选择明文攻击下具有不可区分性)。
  • 重用 nonce 的灾难性后果:如果两次加密使用了相同的 nonce 和密钥,那么生成的密钥流就会相同。这实质上等同于两次使用了相同的“一次一密”密钥
  • 两次一密的风险:正如我们之前课程所学的,两次一密是极不安全的。攻击者将两个密文异或,可以得到两个对应明文的异或值:c1 ⊕ c2 = (p1 ⊕ stream) ⊕ (p2 ⊕ stream) = p1 ⊕ p2。这可能会泄露大量信息,如果其中一个明文已知,另一个明文将立即被破解。

因此,绝对不要重复使用 nonce。否则,系统的安全性将彻底崩溃。

正确与错误的示例 🐧

为了直观理解,我们可以看一些图像加密的示例:

  • 正确使用(每次使用新 nonce:加密后的图像看起来是完全随机的噪声,没有任何原始图像的轮廓可见,这符合安全加密的预期。
  • 错误使用(重用 nonce:加密后的图像可能会泄露信息,安全性失效。这警示我们必须遵循正确的加密实践。

历史上就有因错误使用密码学而导致安全漏洞的案例。例如,某些学生项目在自行实现加密方案时,意外地重复使用了初始化向量(IV,在CTR模式下即 nonce),导致整个系统的安全防护形同虚设。

核心教训是:不要尝试自己设计和实现密码学方案。其中存在太多微妙的陷阱,例如 nonce 的意外重用,就足以摧毁整个系统的安全性。

总结 📝

本节课中我们一起学习了CTR模式的几个关键特性:

  1. 高效并行:CTR模式允许加密和解密过程完全并行化,提升了处理速度。
  2. 无需填充:由于明文不直接参与分组密码运算,CTR模式可以自然地处理任意长度的消息,无需填充。
  3. 安全依赖:CTR模式达到IND-CPA安全性的绝对前提是每次加密都使用一个唯一的、不可预测的 nonce。重用 nonce 会导致灾难性的安全失败,相当于实施不安全的“两次一密”。

请始终使用标准库中经过严格测试的加密实现,并确保正确管理 nonce 等参数。

110:切勿重复使用初始化向量 🔐

在本节课中,我们将探讨一个在对称加密中至关重要的安全实践:切勿重复使用初始化向量。我们将解释初始化向量的作用,分析重复使用它可能带来的安全风险,并强调遵循此最佳实践的重要性。


术语说明

上一节我们介绍了分组密码的不同工作模式。本节中,我们来看看一个在这些模式中常见的概念。

在加密方案中,有两个术语都指代每次加密时都需要不同的随机值:初始化向量随机数

  • 从本课程的目的出发,这两个术语可以互换使用。它们本质上都是每次加密消息时必须不同的随机值。
  • 技术上,IV和随机数存在一些非常细微的差别。例如,其中一个可以提前预测,而另一个则不能。但这并非本课程的重点。
  • 核心要记住的是:IV和随机数是功能非常相似的两个不同名称

核心原则:一次性使用

这个值只能使用一次。每次加密新消息时,都必须生成一个不同的IV。

切勿重复使用它们。

以下是重复使用IV可能导致的后果:

  • 信息部分泄露:在某些模式下,可能导致前几个加密块相同的信息被泄露。
  • 信息完全泄露:例如在CTR模式下,会直接泄露两条消息的异或值。

无论具体后果如何,重复使用IV都是一个糟糕的做法,它会让你之前构建的所有安全防护完全失效

所以,无论你称它为IV还是随机数,都请不要重复使用。


为何要避免重复使用:安全与复杂性

如果你选择重复使用IV和随机数,将面临双重困境。

首先,你必须进行极其复杂的分析。你需要深入思考在特定加密模式下,重复使用IV具体会泄露哪些信息。例如,在CBC模式下泄露的信息有限,而在CTR模式下则会泄露两条消息的异或值。这要求你对各种潜在的复杂攻击有深刻理解。

其次,最佳实践是根本不让这种情况发生。一个更好的方法是:绝不重复使用IV。这样一来,你完全不需要去思考那些微妙的、复杂的攻击场景。

遵循“不重复使用IV”的原则,你可以直接获得加密方案所承诺的安全保证,而无需进行艰难的、容易出错的安全分析。这无疑是一种更简单、更安全的选择。

111:模式对比 🔍

在本节课中,我们将快速比较刚刚介绍的两种加密模式:CBC模式与CTR模式。我们将分析它们各自的优缺点,并探讨在不同应用场景下如何选择。

上一节我们分别介绍了CBC模式和CTR模式的工作原理。本节中,我们来看看这两种模式在实际应用中的权衡与选择。

实际上,这两种模式存在不同的权衡点,哪一种更好取决于您试图优化的目标。

以下是两种模式的主要考量因素:

  • 性能与速度:如果您需要一个运行速度极快的算法,那么CTR模式可能更适合您,因为它在加密和解密两个方向上都可以实现并行计算。请记住,在CBC模式中,您只能在解密方向进行并行化。
  • 安全性与容错:如果您对安全性要求极高,甚至有些“偏执”,那么CBC模式可能更好。因为我们看到,如果初始化向量被重复使用,CBC模式仅会泄露前几个块相同的信息,而CTR模式则会泄露两条消息全部明文的异或结果。因此,在发生故障时,CBC模式的失效方式相对更安全,泄露的信息也更少。

所以,并非一种模式绝对优于另一种。这更像是一种权衡取舍。您可以根据自己试图优化的目标,来选择使用哪一种模式。

本节课中,我们一起学习了CBC模式与CTR模式的对比。关键在于理解它们在不同维度(如速度和安全性)上的权衡,从而能够根据具体的应用需求做出合适的选择。

112:其他加密模式 🔐

在本节课中,我们将学习除CBC和CTR之外的其他分组密码加密模式。我们将了解如何分析一个加密模式,并思考其安全性、效率等方面的权衡。课程最后会提供一个名为CFB的加密模式供你自行分析练习。

上一节我们介绍了CBC和CTR等常见加密模式。本节中我们来看看其他可能存在的加密模式,并学习如何分析它们。

需要指出的是,其他加密模式确实存在。有时在考试中,可能会提出一个新的模式让你分析思考。

分析时的权衡问题非常相似。我们需要思考以下几个核心问题:

以下是分析加密模式时需要考量的一系列关键问题:

  • 填充是否必要?
  • 在加密方向上能否并行化处理?
  • 在解密方向上能否并行化处理?
  • 如果重复使用初始化向量(IV)会怎样?
  • 会泄露什么信息?

如果你想在家练习,可以思考以上所有问题。这里有一个练习供你尝试。

这个练习叫做CFB(Cipher Feedback)加密模式。我们不会在课上现场分析,但你可以回家后思考该方案的权衡利弊,并尝试自己回答上述问题。

如果你想核对答案,我们已将其附在此处。你可以自行查阅验证。

本节课中我们一起学习了如何分析一个加密模式,重点关注了填充必要性、并行化能力、IV重用风险和信息泄露等关键权衡点。通过课后分析CFB模式的练习,你可以巩固这些分析技能。

113:完整性缺失 🔓

在本节课中,我们将要学习分组密码链接模式的一个关键局限性:它们无法提供完整性真实性。这意味着攻击者即使不知道密钥,也可能篡改密文,导致接收者解密出错误的信息。我们将通过一个具体的例子来理解这种攻击是如何发生的。

上一节我们介绍了分组密码的链接模式(如CBC和CTR)可以提供IND-CPA保密性。本节中我们来看看为什么这些模式无法保证消息的完整性。

完整性缺失的问题

分组密码及其链接模式是为保密性设计的,而非为完整性真实性设计。这意味着,如果攻击者Mallory能够在消息通过信道发送时对其进行篡改,我们实际上无法保证能检测到错误的发生。加密可以确保攻击者无法读取我们的消息,但仅仅因为信息被加密,并不意味着攻击者无法篡改它。

为了给出一个具体的例子,假设我们正在使用CTR模式。使用CTR模式的方式是:获取消息,使用分组密码生成一个一次性填充,然后将消息与该填充进行异或运算,得到密文。

假设Alice发送给Bob的消息是“Pay $100”。她生成了一个随机的填充,然后得到了密文。问题是,当这个密文通过网络发送时,Mallory可能能够篡改密文,导致Bob解密出不同的内容。

一个具体的篡改攻击示例

考虑一个威胁模型:Mallory知道原始消息的内容(例如,通过某种泄露)。她知道原始消息是“Pay $100”,但她希望Bob解密出的消息是“Pay $900”。Mallory的目标是接收密文,并以某种方式篡改它,使Bob解密出“900”而不是“100”。

为了实现这个目标,首先注意到密文的大部分字节根本不需要修改。如果不触碰其他字节,解密出的前几个词仍然是“Pay”、“空格”、“Ma”、“空格”、“$”和“00”。因此,如果攻击者想将“100”改为“900”,唯一需要触碰的字节就是对应字符“1”的那个字节。

现在,我们可以通过一点代数运算来找出Mallory应该如何更改这个字节。当前该字节的值是58(十六进制),Mallory需要将其改为多少,才能使Bob解密时得到“900”?

以下是计算步骤:

  1. 首先,写出异或运算的定义:Ciphertext = Plaintext XOR Pad
  2. 我们知道密文字节 C = 58
  3. 我们知道原始明文字节 P = 31(字符‘1’的ASCII码十六进制值)。
  4. 因此,我们可以解出填充字节 PadPad = C XOR P = 58 XOR 31 = 69
  5. 现在,Mallory希望解密出的明文字节 P' = 39(字符‘9’的ASCII码十六进制值)。
  6. 她可以计算出需要的新密文字节 C'C' = P' XOR Pad = 39 XOR 69 = 50

所以,Mallory只需将密文中的字节58改为50。当Bob用相同的密钥和计数器(生成相同的填充字节69)解密时,他会计算:P' = C' XOR Pad = 50 XOR 69 = 39,即字符‘9’。这样,Bob解密出的消息就变成了“Pay $900”。

通过利用一点代数知识和对原始明文的了解,Mallory成功地篡改了消息,并且她不需要知道密钥。这证明了攻击者即使不知道密钥,也能破坏消息的完整性。

CBC模式的情况

对于CBC模式,也可以尝试类似的攻击,但会稍微复杂一些。因为篡改单个密文块会影响后续的解密结果(误差传播)。然而,核心问题依然存在:Bob解密出的内容,他无法判断是否正确,或者是否被篡改过。我们仍然无法保证完整性或真实性。

本章总结

本节课中我们一起学习了分组密码链接模式在完整性方面的缺陷。

我们回顾了分组密码的发展:从确定性的ECB模式(不安全),到引入随机IV的CBC和CTR模式(在谨慎设计下可获得IND-CPA保密性)。我们分析了这些模式的各种属性,如并行性、填充要求以及IV/Nonce重用带来的危险。

最后,我们通过一个CTR模式下的具体攻击示例,清晰地展示了保密性不等于完整性。像CBC或CTR这样的分组密码链接模式可以阻止攻击者获知消息内容,但无法阻止他们篡改消息并导致接收者解密出不同的内容。

因此,我们需要寻找其他密码学方案(例如消息认证码MAC或认证加密)来提供我们所需的完整性和真实性保障。关于分组密码及其链接模式的讨论就到此为止。

114:哈希函数定义 🔐

在本节课中,我们将要学习密码学哈希函数。哈希函数本身并不提供完整性或认证性,但它是构建能够提供这些安全属性的方案(如消息认证码)的必要基础模块。此外,哈希函数还有许多其他用途,因此了解它非常重要。

上一节我们介绍了分组密码及其工作模式(如CBC和CTR),它们能提供保密性,但无法保证消息的完整性。本节中,我们来看看一个关键的密码学组件——哈希函数。

哈希函数的数学定义

哈希函数 H 接受一个任意长度的输入 M,并输出一个固定长度为 n 比特的哈希值 h。其数学表示如下:

H: {0, 1}* -> {0, 1}^n

其中,{0, 1}* 表示任意长度的比特串,{0, 1}^n 表示固定长度为 n 比特的比特串。

哈希函数的基本属性

以下是哈希函数应具备的基本属性:

  • 确定性:对于相同的输入,哈希函数必须总是产生相同的输出。
  • 高效性:计算哈希值的过程应该是高效的,不应消耗过多计算资源。
  • 不可预测性:哈希函数的行为应该是不可预测的。即使输入发生微小的改变(例如改变一个比特),其输出也应该看起来是随机的、全新的,无法从原输出推导出来。

一种理解密码学哈希函数的方式是将其视为数据的“指纹”。就像每个人的指纹是唯一的一样,每个(不同的)数据也应该产生一个唯一的“指纹”(哈希值)。这为数据比对提供了一种高效的方法。

哈希函数的应用示例:文档比对

以下是哈希函数的一个典型应用场景:

假设爱丽丝和鲍勃各自持有一个非常大的文件(例如1GB)。他们想确认双方的文件是否完全相同。一种低效的方法是直接通过网络传输整个文件进行逐字节比较。

更高效的做法是,双方各自在本地计算文件的哈希值。由于哈希值是固定长度(如128比特)的“指纹”,他们只需通过网络交换这个很小的哈希值。如果两个哈希值相同,那么文件极大概率是相同的;如果哈希值不同,则文件必然不同。

本节课中我们一起学习了密码学哈希函数的定义、基本属性以及一个简单的应用示例。哈希函数通过将任意长度的数据映射为固定长度的“指纹”,为高效的数据比对等操作奠定了基础。在接下来的章节中,我们将利用哈希函数来构建能够提供完整性和认证性的方案。

115:哈希函数的安全性 - 单向性 🔐

在本节课中,我们将要学习哈希函数的一个核心安全属性:单向性。我们将了解它的正式定义、直观理解,并通过一个简单的例子来掌握其核心思想。


上一节我们介绍了哈希函数的基本概念,本节中我们来看看其第一个关键安全属性:单向性。

单向性,有时人们也称之为哈希是单向的。其正式定义如下:如果给定一个输出值 y,对于攻击者而言,很难找到一个输入值 X,使得该输入经过哈希函数计算后能得到给定的输出 y

这个定义有些冗长。或许可以这样直观理解:这就像一场挑战。我随机选择一个输入(不告诉你是什么),将其输入哈希函数,得到一个输出值。然后,我将这个输出值发送给你,并提问:“这里有一个输出值。你能告诉我任何一个能哈希出这个输出值的输入吗?”

你的挑战就是找到任意一个输入 X,使得 hash(X) = yy 是我给你的输出)。你可以选择我最初使用的那个输入,也可以选择一个完全不同的、但能产生相同哈希值的输入。定义之所以略显复杂,正是因为攻击者不需要找出我使用的原始输入,任何能产生相同哈希值的输入都算攻击者获胜。

因此,人们有时将其理解为哈希函数“不可逆”——给定输出,很难找到对应的输入。但更准确地说,由于可能存在多个输入对应同一个输出(哈希碰撞),只要攻击者能找到其中任何一个输入,他就赢得了挑战。

以下是单向性的一个非正式数学描述:

对于所有高效算法 A,给定 y = hash(X) (X 随机选取),
A 成功找到任意 X' 使得 hash(X') = y 的概率是可忽略的。

为了更清晰地理解,我们来看一个不满足单向性的哈希函数例子。

假设有一个哈希函数,其规则是:无论你提供什么输入,它都固定输出数字 42。用代码表示如下:

def bad_hash(input_data):
    return 42  # 总是返回常数 42

现在,我们来进行单向性挑战。我背过身,随机选择一个输入 X(比如 "secret"),计算 bad_hash("secret"),得到结果 42。然后我告诉你:“我使用的哈希函数输出是 42。请找出任何一个能哈希出 42 的输入。”

在这种情况下,你能找到这样的输入吗?答案是肯定的。因为该哈希函数对任何输入都输出 42

以下是你可以轻松获胜的几种输入选择:

  • 你可以猜我用的原始输入 "secret"
  • 你可以选择单词 "potato",因为 bad_hash("potato") 也等于 42
  • 事实上,任何你选择的输入(如 "hello"12345 等)都会输出 42

由于攻击者(你)能够轻易找到一个输入映射到我提供的输出,因此这个 bad_hash 函数不满足单向性。反之,如果一个哈希函数能让攻击者在面对此类挑战时成功概率极低,我们就说它是单向的。


总而言之,本节课中我们一起学习了哈希函数的单向性属性。其核心是:给定一个哈希输出,在计算上难以找到任何一个能产生该输出的原始输入。这不仅是“不可逆”的直观感受,更严谨地包含了应对可能存在的多个前像的情况。一个常数输出函数是典型的反例,它完全不具备单向性。

116:哈希函数的安全性 - 抗碰撞性 🔐

在本节课中,我们将要学习哈希函数的第二个核心安全属性:抗碰撞性。我们将详细解释其定义、与单向性的区别,以及为什么它在计算上是不可行的。


概述

上一节我们介绍了哈希函数的单向性。本节中,我们来看看另一个关键的安全属性:抗碰撞性。虽然这两个概念初看相似,但它们存在微妙的区别。我们将通过挑战游戏的形式来阐明这种区别,并解释为什么抗碰撞性对于哈希函数的安全性至关重要。


抗碰撞性挑战与单向性挑战的区别

在单向性挑战中,挑战者会提供一个特定的哈希输出(例如 H(x) = 42),攻击者的目标是找到一个输入 x',使得 H(x') 也等于 42。攻击者必须匹配挑战者给出的特定输出。

相比之下,在抗碰撞性挑战中,挑战者不会提供任何输出。攻击者的任务是自行找到任意两个不同的输入 xx',使得它们的哈希输出相同,即 H(x) = H(x')。攻击者不需要匹配任何预先给定的值,只需找到一对碰撞即可。

核心区别公式化描述:

  • 单向性挑战: 给定 y = H(x),寻找 x' 使得 H(x') = y
  • 抗碰撞性挑战: 寻找任意一对 (x, x'),其中 x ≠ x',但 H(x) = H(x')

成功找到这样一对输入的行为,就称为发现了一个碰撞


碰撞必然存在吗?

一个自然的问题是:能否构造一个完全没有碰撞的哈希函数?答案是否定的。原因在于哈希函数的输入空间和输出空间大小不同。

以下是分析:

  • 输入空间: 哈希函数接受任意长度的比特串,因此可能的输入数量是无限的。
  • 输出空间: 哈希函数产生固定长度的输出(例如256比特),因此可能的输出数量是有限的,具体为 2^n 个(n为输出比特长度)。

根据鸽巢原理(Pigeonhole Principle),当试图将无限多的“鸽子”(输入)放入有限数量的“巢穴”(输出)时,必然至少有一个巢穴中包含多于一只鸽子。这意味着碰撞在数学上是必然存在的。

结论: 对于任何哈希函数,碰撞都必然存在。因此,抗碰撞性的安全目标不是“完全防止碰撞”,而是“使寻找碰撞在计算上不可行”。


抗碰撞性的正式定义

抗碰撞性要求,对于任何在多项式时间内运行的攻击者,找到一对碰撞 (x, x') 的概率是可以忽略不计的。

虽然碰撞在理论上是存在的,但一个安全的哈希函数应确保攻击者无法在现实可行的时间内(例如,在宇宙寿命结束前)找到它们。这被称为计算不可行性

注意: 单向性也基于同样的“计算不可行性”理念来定义。两者都要求攻击者在有限的实际时间内无法解决相应的挑战。


寻找碰撞的难度:生日攻击

你可能会问,找到碰撞到底有多难?这涉及到生日悖论和由此衍生的生日攻击

以下是其核心思想:
对于一个输出长度为 n 比特的哈希函数,一个通用的寻找碰撞的算法平均需要尝试大约 2^(n/2) 次哈希计算。

例如:

  • 对于 n=256 比特的输出(如SHA-256),寻找碰撞大约需要 2^128 次尝试。
  • 2^128 是一个天文数字,远超出任何多项式时间算法的能力,甚至在可观测宇宙的寿命内也无法完成。

代码示例(概念性描述):

# 这是一个概念性示例,说明生日攻击的复杂度是 O(2^(n/2))
# 在实际中,我们不会这样暴力搜索
n = 256 # 输出比特长度
birthday_attack_complexity = 2 ** (n // 2) # 2^128
print(f"对于 {n} 比特哈希,生日攻击复杂度约为 {birthday_attack_complexity} 次操作")

因此,只要哈希函数的输出长度足够大(例如256位或以上),就可以有效抵御生日攻击,从而满足抗碰撞性的要求。


总结

本节课中我们一起学习了哈希函数的抗碰撞性。我们明确了它与单向性的关键区别:抗碰撞性要求攻击者自行找到任意一对产生相同输出的不同输入,而单向性要求攻击者逆转一个给定的输出。

我们了解到,由于输入空间无限而输出空间有限,碰撞必然存在。因此,抗碰撞性的安全目标在于使寻找碰撞在计算上不可行。最后,我们通过生日攻击的概念,解释了为什么足够长的哈希输出(如256位)可以确保在实际中无法找到碰撞,从而保障了哈希函数的安全性。

117:哈希函数实例 🔐

在本节课中,我们将学习几种具体的哈希函数算法。我们将了解它们的设计目标、历史背景以及当前的安全性状况。哈希函数旨在提供单向性和抗碰撞性,但并非所有算法都成功实现了这些目标。


一些历史上的哈希函数示例

以下是几种在现实中曾使用或仍在使用的哈希函数。我们将逐一探讨它们的特点和现状。

MD5:一个已被攻破的早期尝试

MD5是一种非常古老的哈希函数,设计于20世纪90年代,旨在提供安全性。然而,时至今日,人们已经发现了针对MD5的各种攻击。

  • 单向性被攻破:给定一个MD5输出值,攻击者可以找到一个能产生相同哈希值的输入。
  • 抗碰撞性被攻破:攻击者可以找到两个不同的输入,但它们的MD5哈希值相同。

因此,尽管MD5最初设计为安全的,但截至2025年,它已被完全攻破。它不应再被用于任何密码学安全目的。不过,人们仍可能将其用于其他非安全目的,例如生成文档指纹。

示例:有人找到了一个图片文件,其MD5哈希值恰好是某个特定值,这证明了其脆弱性。

# 示例:计算字符串的MD5哈希值(仅作演示,切勿用于安全场景)
import hashlib
input_data = b"Hello World"
md5_hash = hashlib.md5(input_data).hexdigest()
print(f"MD5 hash of 'Hello World': {md5_hash}")

SHA-1:一度安全,但现已过时

在MD5之后,出现了SHA-1哈希函数。它曾安全了一段时间,但攻击方法随后被发现。截至2017年,SHA-1也不再安全。

  • 攻击者可以找到SHA-1的碰撞。
  • 给定一个SHA-1输出,攻击者可以逆向找到一个匹配的输入。

因此,尽管一些旧系统可能仍在使用SHA-1,但它已不再适用于安全场景。


当前仍在使用的哈希函数

上一节我们介绍了两种已被攻破的哈希函数。本节中,我们来看看目前被认为更安全的选项。

SHA-2:目前广泛使用的选择

SHA-2系列哈希函数是SHA-1的后继者。一个显著特点是其输出长度增加了(例如SHA-256输出256位,SHA-512输出512位),更长的输出理论上使寻找碰撞更加困难。

截至2025年,我们认为SHA-2尚未被完全攻破。然而,它存在一些需要注意的问题:

  • 长度扩展攻击:SHA-2的某些变体容易受到一种名为“长度扩展攻击”的威胁。这种攻击并不直接破坏单向性或抗碰撞性,而是利用了哈希函数结构的另一个弱点。你可能会在作业中遇到或在线查到相关内容。

因此,SHA-2是一个可选项,它本身未被完全破解,但仍存在一些需要警惕的细微攻击方式。目前,它是许多人使用的安全哈希函数。

SHA-3:更新的标准

比SHA-2更优的选择是SHA-3。它是较新的哈希标准,其内部工作原理与SHA-2有所不同。

就目前所知,SHA-3也未被攻破。它代表了当前哈希函数技术的较新进展。


总结与核心要点

本节课中,我们一起学习了哈希函数的具体实例。我们从历史角度回顾了MD5和SHA-1如何因密码分析进展而被攻破,然后探讨了目前仍在使用的SHA-2和SHA-3。

核心概念总结

  • 哈希函数的安全性会随时间演变:随着计算能力提升和密码分析技术发展,曾经安全的算法可能变得脆弱。
  • 选择当前公认安全的算法:对于新的应用,应选择像SHA-256(属于SHA-2家族)SHA-3这样目前未被发现严重漏洞的算法。
  • 警惕已知攻击:即使算法整体安全,也可能存在像长度扩展攻击这样的特定弱点,需要在应用时采取相应防护措施。

请记住,一些旧的哈希函数因多年的研究而被攻破,而其他一些目前被认为是安全的,但未来人们仍可能在其中发现缺陷。在密码学中,保持对算法状态更新的了解至关重要。

118:长度扩展攻击 🔐

在本节课中,我们将要学习一种针对特定哈希函数构造的攻击方式——长度扩展攻击。这种攻击虽然不直接破坏哈希函数的核心安全属性,但在某些应用场景下可能带来安全风险。

概述

长度扩展攻击利用了某些哈希函数(如SHA-256)的构造特性。攻击者即使不知道原始消息的具体内容,也能在已知其哈希值和长度的情况下,计算出“原始消息拼接上新消息”后的哈希值。

上一节我们介绍了哈希函数的基本安全属性,本节中我们来看看长度扩展攻击是如何工作的。

攻击原理

假设我选择了一个秘密值 x,并计算了它的哈希值 H(x)。我将这个哈希值以及原始消息的长度(例如10个字符)告诉你,但不告诉你 x 本身是什么。

长度扩展攻击指出,如果你获得了这个哈希值 H(x) 和原始消息长度,你就可以从哈希计算过程中断的地方继续执行,从而计算出 H(x || y) 的哈希值,其中 y 是你选择的任意消息。

核心公式

已知:H(x), len(x)
可计算:H(x || padding || y)

即使你完全不知道 x 是什么。

攻击的影响与局限性

这种攻击的影响需要根据具体的安全威胁模型来评估。

以下是长度扩展攻击的几个关键点:

  • 不破坏核心属性:该攻击既不破坏哈希函数的单向性(无法从 H(x) 反推出 x),也不破坏抗碰撞性(没有找到两个不同的消息产生相同的哈希值)。
  • 造成扩展风险:攻击者能够计算出“秘密+附加数据”的合法哈希值。在某些依赖哈希进行身份验证或数据完整性的协议中,这可能被利用。
  • 实例:SHA-2系列哈希函数(如SHA-256)容易受到此类攻击,而SHA-3则修补了这个问题。

总结

本节课中我们一起学习了长度扩展攻击。我们了解到,该攻击允许攻击者在已知原始消息哈希值和长度时,扩展消息并计算出新消息的合法哈希值。虽然它不直接攻破哈希函数的基础安全假设,但在设计密码学协议时仍需考虑此风险,并选择如SHA-3这类能抵抗长度扩展攻击的哈希函数。通过课程作业的实践,你将能更深入地理解这一攻击的具体过程。

119:哈希函数能否提供完整性?🔐

在本节课中,我们将探讨一个核心问题:哈希函数能否提供消息的完整性保证? 我们将通过分析不同的威胁模型来理解其答案并非绝对,并解释为什么在某些场景下哈希函数有效,而在另一些场景下则无效。


概述

哈希函数常被比作“数字指纹”,它能将任意长度的数据映射为固定长度的哈希值。一个关键特性是:对原始数据的任何微小改动,都会产生完全不同的哈希值。这听起来像是验证完整性的完美工具。但实际情况是否如此?我们将通过两个具体的威胁模型来剖析这个问题。


场景一:软件下载验证 ✅

上一节我们介绍了哈希函数的基本特性。本节中,我们来看看一个哈希函数能提供完整性的典型场景:软件发布与下载验证

在这个威胁模型中,我们假设攻击者可以篡改用户下载的软件文件,但无法篡改软件官网发布的哈希值

核心流程如下:

  1. 软件发布者(如Firefox)在官网上同时发布新版本的程序文件 P 及其哈希值 H = Hash(P)
  2. 用户爱丽丝可以从任何地方(包括不安全的镜像服务器)下载程序文件 P‘
  3. 爱丽丝在本地计算下载文件的哈希值 Hash(P‘)
  4. 她将计算结果与官网公布的哈希值 H 进行比较。

以下是验证过程的逻辑判断:

if Hash(P‘) == H:
    print("文件完整,未被篡改。")
else:
    print("文件已被篡改,请重新下载。")

为什么这个方案能提供完整性?因为攻击者马洛里如果想在程序 P 中植入病毒,生成一个恶意程序 P_malicious,他必须让 Hash(P_malicious) 等于官方哈希 H。这正是哈希函数抗碰撞性单向性要防范的:在计算上,无法找到另一个不同的输入产生相同的哈希输出。因此,只要官网的哈希值 H 是可信且未被篡改的,爱丽丝就能验证文件的完整性。

此场景的关键假设是:哈希值本身的发布渠道是安全、可信的。 我们假设攻击者无法修改官网上的哈希值。


场景二:开放信道通信验证 ❌

然而,我们最初关心的威胁模型并非如此。让我们回到之前几讲熟悉的场景:爱丽丝和鲍勃在一个不安全的信道上通信,攻击者马洛里可以拦截并篡改任何消息。

那么,如果爱丽丝简单地将消息 M 和它的哈希值 Hash(M) 一起发送给鲍勃,能否保证完整性?

通信流程如下:

  1. 爱丽丝发送组合消息:[M, Hash(M)]
  2. 攻击者马洛里截获该消息。
  3. 鲍勃最终收到消息。

这个方案存在致命缺陷。因为哈希函数的计算是公开的,没有任何秘密可言。马洛里可以轻松地进行以下操作:

  1. 将消息篡改为 M‘
  2. 计算篡改后消息的哈希值 Hash(M‘)
  3. 将发送给鲍勃的组合消息替换为 [M‘, Hash(M‘)]

当鲍勃收到 [M‘, Hash(M‘)] 后,他会重新计算 Hash(M‘),并与收到的哈希值比较。由于两者完全匹配,鲍勃会错误地认为消息 M‘ 是完整且未被篡改的。

核心问题在于:整个过程中没有使用任何密钥(Secret Key)。 哈希值本身不具备防篡改能力,因为任何人都能计算它。用公式表示,完整性未能实现的原因是:

攻击者可以生成: [ M‘, Hash(M‘) ] 替代 [ M, Hash(M) ]
而接收者无法区分。

因此,在攻击者能够同时篡改消息和其哈希值的威胁模型下,仅使用哈希函数无法提供完整性保证。


总结

本节课中我们一起学习了哈希函数在完整性保护中的作用,并理解了安全方案高度依赖于威胁模型

  • 哈希值发布渠道可信的模型下(如软件下载验证),哈希函数能有效提供完整性,因为它利用了哈希的单向性和抗碰撞性。
  • 通信信道完全开放的模型下(攻击者可篡改一切),仅发送“消息+哈希值”无法提供完整性,因为缺少密钥来保护哈希值本身不被伪造。

虽然哈希函数本身未能解决我们最初的通信完整性问题,但它是一个至关重要的密码学原语。接下来,我们将学习如何将哈希函数与密钥结合,构建出真正能抵御主动攻击者的完整性保护方案。

120:MAC定义 🧩

在本节课中,我们将学习消息认证码(MAC)的基本概念。MAC是一种密码学工具,用于确保消息的完整性和真实性。我们将从MAC的定义开始,逐步了解其工作原理、关键属性以及安全要求。


上一节我们介绍了哈希函数作为构建块,本节中我们来看看如何利用它们构建消息认证码(MAC),以实现消息的完整性和真实性保护。

我们目前处于左下角象限,目标是设计一种方案,在对称密钥场景下提供完整性和认证。我们继续假设Alice和Bob共享一个秘密密钥,且其他人都不知道这个密钥。他们如何获得这个密钥不是我们今天要讨论的问题。他们知道一个秘密密钥,其他人不知道。今天我们就当这是魔法。


记得在几节课前我们提到,提供完整性的方法是在消息上附加一个标签。这就像一个封印。因此,我们要做的是除了发送原始消息外,还要发送一个关于该消息的标签。这个标签将帮助我们证明消息未被篡改。

举个例子,这是你上次看到的方案。首先,Alice将消息和密钥输入到某个MAC算法(消息认证码)中。设计这个“黑盒子”里的内容是我们今天的目标。这个盒子里有一些代码,它接收两个参数。我们目前还不知道它们是什么,也不知道这个盒子如何工作,但我们会弄清楚。其输出是一个标签T。你也可以把它想象成一个签名,如果这样理解更直观的话。但这就是一个标签、封印或签名,它是与消息和密钥相关联的唯一值。发送消息时,你不仅发送原始消息,还发送这个标签。因此,你通过不安全的信道同时发送这两个值。

然后,当Bob收到消息和标签时,他将把消息、标签和密钥(共三个输入)传递给验证函数。同样,我们必须设计这个盒子里的内容。如果这个标签基于此密钥与消息匹配,那么我们就知道消息未被篡改,Bob可以安全地接收原始消息。

但是,如果消息或标签被篡改,如果Mallory试图改变消息中的哪怕一个比特,我们需要这个验证函数输出false,然后Bob会说“不,这个消息不正确,所以我不会接收它”。这就是完整性的含义:我们必须知道这个消息是否被篡改过,而添加标签将帮助我们做到这一点。

顺便提一个小的注意事项,在我们继续之前,你可能会怀疑我们以纯文本形式发送消息,Mallory可以阅读这个消息,你是对的。但请记住,目前我们只考虑完整性,我们只是试图防止消息被篡改。所以,目前我们并不关心Mallory是否能阅读消息。如果你想要同时具备完整性和机密性,我们必须组合多种方案,我们稍后会做。但现在,Mallory能阅读消息是可以的,我们只需要确保她不能修改它,而标签将帮助我们实现这一点。

好的,这是MAC的形式化定义。对于你设计的任何符合MAC“黑盒子”的方案,它都必须匹配这个定义。

首先,你必须说明密钥是如何生成的。目前,你可以假设Alice和Bob有一个随机的比特串,其他人不知道,这就是你生成密钥的方式。但如果你要公开发布MAC方案,你可能需要定义密钥是如何生成的。

然后,我们必须提出生成标签的方案。形式化地看,它看起来像这样:它接收两个参数,正如你刚才看到的,密钥和消息,并输出消息上的安全标签。为了更形式化一点,密钥是密钥生成产生的秘密密钥,消息是任意长度的。你可以计算短消息或长消息的MAC,对M的长度不应有限制,而标签是固定长度的。因此,无论你的消息多长,标签总是固定的长度,例如128位。

这听起来可能和哈希函数类似,也许你看出其中的联系了,但如果没有也没关系。只需知道消息是任意长度,标签是固定长度。

因此,如果你以这种方式定义MAC,那么有一些我们关心的属性。

一个是正确性。我们将再次说明,如果MAC是确定性的,那么它就是正确的。这意味着如果你用相同的密钥和相同的消息运行MAC算法,你最好得到相同的标签。如果你这样定义,你实际上可以回到这里稍微简化一下这个方案:与其在这里写一个不同的验证函数,你实际上可以在两个“黑盒子”中使用相同的MAC算法。为什么?因为如果消息和密钥相同,你会生成一个标签。当Bob收到标签时,他只需使用相同的密钥和相同的消息,我们生成相同的标签(或尝试生成)。如果我们生成的标签与T匹配,我们就知道消息是好的。因此,在我们讨论MAC是确定性的情况下,验证“黑盒子”不必是自定义算法,你可以直接再次运行MAC算法。如果消息相同、密钥相同,你将得到相同的标签;如果消息不同,你将得到不同的标签,你就知道它不匹配。


这不是你可以使用的唯一类型的MAC,你可以使用更复杂的类型,并做我们讨论过的事情:传入密钥、消息和标签,它输出truefalse。但对于本课程,我们不会担心那些,我们只考虑确定性的MAC。

在效率方面,同样,这不是一个形式化定义,但希望你做的是计算机擅长的事情,比如移动比特,而不是做一些非常复杂、会消耗大量计算机时间的事情,因为那样就没有人想用你的MAC了。

最后,我们将定义一个不同的安全游戏,一个你以前没见过的,叫做“存在不可伪造性”。它有点像IND-CPA,但它是MAC的等价物。一旦我们定义了它,它就为我们提供了一个定义,来检查我们的MAC是否能安全抵御攻击者。这些是当你设计一个MAC时希望它具有的特性。


本节课中我们一起学习了消息认证码(MAC)的基本定义和工作流程。我们了解到MAC通过在消息上附加一个由密钥生成的固定长度标签来提供完整性和认证。关键点包括:MAC是确定性的,其正确性依赖于相同输入产生相同输出;消息可以是任意长度,而标签是固定长度;其安全性通过“存在不可伪造性”等概念来定义。在接下来的课程中,我们将探讨如何具体构建安全的MAC方案。

121:EU-CPA安全性 🔐

在本节课中,我们将学习如何定义消息认证码(MAC)的安全性。我们将介绍一个名为“存在性不可伪造性在适应性选择明文攻击下”(EU-CPA)的安全游戏,它用于衡量攻击者在不知道密钥的情况下,能否伪造出有效的消息认证标签。

概述

上一节我们介绍了消息认证码的基本概念。本节中,我们来看看如何形式化地定义其安全性。我们将通过一个安全游戏来模拟攻击者的能力,并判断一个MAC方案是否足够安全。

核心安全目标

我们希望达成的安全属性是:如果攻击者不知道密钥 K,那么他不应该能够为他自己选择的消息生成有效的认证标签(Tag)。这一点至关重要,因为如果攻击者能做到这一点,他就可以伪造消息,让接收者Bob误以为该消息是真实可信的。

另一种等价的表述是:如果有人给你一个消息 M 及其标签 T,在不知道密钥的情况下,你不应该能够修改消息内容,并相应地修改标签使其仍然通过验证。任何对消息的篡改都应导致认证失败。这就是“不可伪造性”的含义。

EU-CPA安全游戏 🎮

我们将通过一个安全游戏来精确定义上述安全目标。在这个游戏中,攻击者Mallory的目标是伪造一个有效的消息-标签对。如果她成功,则说明该MAC方案不安全;如果她无法成功,则说明该方案是安全的。

游戏比我们之前学过的IND-CPA游戏要简短一些。它主要分为两个阶段。

第一阶段:查询阶段

这是攻击者Mallory可以“施展能力”的阶段。这里的“CPA”代表“选择明文攻击”,这是我们讨论的威胁模型。在此阶段,Mallory可以(在多项式时间内)多次进行以下操作:

以下是Mallory在查询阶段可以重复进行的操作:

  1. Mallory选择一个消息 M_i,发送给诚实的参与者Alice。
  2. Alice使用共享的密钥 K 和MAC算法,为消息 M_i 计算标签 T_i = MAC(K, M_i)
  3. Alice将计算出的标签 T_i 发送回给Mallory。

通过这个过程,Mallory可以观察到任意多条她自己选择的消息所对应的有效标签。她可以利用这个阶段来尝试分析MAC方案的弱点,甚至试图推断出密钥 K

第二阶段:挑战阶段

当Mallory认为自己已经准备好,或者找到了方案的破绽时,她宣布进入挑战阶段。

在挑战阶段,Mallory需要提交一个伪造的消息-标签对 (M*, T*)。这个伪造需要满足以下条件:

  • 有效性:使用密钥 K 对消息 M* 进行认证计算,结果必须等于 T*。即 MAC(K, M*) == T*
  • 新鲜性:消息 M* 不能是她在第一阶段中查询过的任何消息 M_i。这是因为MAC通常是确定性的,如果允许重复提交已查询过的消息,Mallory总能轻松获胜,这使得游戏失去意义。

如果Mallory能够提交一个满足上述条件的新消息-标签对,那么她就赢得了游戏,该MAC方案被视为不安全。反之,如果无论Mallory采用何种策略,都无法在多项式时间内成功伪造,那么我们就说该MAC方案是 EU-CPA安全 的。

总结

本节课中,我们一起学习了消息认证码(MAC)的核心安全目标——不可伪造性。我们通过引入 EU-CPA安全游戏 来形式化地定义和评估这一目标。该游戏模拟了攻击者在适应性选择明文攻击下的能力,并以其能否成功伪造一个从未见过的新消息的有效标签,作为判断MAC方案是否安全的标准。一个安全的MAC方案应能确保,不知道密钥的攻击者无法赢得这个游戏。

密码学4:122:为什么EU-CPA使用0.0而非0.5作为阈值 🎯

在本节课中,我们将要学习一个关于EU-CPA(不可伪造性)安全定义的重要细节:为什么其安全游戏中的获胜概率阈值被设定为0.0,而不是像IND-CPA(不可区分性)中那样是0.5。理解这个区别对于掌握不同安全概念的核心思想至关重要。


回顾IND-CPA游戏

上一节我们介绍了IND-CPA安全游戏,其中攻击者Mallory需要从两个消息(message0message1)中猜出哪个被加密了。在这个游戏中,一个完全随机猜测的攻击者(例如抛硬币)有50%的概率猜对。

因此,在IND-CPA中,一个加密方案被认为是安全的,当且仅当任何攻击者赢得游戏的概率不比0.5好,即:
公式: Pr[Win] ≤ 0.5 + negligible(λ)

这里的neglible(λ)代表一个可忽略的函数。


EU-CPA游戏的核心区别

本节中我们来看看EU-CPA(Existential Unforgeability under Chosen Plaintext Attack)游戏。它与IND-CPA有本质不同。

在EU-CPA游戏中,攻击者Mallory的目标是伪造一个有效的“消息-标签”对,而不是区分两个密文。她需要输出一个从未被查询过的消息m*及其对应的认证标签t*

以下是EU-CPA游戏的关键步骤:

  1. 挑战者生成密钥。
  2. Mallory可以适应性地查询任意消息m的标签t
  3. Mallory最终输出一个伪造的(m*, t*)
  4. 她获胜的条件是:m*从未被查询过,且t*m*的有效标签。

为什么阈值是0.0?

现在我们来解答核心问题:为什么EU-CPA的获胜阈值是0.0,而不是0.5?

关键在于,一个完全随机的攻击者在EU-CPA游戏中成功的概率几乎为0

  • 在IND-CPA中,随机猜测(二选一)有0.5的成功率。
  • 在EU-CPA中,攻击者需要凭空构造一个有效的(消息, 标签)对。如果她只是随机生成一串比特作为标签,那么该标签通过验证的概率是极低的,可以认为是0

因此,如果Mallory能够以任何显著高于0的概率(例如10%)成功伪造,这已经远远优于随机攻击了。所以,在EU-CPA中,我们设定:
公式: Pr[Forge] ≤ negligible(λ)

这意味着,一个方案是EU-CPA安全的,当且仅当任何攻击者成功伪造的概率是可忽略的(即,几乎为0)。


总结

本节课中我们一起学习了EU-CPA安全定义中获胜概率阈值的设定逻辑。

  • IND-CPA(不可区分性):攻击者进行二选一猜测,随机成功率为0.5。因此安全阈值是0.5
  • EU-CPA(不可伪造性):攻击者需要伪造有效标签,随机成功率几乎为0。因此安全阈值是0.0

这个区别源于两种安全游戏目标的不同:一个是“猜”,另一个是“造”。理解这一点有助于我们更清晰地把握密码学中不同安全属性的定义和评估标准。

123:HMAC定义 🔐

在本节课中,我们将学习如何基于密码学哈希函数构建一个安全的消息认证码(MAC)。我们将从一种基础构造(NMAC)开始,分析其潜在问题,并最终推导出更实用、更安全的HMAC标准。


概述

上一节我们介绍了消息认证码(MAC)的基本概念。本节中,我们将探讨如何利用密码学哈希函数来具体构建一个MAC。我们将从一种直观但存在缺陷的简单想法出发,逐步改进,最终得到一个被广泛使用的标准——HMAC。

密码学哈希函数接收输入并产生看似随机的输出,但它本身不包含密钥。一个自然的想法是:将密钥和消息一起进行哈希运算,以此生成一个MAC标签。


NMAC:一种基础构造

基于上述想法,我们首先定义一种称为NMAC的MAC方案。其工作流程如下:

以下是NMAC方案的关键步骤:

  1. 密钥生成:生成两个独立的密钥 K1K2。因此,完整的密钥是 (K1, K2)
  2. 标签计算:对于消息 M,MAC标签的计算公式为:
    Tag = H( K1 || H( K2 || M ) )
    其中 H 是密码学哈希函数,|| 表示连接操作。

你可能会问:为什么需要两个密钥?为什么需要两次哈希运算,而不是一次?

如果只使用一次哈希,即计算 H(K || M),会面临长度扩展攻击的威胁。攻击者即使不知道密钥 K,也可能在已知 H(K || M) 的情况下,计算出 H(K || M || M') 的值(M' 是攻击者附加的消息),从而伪造MAC标签。

通过在外层包裹第二个哈希函数 H(K1 || ...),可以彻底杜绝此类攻击。攻击者无法从外层哈希的输出反推出内层哈希的结果,因此无法进行长度扩展。

从高层次理解,双重哈希结构帮助防御了长度扩展攻击。NMAC的安全性已被证明:只要底层哈希函数 H 是抗碰撞且单向的,那么NMAC就是不可伪造的。攻击者在不知道密钥的情况下,极难为任何消息计算出有效的标签。


从NMAC到HMAC的演进

虽然NMAC是安全的,但它存在两个不便之处:

以下是NMAC的两个主要缺点:

  1. 需要两个密钥:用户必须管理两个密钥,这增加了复杂性。
  2. 密钥长度固定:两个密钥的长度必须严格等于哈希函数输出长度 n 比特。如果用户共享的单个密钥长度不对,则无法直接使用。

为了解决这些问题,我们对NMAC进行包装和改造,得到HMAC。HMAC保持了NMAC的所有安全属性,但使用起来更加方便。

HMAC的核心思想是:允许用户输入任意长度的单个密钥 K,然后在内部自动将其转换为NMAC所需格式的两个固定长度密钥。


HMAC的构造细节

HMAC的算法步骤如下:

以下是HMAC密钥处理与标签计算的过程:

  1. 密钥预处理

    • 如果密钥 K 长度大于哈希函数块长度 n,则先计算 K' = H(K) 将其压缩为 n 比特。
    • 如果密钥 K 长度小于 n,则在其后填充零比特(0x00)直到长度达到 n 比特。
    • 经过上述步骤,我们得到一个 n 比特的中间密钥 K'
    • 注意:密钥 K 不应过短(如仅有几位),否则容易被暴力破解。
  2. 派生NMAC所需密钥

    • 计算内层密钥:K1 = K' xor ipad
    • 计算外层密钥:K2 = K' xor opad
    • 其中 ipad(inner pad)是重复的字节 0x36opad(outer pad)是重复的字节 0x5C,重复次数以达到 n 比特长度。
  3. 计算MAC标签

    • 最终,HMAC标签的计算公式与NMAC一致:
      HMAC(K, M) = H( K2 || H( K1 || M ) )
    • 这等价于以 (K1, K2) 为密钥对消息 M 执行NMAC。

ipadopad 是硬编码在HMAC标准中的常量。选择不同常量的目的是确保派生出的两个密钥 K1K2 互不相同。只要攻击者不知道原始密钥 K,就无法推导出这两个派生密钥。


总结

本节课中我们一起学习了如何基于密码学哈希函数构建安全的MAC。

  1. 我们从简单的“哈希(密钥||消息)”想法出发,发现了其易受长度扩展攻击的缺陷。
  2. 为了修复缺陷,我们引入了NMAC构造,它通过双重哈希来防御攻击,但要求使用两个固定长度的密钥。
  3. 为了提升实用性,我们在NMAC基础上设计了HMAC。HMAC允许使用任意长度的单个密钥,并在内部通过填充、哈希和与固定常量异或等操作,将其转换为NMAC所需格式,从而在保持安全性的同时提供了极大的使用便利。

最重要的是,我们掌握了利用哈希函数构建MAC的核心思路。HMAC是这一思路的标准化、实用化实现,如今被广泛应用于各种网络安全协议中。

124:HMAC特性 🔐

在本节课中,我们将要学习HMAC(基于哈希的消息认证码)的核心特性。我们将看到,HMAC本质上是一个哈希函数,并因此继承了安全哈希函数的所有重要属性,如抗碰撞性和单向性。我们还将讨论其不可伪造性的证明,以及一个关于验证权限的有趣特点。


概述 📋

上一节我们介绍了HMAC的基本结构,它通过一些巧妙的代码从一个密钥生成两个密钥。本节中,我们来看看HMAC作为一个密码学原语,具体拥有哪些重要特性。

HMAC本质上是一个哈希函数。它接收输入,经过一些特殊处理后,最终输出一个哈希值。因此,HMAC的输出就是其内部哈希函数H的输出。

HMAC继承哈希函数的属性 🔗

因为HMAC本身是一个哈希函数(只是在计算哈希前对输入做了一些额外处理),所以它继承了底层哈希函数H的所有属性。我们之前提到,这个哈希函数是一个安全的密码学哈希函数。

以下是安全哈希函数的关键属性,HMAC也同样具备:

抗碰撞性

这意味着你无法找到两条不同的消息,使它们产生相同的MAC(消息认证码)。这样的碰撞虽然理论上存在,但实际中极难被发现。

核心概念:对于一个抗碰撞的哈希函数H,找到任意两个不同的输入 xy,使得 H(x) = H(y),在计算上是不可行的。

这个属性非常有用。它防止了攻击者让Alice对一条消息生成MAC后,再拿出另一条具有相同MAC的消息来进行欺骗。由于HMAC的哈希是抗碰撞的,这种攻击无法实现。

单向性

这意味着即使有人告诉你HMAC的输出,你也无法逆向推导出原始的输入或密钥。

核心概念:给定输出 h = H(m),要计算出原始输入 m 在计算上是不可行的。

HMAC的不可伪造性 🛡️

之前我们提到,存在一个(此处不展示的)证明:只要底层的哈希函数是安全的,那么HMAC就是不可伪造的

其大致原理是:哈希函数将消息和密钥“打乱”混合,最终生成一个同时基于消息和密钥的“指纹”。这使得攻击者在不知道密钥的情况下,无法为一个新的消息伪造出有效的MAC。

一个关于验证的有趣特点 🔑

最后,有一个值得注意的特点:如果没有密钥K,你也无法验证一个标签(Tag)。这取决于你的视角,可能是一个特性,也可能是一个限制。

回顾我们之前构建MAC的方式:当Bob想要验证时,他需要密钥和原始消息,然后重新生成标签进行比对。

这意味着,只有拥有对称密钥的人(比如Bob)才能验证消息的真实性。这是HMAC的一个固有特性:只有持有密钥的人才能对消息进行签名或验证。


总结 ✨

本节课中我们一起学习了HMAC的核心特性。

  1. 本质是哈希:HMAC是一个哈希函数,继承了安全哈希函数的属性。
  2. 关键属性:包括抗碰撞性(无法找到两条相同MAC的消息)和单向性(无法从输出反推输入)。
  3. 安全保证:有证明表明,在底层哈希安全的前提下,HMAC具有不可伪造性
  4. 验证权限:只有密钥持有者才能进行有效的验证,这是由其对称密钥机制决定的。

理解这些特性,有助于我们明白为何HMAC被广泛用于确保消息的完整性和真实性。

125:MAC是否提供完整性与真实性?🔐

在本节课中,我们将探讨消息认证码(MAC)的核心功能:它们是否真的能提供消息的完整性和真实性?我们将基于之前建立的威胁模型进行分析,并明确MAC的能力边界。


上一节我们讨论了MAC的基本工作原理,本节中我们来看看MAC在实际威胁模型中如何保障完整性。

在Alice和Bob交换消息、Mallory试图篡改的模型中,MAC确实能提供完整性。如果Mallory篡改了消息,Bob验证时将不会成功,从而得知消息已被篡改。同样,如果Mallory篡改了标签,验证也会失败。因此,在我们讨论的威胁模型中,MAC成功提供了完整性保障。


那么,MAC是否提供真实性呢?

真实性意味着你能确认消息来自原始发送者。这取决于你的模型和通信方是谁。

当你收到一条消息及其标签,并验证通过时,你能推断出什么?MAC是一个基于密钥K和消息M的函数,输出标签T。验证通过意味着生成此标签的人必须拥有密钥K

以下是关于真实性的一些关键点:

  • 如果多人共享同一密钥K,你只能知道消息来自其中一人,但无法确定具体是谁。在这种情况下,根据你的定义,MAC可能不提供真实性。
  • 如果只有Alice和Bob拥有密钥,而你(Bob)收到一条验证通过的消息,且消息并非你发送的,那么它必定来自Alice。在这种情况下,MAC提供了真实性。

因此,MAC是否提供真实性是一个模糊的问题,它取决于密钥的持有者数量以及你是否需要确定具体的发送者。


最后,我们讨论MAC是否提供保密性。

在我们的密码学路线图中,MAC被设计用于提供完整性,而非保密性。以下原因说明MAC不提供保密性:

  • MAC通常是确定性的,不使用随机性。如果同一消息多次发送,其标签相同,攻击者可以推断出消息内容相同,因此MAC不满足IND-CPA安全。
  • 在IND-CPA游戏中,MAC甚至没有解密函数,相关定义在此不适用。

这意味着,任何MAC(如HMAC或其他类型)在计算标签时,都可能泄露关于消息的信息。因此,使用MAC时需注意,它不提供保密性。


本节课中我们一起学习了MAC在完整性、真实性和保密性方面的表现。总结如下:

  • 完整性:MAC能有效提供完整性保障,防止消息被篡改。
  • 真实性:MAC是否提供真实性取决于密钥分发模型。在仅双方共享密钥时,它能提供真实性;在多人共享密钥时,则不能。
  • 保密性:MAC不提供任何保密性保障,其标签可能泄露消息信息。

理解这些边界对于正确应用MAC至关重要。

126:结合加密与MAC方案 🔐

在本节课中,我们将要学习如何将加密方案与消息认证码(MAC)方案结合起来,以实现同时具备机密性完整性的认证加密。我们将探讨两种主要策略:一种是组合现有方案,另一种是设计全新的方案。课程内容将简单直白,确保初学者能够理解。


概述

到目前为止,我们已经学习了提供机密性的对称密钥方案(如AES-CBC、CTR模式),以及提供完整性认证的方案(如HMAC)。然而,在实际应用中,我们通常需要同时确保消息的机密性和完整性。本节课程将探讨如何巧妙地组合这些方案,以实现认证加密


组合现有方案

上一节我们介绍了认证加密的基本概念,本节中我们来看看第一种策略:组合我们已经学过的加密和MAC方案。我们可以使用以下两种构建模块:

  • 一个IND-CPA安全的加密方案(例如AES-CBC或CTR模式),包含加密函数 E 和解密函数 D
  • 一个不可伪造的MAC方案(例如HMAC),包含标签生成函数 MAC

首次尝试:分别加密与MAC

以下是我们的第一个组合思路:在发送消息时,先对明文进行加密,再单独计算明文的MAC。

发送内容 = (密文 = E(K1, M), 标签 = MAC(K2, M))

这个方案能提供完整性吗?看起来可以。如果攻击者篡改了密文或标签,接收方Bob解密后得到的明文将无法通过MAC验证。

然而,它无法提供机密性。因为MAC本身不保证机密性,对同一明文生成的MAC标签是确定性的。攻击者通过观察信道中重复的标签,可以推断出相同的消息被发送了多次。更极端的情况下,一个设计不当的MAC函数甚至可能直接泄露部分明文信息。

因此,我们的首次尝试失败了,它只提供了完整性,但牺牲了机密性。我们需要更巧妙的设计。

方案一:对密文计算MAC(Encrypt-then-MAC)

为了解决MAC泄露明文信息的问题,一个自然的想法是:不对明文计算MAC,而是对密文计算MAC。

发送内容 = (密文 = E(K1, M), 标签 = MAC(K2, 密文))
  • 完整性:攻击者无法篡改密文或标签而不被察觉,因为他不知道MAC密钥 K2
  • 机密性:即使MAC可能泄露关于其输入(即密文)的信息,但密文本身已经是加密的。攻击者获得密文信息并不能帮助他恢复明文。

这个方案是可行的,它也是TLS等协议中使用的标准方法之一。

方案二:加密“消息+MAC”(Encrypt-and-MAC)

另一个思路是将MAC也保护起来,将其和明文一起加密。

发送内容 = 密文 = E(K1, (M, MAC(K2, M)))
  • 完整性:如果攻击者篡改了外部密文,解密后得到的内部(M, MAC)对将无法通过验证。
  • 机密性:由于整个数据包(包括消息和MAC)都被加密,攻击者无法获得任何明文或MAC标签信息。

这个方案同样有效。它与第一种方案的关键区别在于MAC标签的可见性。


构建全新方案

除了组合现有方案,另一种策略是设计一个全新的、从一开始就以同时实现机密性和完整性为目标的密码学原语。这类方案通常具有更高效和更简洁的设计。一个著名的例子是认证加密关联数据(AEAD) 模式,例如GCM(Galois/Counter Mode)。AEAD模式在一个统一的算法框架内,使用单个密钥处理加密和认证,并且能额外认证一些未加密的关联数据(如数据包头)。


总结

本节课中我们一起学习了如何实现认证加密。我们了解到,简单地并行使用加密和MAC(分别处理)是不够的,因为MAC可能破坏机密性。我们探讨了两种有效的组合方式:

  1. Encrypt-then-MAC:先加密,再对密文生成MAC。
  2. Encrypt-and-MAC:先对明文生成MAC,然后将“明文+MAC”一起加密。

此外,我们还了解到存在像GCM这样的集成化AEAD方案,它们被专门设计来一次性高效地解决机密性和完整性问题。在实际系统中,应根据安全需求和性能考虑选择合适的方法。

127:加密后MAC更优方案

在本节课中,我们将探讨两种结合加密与消息认证码(MAC)的方案:“加密后MAC”与“MAC后加密”。我们将分析两者的工作原理,并重点解释为何“加密后MAC”是更优、更安全的选择。

方案回顾

上一节我们介绍了如何结合加密与MAC来实现同时具备保密性和完整性的通信。本节中我们来看看这两种具体方案的命名与步骤。

这两种方案有各自的名称,其名称直观反映了操作顺序。

以下是两种方案的基本描述:

  • 加密后MAC:先对明文进行加密,然后计算密文的MAC,最后将密文密文的MAC一起发送。这是我们看到的第一个想法。
  • MAC后加密:先计算明文的MAC,然后将明文明文的MAC一起加密,最后发送这个整体的密文

关键差异与潜在问题

一个自然的问题是:哪种方案更好?我们已经证明,如果正确使用,两者都能提供IND-CPA保密性和EUF-CMA完整性。然而,“MAC后加密”方案存在一个非常微妙的缺陷。

这个微妙的缺陷在于:接收方必须先解密,才能检测消息是否被篡改

让我们对比一下接收方Bob处理消息的流程:

在“加密后MAC”方案中,Bob首先检查MAC。如果消息被篡改,MAC验证会失败,Bob会立即停止处理

相比之下,在“MAC后加密”方案中,Bob收到的是一个完整的加密包。他必须做的第一件事是解密这个包,以获取原始消息和附带的MAC。只有完成解密后,他才能计算并验证MAC是否正确。

因此,在“加密后MAC”中,Bob先检查完整性;在“MAC后加密”中,Bob必须先解密才能检查完整性。

“解密预言机”的风险

“必须先解密”这一事实会带来非常微妙的安全隐患。具体来说,攻击者可以给Bob发送任意值,而Bob被迫先解密它,因为他只有解密后才能知道消息是否有效。

如果你使用“MAC后加密”方案,你基本上创造了一个角色Bob,他会解密你给他的任何东西。

攻击者 -> Bob: 发送任意垃圾数据
Bob: 1. 解密数据
Bob: 2. 检查解密后数据的MAC
Bob: 3. 发现MAC无效,丢弃数据

虽然通常Bob解密后不会将结果返回给攻击者,但可能存在一些信息泄露。例如,Bob解密所花费的时间、解密过程中的错误信息或系统状态变化,都可能泄露关于密钥或明文的部分信息。

在密码学中,我们称这样一个会解密任何提供给他的密文的实体为解密预言机。提供这样的解密预言机是危险的,因为它可能泄露关于解密过程或所用密钥的信息。

因此,我们不喜欢“MAC后加密”方案,因为它创造了一个会解密任何输入的角色Bob,并且只有在他解密之后才能检测到篡改。

总结与建议

总而言之,我们始终更倾向于使用加密后MAC方案,因为它不会让我们面临同样的问题。Bob会立即检查MAC,如果MAC错误,他完全不会进行任何解密操作。这样,我们就不会有一个可能泄露数据、并为攻击者解密任何输入的解密预言机。

至少出于我们的目的,我们始终建议使用“加密后MAC”,因为它对实现错误更具鲁棒性。如果Bob存在信息泄露的风险,你绝对会更希望是第一种情况而非第二种,因为在第二种情况下,Bob会变成一个潜在泄露解密信息的解密预言机。

这就是我们主张“加密后MAC”是更优方法的原因。

本节课中,我们一起学习了“加密后MAC”与“MAC后加密”两种方案的核心区别,重点分析了“MAC后加密”方案中“先解密后验证”所带来的“解密预言机”风险,并明确了在实践中选择“加密后MAC”方案以获得更高安全性的理由。

128:密钥复用 🔑

在本节课中,我们将要学习一个在密码学中需要特别注意的概念:密钥复用。我们将探讨什么是密钥复用,为什么它可能带来安全风险,以及如何避免这些问题。

概述

在之前的课程中,我们已经接触过使用不同密钥的场景。例如,在“先加密后MAC”方案中,我们使用一个密钥 K1 进行加密,而使用另一个完全不同的密钥 K2 来计算MAC。本节我们将深入探讨,如果在这两种不同的算法中使用同一个密钥会发生什么,以及为什么这通常是一个坏主意。

什么是密钥复用?🤔

上一节我们介绍了在加密和MAC中使用不同密钥的做法。本节中我们来看看,如果使用同一个密钥会怎样。

密钥复用,在本课程的语境下,特指在两种不同的算法中使用同一个密钥。例如,加密是一个算法,计算MAC是另一个算法。如果两者都使用同一个密钥 K,这就构成了密钥复用。

核心概念公式:

密钥复用 = 同一密钥K 用于 算法A 和 算法B

密钥复用的风险

以下是使用同一密钥可能引发的安全问题:

  • 算法间干扰:如果加密方案和MAC方案都基于分组密码(例如,使用CBC模式的加密和基于分组密码的MAC算法),那么使用同一个密钥可能导致这两个算法以非常微妙的方式相互干扰。例如,加密过程可能将消息正向通过分组密码,而MAC过程可能无意中将消息反向通过同一个分组密码,这可能导致原始明文被意外恢复。
  • 安全分析复杂化:密钥复用使得整个方案的安全性分析变得极其复杂。你需要额外证明,这两种算法在使用同一密钥时不会相互影响,不会引入新的漏洞。这通常是一项困难且容易出错的工作。

因此,结论是:避免密钥复用。使用不同的密钥可以彻底消除算法间干扰的可能性,让你无需进行复杂且容易出错的安全分析。

如何避免密钥复用?✅

上一节我们了解了密钥复用的风险,本节中我们来看看解决方案。

避免密钥复用的方法很简单:为不同的算法使用不同的密钥

以下是一些具体的应用场景和最佳实践:

  • 加密与认证:当你需要对消息同时进行加密和MAC计算时,请务必使用两个独立的密钥。例如,用 K_enc 加密,用 K_mac 计算MAC。
  • 不同通信对象:如果你需要为发送给Bob的消息和发送给Alice的消息分别计算MAC,考虑使用不同的MAC密钥。这可以防止针对一个对象的攻击影响到另一个对象。
  • 相同算法的多次使用:根据本课程的定义,使用同一密钥多次加密不同消息(例如,用同一个 K_enc 加密文件A和文件B)不被视为“密钥复用”,因为使用的是同一个算法。这在某些情况下可能是可接受的,但具体取决于你的威胁模型。在某些项目中,为不同的数据使用不同的加密密钥可能更安全。

核心原则代码描述:

# 好的做法:不同算法,不同密钥
key_enc = generate_key()  # 用于加密的密钥
key_mac = generate_key()  # 用于MAC的密钥

ciphertext = encrypt(message, key_enc)
tag = mac(ciphertext, key_mac)

# 风险做法:密钥复用(应避免)
shared_key = generate_key()
ciphertext = encrypt(message, shared_key)  # 使用同一密钥
tag = mac(ciphertext, shared_key)          # 使用同一密钥

总而言之,一个绝对正确的做法是:当涉及两个不同的算法时,始终使用两个独立的密钥。这可以避免不必要的干扰,并简化安全保证。

一个历史案例:先MAC后加密的攻击

在密码学发展早期,人们曾普遍使用“先MAC后加密”的方案(即先计算MAC,再加密“消息+MAC”)。这个方案本身存在缺陷(例如,它向攻击者提供了一个解密预言机)。历史上,互联网早期确实出现过针对该方案的实际攻击。这一事件促使“先加密后MAC”成为了更受推荐的标准。如果你感兴趣,可以搜索相关历史攻击的详细信息。

总结

本节课中我们一起学习了密钥复用的概念。我们明确了在本课程中,密钥复用特指在两种不同的算法中使用同一个密钥。我们探讨了这样做可能引发的算法间干扰和安全分析复杂化等风险。为了避免这些风险,我们得出的最佳实践是:为不同的密码学算法始终使用不同的密钥。记住这个原则,可以帮助你构建更简单、更安全的密码学系统。

129:AEAD方案总结 🧩

在本节课中,我们将总结实现认证加密的第二种策略,即从头构建一个同时提供机密性和完整性的方案。我们还将回顾哈希函数、消息认证码(MAC)以及组合加密与认证方案的关键要点。

从头构建认证加密方案 🔨

上一节我们讨论了通过组合现有加密和MAC方案来实现认证加密。本节中,我们来看看另一种策略:从头设计一个全新的方案。

这种方法被称为AEAD(Authenticated Encryption with Associated Data,带有关联数据的认证加密)。AEAD方案通过单一算法,在加密消息的同时生成认证标签,从而一次性提供机密性和完整性。

以下是AEAD方案的主要特点:

  • 优点:无需再担心如何组合加密和MAC(例如“先加密后MAC”还是“先MAC后加密”),只需使用这一个方案即可。
  • 缺点:由于所有功能集成于单一方案,一旦使用出错(例如错误地重复使用初始化向量IV),将同时丧失机密性和完整性,风险更高。

核心概念示例(伪代码)

ciphertext, tag = AEAD_Encrypt(key, nonce, plaintext, associated_data)
is_valid = AEAD_Decrypt(key, nonce, ciphertext, tag, associated_data)

课程内容回顾 📚

接下来,我们对本系列视频的核心内容进行简要总结。

关于哈希函数

我们首先介绍了哈希函数,它能将任意长度的输入映射为固定长度的输出(可以理解为消息的“指纹”)。

我们讨论了哈希函数应具备的安全属性,并提到了某些哈希函数容易遭受“长度扩展攻击”。需要注意的是,在特定的威胁模型下,仅凭哈希函数通常无法提供完整性保证。

关于消息认证码(MAC)

随后,我们引入了消息认证码(MAC)。其核心思想是使用密钥和消息生成一个认证标签。

我们通过安全游戏定义了MAC的安全性——不可伪造性。并以HMAC(基于NMAC构建)为例,介绍了一种具体的MAC构造方法。最后我们强调,MAC本身不提供机密性,直接使用可能泄露消息信息。

关于组合加密与认证

最后,我们探讨了如何组合加密方案和完整性方案。主要有两种途径:

  1. 组合现有方案:如果选择这种方式,务必采用“先加密后MAC”的模式,并且避免向攻击者暴露解密预言机。
  2. 使用AEAD方案:这是一种强大但需要谨慎使用的方案,因为它将两者功能紧密耦合。

本节课中,我们一起学习了实现认证加密的两种路径,重点分析了从头构建的AEAD方案的利弊,并系统回顾了哈希函数、MAC以及方案组合的核心知识点。记住,在选择方案时,务必权衡其便利性与潜在风险。

130:回顾与引言 🔄

在本节课中,我们将回顾之前学过的密码学核心概念,并引出两个尚未解答的关键问题:对称密钥方案中的随机性从何而来,以及通信双方如何首次获得共享的对称密钥。


上一节我们回顾了哈希函数和消息认证码(MAC)的基本概念。本节中,我们来看看如何将保密性和完整性方案结合起来。

哈希函数回顾 🔍

哈希函数将任意长度的输入映射为固定长度的输出。输出是确定性的,但具有不可预测性。这意味着即使只改变输入的一个比特,输出也应看起来完全不同且不可预测。我们讨论过使密码学哈希安全的两个安全属性。

然而,哈希函数的输入不包含任何密钥,因此在我们的威胁模型下,它无法提供完整性。

消息认证码(MAC) 🛡️

为了解决完整性问题,我们引入了消息认证码(MAC)。MAC在我们的威胁模型下能提供完整性。其输入是一个密钥和一条消息,输出是该消息的一个标签。

我们描述了安全的MAC应具备不可伪造性,并展示了一个基于哈希函数构建MAC的示例。最后我们指出,MAC提供完整性,但不提供保密性。

认证加密 🔐

最后,我们讨论了使用认证加密来结合保密性方案和完整性方案的两种方法。

以下是两种主要方法:

  • 先加密后MAC:这种方式比“先MAC后加密”更优。
  • AEAD加密模式:这种方式存在风险,因为如果使用不当,可能会同时丧失保密性和完整性。

以上就是我们上节课讨论的内容。


本节课的核心问题 ❓

今天,我们将解答两个被搁置了一段时间的问题。

以下是本节课要解决的两个核心问题:

  1. 在我们的对称密钥方案中,随机性从何而来?
  2. 爱丽丝和鲍勃最初是如何获得那个共享的对称密钥的?

这两个问题我们回避了很久,但今天终于要给出答案了。


本节课中,我们一起回顾了哈希、MAC和认证加密的概念,并明确了接下来要探索的两个关于随机性和密钥交换的核心问题。在后续章节中,我们将深入探讨伪随机数生成器和迪菲-赫尔曼密钥交换协议。

131:熵与真随机性

在本节课中,我们将要学习密码学中的一个核心概念:随机性。我们将探讨随机性的定义、如何衡量它,以及真随机数的来源。理解这些概念对于构建安全的密码系统至关重要。

什么是随机性?

在密码学中,随机性意味着不可预测性。许多我们已学过的加密方案和MAC计算都需要随机密钥。在加密方案中,每次加密也需要随机的初始化向量(IV)或随机数。如果攻击者能够预测这些随机值,整个系统的安全性将荡然无存。因此,我们需要讨论这些随机数从何而来,以及如何安全地生成它们。

如何衡量随机性?

在讨论如何生成随机数之前,我们首先需要定义“随机”的含义。在密码学中,“随机”意味着真正随机且不可预测。

为了直观理解不可预测性,假设我们需要生成一个攻击者无法猜测的密钥。一个理想的方法是反复抛掷一枚均匀的硬币(正面和反面各50%概率),正面记为0,反面记为1。这样生成的比特串对攻击者来说很难猜测,因为每个比特是0或1的概率完全相等。

相比之下,如果使用一枚有偏的硬币(例如正面90%,反面10%)来生成密钥,那么密钥中的大部分比特都将是0。攻击者知道这一点后,就更有可能猜中你的密钥。

我们需要一个更正式的方式来描述:均匀硬币(50%/50%)比有偏硬币(大部分输出为0)是更好的随机性来源。这个正式的衡量标准被称为。熵衡量了结果的不确定性或不可预测性。通常,在密码学中我们喜欢高熵的事件,因为高熵意味着结果难以预测。

例如,均匀硬币具有高熵,因为每次抛掷的结果(正面或反面)难以预测。而有偏硬币则具有低熵,因为结果(很可能是正面)是可预测的。

熵通常以比特为单位来衡量。例如,一枚均匀硬币具有1比特的熵(两种等可能的结果)。一个在8个值上均匀分布的随机源,其熵为 log₂(8) = 3 比特。对于本课程而言,重要的是理解熵让我们能够正式地衡量不确定性,而高熵事件是好的。

低熵的风险

以下是一个现实世界的例子,说明了使用低熵随机源的危险。某个比特币代码库中,有人提交了一个“改进”随机数生成器的代码。然而,这个“改进”实际上降低了生成器的熵。这意味着攻击者现在更容易猜出用此代码生成的私钥。如果攻击者能预测私钥,他们就能窃取你的比特币,所有安全性都将丧失。所以,这或许根本算不上什么改进。

真随机性从何而来?

既然我们知道了如何定义和衡量随机性,现在让我们思考随机性究竟从何而来。如果你想要真随机性,你必须依赖某种物理熵源,即现实世界中存在的、不可预测的物理现象。

以下是几个物理熵源的例子:

  • CPU电路噪声:在你的CPU上,可以设计一个电路,使其行为具有不可预测性。例如,让电压水平在0.5伏左右波动,这样有时被读取为1,有时被读取为0,任何时刻的电压水平都是不可预测的。
  • 人类活动时序:以非常精确的时间尺度测量的人类活动。例如,记录你每次按下键盘按键时的微秒级时间戳。人类无法在微秒级别精确控制动作,因此这些时间戳数字可以视为随机的。
  • 更奇特的熵源:例如,Cloudflare公司的办公室里有一整面墙的熔岩灯,并用摄像头对着它们拍摄。熔岩灯中光影的闪烁模式也可以被用作随机源。

使用真随机性的主要问题是,生成速度慢且成本高。因为你依赖于这些物理熵源,收集熵的过程可能很慢,与运行一段代码相比可能非常昂贵。因此,虽然理想情况下我们需要真随机性,但高效地生成它并不容易。

总结

本节课中,我们一起学习了密码学中随机性的核心概念。我们了解到,随机性在密码学中意味着不可预测性,而是衡量这种不可预测性的正式标准。高熵的随机源对安全至关重要,低熵则会导致严重的安全漏洞。最后,我们探讨了真随机性必须来源于物理世界的不可预测现象,例如硬件噪声或人类活动的精确时序,尽管这些方法在效率上存在挑战。理解这些是设计和评估安全密码系统的基础。

132:伪随机数生成器定义 🔐

在本节课中,我们将要学习伪随机数生成器的核心概念、定义方式以及其应具备的安全属性。我们将从为何需要PRNG开始,逐步深入到其工作原理和安全要求。

概述

由于真正的随机性获取成本高昂,我们转而寻求软件解决方案。伪随机数生成器就是一段软件代码,它接收少量真正的随机性作为输入,能够快速、高效地生成大量看似随机的输出。

PRNG的工作原理

上一节我们提到了真随机数的局限性,本节中我们来看看软件如何模拟随机性。PRNG的使用方式如下:首先收集少量昂贵的真随机数,将其输入PRNG。随后,PRNG便能高效、廉价地输出大量看似随机的数字。

需要注意的是,PRNG是确定性的。这意味着,如果你两次输入相同的真随机比特,你将得到相同的“随机”输出。毕竟,PRNG只是一段代码,输入相同,输出必然相同。

然而,如果一个PRNG被设计为安全的,那么我们可以说其输出在计算上是不可区分的。

安全目标:计算不可区分性

从真正的随机性来看,这意味着如果你给攻击者一些以这种方式生成的PRNG输出,再给他们一些由熔岩灯或其他物理随机源生成的真正随机输出,那么具有多项式运行时间的攻击者将无法分辨哪些来自PRNG,哪些来自真正的随机源。

这就是我们对PRNG的目标:它是一段能够高效生成与随机数无异的数字的代码,尽管它们来自一个确定性算法。

如何定义PRNG

实际上,定义PRNG有多种方式。如果你查阅不同的研究论文,会看到PRNG的不同定义。但为了本课程的目的,我们将PRNG视为一个对象。

以下是PRNG作为对象的核心方法:

  • seed方法:此方法接收一些真随机比特,并用它们来初始化PRNG的内部状态。作为一个对象,PRNG拥有一些内部实例变量来帮助生成伪随机数。播种就是使用真随机性作为输入来初始化该内部状态。
  • reseed方法:如果你决定输入更多真随机性,可以调用此方法。它再次接收一些真随机性,并更新内部状态以纳入这些随机比特。
  • generate方法:这是实际产生输出的方法,也是你生成伪随机比特的方式。你传入一个数字N,表示需要N比特的伪随机输出。PRNG使用其内部状态(可能根据需要更新这些内部变量),在软件中廉价且高效地生成你所请求的N个伪随机比特。

PRNG的正确性与安全性

那么,什么使一个PRNG正确且安全呢?

以下是PRNG应具备的关键属性:

  • 确定性:它是一段代码。如果用相同的输入运行它,应该得到相同的输出。
  • 高效性:我们不会正式定义这一点,但可以理解为,无论它做什么,都应该是计算机擅长的位操作之类的事情,以便相比真随机性,它确实能带来效率优势。
  • 计算不可区分性:在安全性方面,我们希望它在计算上与随机数不可区分。我们将更正式地定义这一点。
  • 回滚抵抗性:你可能还希望一个额外的安全属性,称为回滚抵抗性,我们也将讨论这一点。

熵源与PRNG安全性

一个好的PRNG应该能够处理你能提供的所有熵源。

如果你给PRG一个好的熵源,那很棒。现在,想要预测你PRG输出的攻击者必须猜测那个初始输入熵,才能运行算法并生成与你相同的输出。

但一个好的PRG也应该能够处理坏的熵源,并使PRG变得更好。

例如,如果你传入一个有偏硬币的输出。我们知道它的熵很低,但它仍然应该有助于PRG变得更安全。攻击者仍然必须猜测那个有偏硬币抛掷的结果,才能以你播种的方式重建PRG。

即使你传入一些完全没有熵的源,比如它总是1,这也不应该使PRG变差。攻击者总是知道你传入了输入1,这是一个非常糟糕的熵源,但它不应该使PRG变差。

因此,只要其中一个源是好的,整个PRNG对攻击者来说就应该是难以猜测的。

另一种说法是,PRNG的总熵应该是你提供的每个熵源之和。因此,如果你提供更多源,即使它们是低熵或零熵的,它们也应该只会有帮助,而不应该使PRNG变差。

这意味着,如果你正在使用PRNG,只要你有一个熵源,即使它很糟糕,就用这个额外的熵重新播种你的PRNG。这应该只会让事情变得更好,而不是更糟。

总结

本节课中我们一起学习了伪随机数生成器的核心概念。我们了解到,PRNG是一种通过确定性算法高效生成“看似随机”数字的软件工具,其安全性核心在于输出的“计算不可区分性”。我们将其定义为一个具有seedreseedgenerate方法的对象,并探讨了其应具备的确定性、高效性和安全性(包括对各类熵源的鲁棒性)等关键属性。理解这些是构建和使用安全密码系统的基础。

133:PRNG安全性 🔐

在本节课中,我们将要学习伪随机数生成器的安全性。我们将探讨PRNG为何无法实现真正的随机性,如何定义其安全性,以及安全使用PRNG的关键前提。


上一节我们介绍了PRNG的基本概念,本节中我们来看看PRNG的安全性证明。

一个可以进行的思维练习是思考PRNG是否能实现真正的随机性。它们能否像我们讨论过的熔岩灯或任何其他物理源那样实现真正的随机?不幸的是,答案是否定的。以下是一个证明。

考虑使用一个PRNG,并传入S比特的初始种子。这是你传入的真正随机性。因为PRNG是确定性的,每个可能的S比特种子都会导致相同的输出序列。所以,如果你传入一个S比特种子,你会得到一个输出序列;传入另一个S比特种子,你会得到另一个输出序列。每个种子都精确映射到一个输出序列,这就是确定性的含义。

因为你的种子有S比特,这意味着有 2^S 个可能的输入种子。对于每个输入种子,由于确定性,你得到一个输出序列。因此,从这个仅用S比特输入种子的PRNG中,你总共只能得到 2^S 个可能的输出序列。

现在考虑使用这个带有S比特输入的PRNG来生成长度为 2S 比特的输出。你想生成长度是输入两倍的输出。同样,因为只有 2^S 个输入,每个输入生成一个唯一的输出序列,这意味着这个PRNG只能生成 2^S 个长度为 2S 的可能输出序列。

但是,如果这个PRNG是真正随机的,它应该能够生成任何长度为 2S 的序列。那么有多少个长度为 2S 的序列呢?如果序列是 2S 比特,并且每个比特可以是0或1,那么应该有 2^(2S) 个可能的不同输出序列。

总结一下,如果这个PRNG是真正随机的,我们应该能够生成 2^(2S) 个可能输出序列中的任何一个。但是,因为它是确定性的,并且我们只输入了S比特的种子,我们只能生成 2^S 个可能的输出序列。因此,这个PRNG不是真正随机的,它无法生成所有可能的输出序列,只能生成其中的一个小子集。


尽管PRNG不是真正随机的,我们可以说对于我们的目的而言,它和随机的一样好。更正式地,我们定义PRNG是安全的,如果它在计算上与随机不可区分。

这是一个我们之前已经使用过的术语,但为了提醒你它的含义:这意味着如果我们给攻击者两个序列,一个是真正随机的(例如通过抛硬币得到),另一个是从一个安全PRG输出的(攻击者不知道我们提供的初始种子),攻击者应该无法分辨哪一个是哪一个——哪个序列是随机的,哪个来自PRG。攻击者不知道,并且我们加入了通常的注意事项,例如,攻击者猜对的概率必须大于 1/2 + 可忽略函数 才算获胜,并且我们将攻击者的运行时间限制在合理的范围内,例如多项式时间。

这类似于我们见过的一些安全游戏,但这里他们是在区分PRNG和真正的随机。因为他们无法区分,我们说PRNG的输出基本上在所有意图和目的上都是随机的。

一个等价的定义是,你可以从数学上证明这两者是相同的。你可以说,看到PRNG输出但不知道内部状态和初始种子的攻击者,无法以任何不可忽略的概率预测PRNG的未来输出。

所以,如果我给攻击者一些PRNG输出(不展示输入),并说“这是PRNG输出,如果我调用更多次生成,接下来会出现什么?”如果攻击者能够预测接下来会出现什么,那么这不是一个安全的PRNG。如果攻击者无法预测接下来会出现什么,我们就说这个PRNG是安全的。从攻击者的角度来看,即将生成的比特和随机的一样好。这就是你定义PRNG安全性的方式。


现在不要忘记,你的PRNG的安全性只和你的初始种子一样好。如果你将一个低熵的坏初始种子传递给一个安全的PRNG,输出仍然将是可预测的。

以下是一个来自赌场的例子:可疑的玩家会进去,把手放在老丨虎丨机的拉杆上,等待一个非常特定的时间,然后突然拉动拉杆,一大堆钱就会掉出来。这里发生的情况似乎是,老丨虎丨机正在使用当前时间作为其PRNG的种子。

这意味着攻击者所要做的就是计算不同时间对应的PRNG输出,找到能产生获胜PRNG输出的时间。当那个时间到来时,他们就会拉动拉杆并赢得所有钱。这家赌场可能因此破产了。这是一个安全PRNG以不安全方式使用的例子,结果仍然是糟糕的。

所以不要忘记,即使你的PRNG是安全的,你仍然必须传入高熵的种子。

还有其他PRNG漏洞的例子,同样源于熵不足。我不会大声读出这个,但这是另一个熵不足的情况,因此攻击者能够暴力尝试所有初始种子,并最终猜出你的密钥。

所以不要忘记,使用PRNG时要配合高熵源。


本节课中我们一起学习了PRNG安全性的核心概念。我们证明了PRNG由于其确定性和有限种子空间,无法实现真正的随机性,只能生成所有可能序列的一个子集。我们定义了PRNG的安全性在于其输出在计算上与随机序列不可区分,或者攻击者无法预测其未来输出。最后,我们强调了安全使用PRNG的关键:必须使用高熵的初始种子,否则即使PRNG算法本身安全,整个系统也会变得脆弱。

134:PRNG的回滚抵抗性 🔄

在本节课中,我们将要学习伪随机数生成器(PRNG)的另一个有用属性——回滚抵抗性。这是一个独立于我们之前讨论过的安全属性的额外特性,可以将其视为一个有益的“加分项”。

上一节我们介绍了PRNG的核心安全属性,本节中我们来看看这个被称为“回滚抵抗性”的额外特性。

什么是回滚抵抗性?

回滚抵抗性是一个独立的安全属性。它与我们之前讨论过的“计算上不可与随机区分”的属性是分开的。直观地说,回滚抵抗性意味着攻击者无法反向运行PRNG。

更正式地定义,这意味着即使攻击者能够攻破PRNG的内部状态(例如,通过入侵机器,获知所有内部变量和运行机制),他们也无法反向运行这个PRNG,从而无法得知该PRNG之前生成的输出。

回滚抵抗性的重要性

回滚抵抗性与我们之前讨论的安全属性无关,但如果PRNG具备此特性,则是一个很好的补充。

以下是回滚抵抗性可能发挥作用的一个例子:

假设爱丽丝使用一个PRG来生成一个秘密密钥。一周后,她又用同一个PRNG生成了一些其他东西。后来,攻击者马洛里成功入侵了机器,并了解了该PRG的运行机制,她可以看到这个PRG对象的所有内部实例变量。

  • 如果PRNG具备回滚抵抗性:这意味着马洛里无法推断出该PRG之前产生的输出,例如上周生成的秘密密钥。
  • 如果PRNG不具备回滚抵抗性:这意味着马洛里能够反向运行这个PRG,从而可以找出爱丽丝上周生成的秘密密钥,导致所有安全性丧失。

因此,回滚抵抗性是一个很好的特性。但请注意,它独立于我们之前讨论过的安全属性。

本节课中我们一起学习了PRNG的“回滚抵抗性”这一额外安全属性。我们了解到,它主要防止攻击者在获知PRNG内部状态后,反向推导出之前生成的输出,这是一个独立但非常有价值的安全增强特性。

135:PRNG的实现 🔐

在本节课中,我们将学习伪随机数生成器的具体实现。我们将探讨两种常见的、安全的PRNG构造方法:基于计数器模式和基于HMAC的实现。


上一节我们介绍了PRNG的定义,本节中我们来看看一些具体的实现例子。

事实证明,一个PRNG的例子一直隐藏在我们眼前。当我们讨论CTR模式时,我们提到CTR模式的行为类似于一次性密码本,其中用于与明文进行异或操作的随机“密码本”是由分组密码的输出生成的。

如果你只看这张图的上半部分,即生成随机“密码本”的部分,它本身就是一个PRG。种子是随机数和密钥,即你传入的真实随机性。而分组密码的输出就是由这个PRG生成的伪随机输出。

因此,基于计数器的PRNG是一种可以用来构建安全PRNG的可能结构,这基于一个事实:分组密码的输出与随机数是不可区分的。所以,这个RNG的输出也与随机数不可区分。


现在,让我们看看另一种构建安全PRNG的方法。

另一种构建安全PRNG的方法实际上是基于HMAC的。请记住,HMAC本身基于哈希函数,并且哈希函数的输出是不可预测的。如果你改变输入的任何一位,输出看起来都完全不可预测。我们将利用这个想法来构建我们的PRNG。

以下是该构造的示意图。不必过于担心这里的精确细节,这不是最重要的。只需记住,我们正在多次调用HMAC,而底层哈希函数的不可预测性正是我们获得不可预测输出的原因。

如果你好奇,以下是所有确切的细节,尽管它们在这里不是最重要的。

基于HMAC的PRNG有两个实例变量:KV。它们恰好从零开始。任何时候你调用 seedreseed 时,请注意我们正在将种子 S 输入到HMAC中,以产生一些不可预测的输出,然后我们将 KV 重新分配给该HMAC的输出。实际上,我们正在借助HMAC将你引入的随机性合并到我们的实例变量 KV 中。

现在,如果你想生成输出,过程可能如下所示。

这里有一个循环,该循环反复调用HMAC,直到输出了足够的比特。因此,如果你请求很多比特,这个循环将运行很多次,直到HMAC被调用了足够次数以生成足够的输出。此外,每次我们调用HMAC时,我们也会更新一个内部变量 V,然后我们将其传递回HMAC,这确保了在这个循环中每次调用HMAC都会产生不同的输出。

同样,不必过于担心这里的精确细节。重要的是,我们正在循环中调用HMAC,并且每次都会更新内部状态,这就是产生看似随机输出的原因。最后,我们再次更新内部状态(这不太重要),但这就是基于HMAC的PRNG可能的样子。

这里需要注意的一点是,这里的一切都是确定性的。它完全基于用户输入 SN。在任何时候,我们实际上都没有使用真正的随机性。代码本身是确定性的,真正的随机性是由用户输入的。这就是为什么我们说PRNG是确定性的。它们从外部或用户提供的 S 中获取真正的随机性,然后确定性地生成伪随机输出。


接下来,我们讨论一下基于HMAC的PRNG的安全性和一个重要特性。

基于HMAC的PRNG是安全的,前提是你所使用的底层哈希函数是安全的。我们不会在本课程中证明这一点,我们不是一个基于证明的课程。但如果你好奇,证明将类似于归约证明,即如果你能破解HMAC,那么你也破解了底层的哈希函数;因此,如果底层哈希函数是安全的,那么基于HMAC的PRG也是安全的。我们不会讨论证明,但大纲会是那样的。

基于HMAC的PRG的另一个巨大好处是它具有抗回滚性。请记住,该属性意味着你无法反向运行HMAC PRG算法。即使有人告诉你当前的内部状态,即 KV 这些内部实例变量的值,你也无法反向运行此算法来找出此PRG之前的输出。

原因在于,这个PRG一直在调用哈希函数,反复调用HMAC,而HMAC是你无法反向调用的。给定HMAC的输出,你并不知道输入是什么,因此你无法反向运行此算法。

总而言之,基于HMAC的PRG是安全的。它们与随机数不可区分,并且还具有抗回滚性。我们拥有这个额外的属性,即你无法反向运行该算法。

还有一件事我之前没有提到,现在不妨提一下:DRBG 代表确定性随机比特生成器,这只是人们有时对PRG使用的另一个名称。有很多缩写,但这就是DRBG的含义。


本节课中我们一起学习了两种安全的伪随机数生成器实现:基于计数器模式和基于HMAC的构造。我们了解到,它们都依赖于密码学原语的安全性来保证输出的伪随机性,并且基于HMAC的实现还具有抗回滚的重要特性。记住,PRNG本质上是确定性的算法,它们将有限的真实随机种子扩展为看似随机的长序列。

136:使用随机性生成唯一ID 🔑

在本节课中,我们将学习伪随机数生成器的实际应用之一:生成全局唯一标识符。我们将探讨其原理、实现方式以及需要注意的安全事项。

伪随机数生成器的安全问题 ⚠️

上一节我们介绍了伪随机数生成器的构建方式,本节中我们来看看实际应用中常见的安全隐患。

不安全的伪随机数生成器在现实中出现得相当频繁,因此我们有很多反面案例。请不要成为下一个案例。

如果使用安全的伪随机数生成器,但没有提供足够的熵,攻击者就可能预测未来的随机输出,从而导致整个安全体系被破坏。

以下是更多相关案例:

  • 案例一
  • 案例二
  • 案例三

我不再逐一讲解这些案例,如果你感兴趣可以自行阅读。但请务必记住,在使用伪随机数生成器时,必须选择安全的算法,并在种子中传入足够的熵。

全局唯一标识符的应用 🆔

随机性的一个与我们之前讨论内容不同的应用,是生成所谓的“全局唯一标识符”。在第二个密码学项目中,你将实际使用到它。

应用场景如下:假设你拥有许多对象,例如文件系统中的文件,你需要为每个对象分配一个唯一且不可预测的ID。你不想简单地使用1、2、3、4、5这样的顺序编号,而是希望编号不可预测,并且确保没有任何两个对象的ID会重复。

事实证明,一种实现方法就是为每个对象随机选择一个数字。你可能会担心运气不好,为两个不同的对象选到了相同的随机数。但如果使用的数字足够大,就可以使这种概率变得极低。

例如,如果你的UUID是128位长,那么生成两个重复数字的概率是 1/(2^128)。我们已经讨论过,这个概率是天文数字般的低,基本上等于零。因此,选择随机数几乎等同于获得了唯一性。

这很酷:通过使用随机性,我们可以为所有不同的对象获得唯一的标识符,这就是UUID。你实际上可以使用伪随机数生成器来实现它,并可以在项目二中用它来为项目中需要定义的不同事物分配唯一ID。

这是一个不同且有用的随机性应用示例。

本章总结 📝

本节课中我们一起学习了伪随机数生成器及其一个重要应用。

我们首先指出,真正的随机性成本高昂。为了解决这个问题,我们设计了伪随机数生成器。它是一个确定性算法,接收少量称为“种子”的真正随机性,然后高效地生成大量看似随机的输出。

如果将伪随机数生成器视为代码中的一个对象,它有三种方法:

  • seed方法:接收熵,不输出任何内容。
  • reseed方法:接收熵,不输出任何内容。
  • generate方法:接收一个数字,生成相应位数的伪随机输出。

伪随机数生成器的安全性在于其输出在计算上与真正的随机性不可区分。一个额外但不同的理想属性是“回滚抵抗性”。

我们看到了两种构建伪随机数生成器的方法:一种基于分组密码,另一种基于哈希函数,当然也存在其他构造方法。

最后,我们展示了一个不同的随机性应用实例。

关于伪随机数生成器的内容就到这里,接下来我们将学习迪菲-赫尔曼密钥交换。

137:流密码定义

在本节课中,我们将学习流密码的概念。流密码是一种对称加密算法,它结合了伪随机数生成器和一次性密码本的思想,能够高效地加密数据。

上一节我们介绍了伪随机数生成器,本节中我们来看看如何将其应用于加密,构建一个称为流密码的加密方案。

流密码的基本原理

流密码是一种对称密钥加密方案。它假设通信双方,例如爱丽丝和鲍勃,共享一个其他人不知道的密钥。

以下是加密过程:

  1. 爱丽丝使用她的秘密密钥作为种子,初始化一个伪随机数生成器。
  2. 她生成与明文长度相同的伪随机比特流。
  3. 她将明文与此伪随机比特流进行异或运算,得到密文。
  4. 她将密文发送给鲍勃。

鲍勃的解密过程与之对称:

  1. 鲍勃使用相同的秘密密钥作为种子,初始化相同的伪随机数生成器。
  2. 由于伪随机数生成器是确定性的,鲍勃会生成与爱丽丝完全相同的伪随机比特流。
  3. 他将收到的密文与此比特流进行异或运算,即可恢复原始明文。

对于窃听者夏娃而言,由于她不知道秘密密钥,因此无法生成正确的伪随机比特流,也就无法解密消息。

引入初始化向量

我们目前的构造存在一个问题:它不允许加密多条消息。如果爱丽丝尝试加密第二条消息,她会使用相同的种子,导致伪随机数生成器产生相同的输出。这相当于在一次性密码本中重复使用密钥,这是不安全的。

为了解决这个问题,我们需要引入一个初始化向量。改进后的方案如下:

当爱丽丝想要加密消息时:

  1. 她首先生成一个随机的初始化向量。
  2. 她使用秘密密钥本次消息的初始化向量共同作为种子,来初始化伪随机数生成器。
  3. 然后,她生成所需的伪随机比特流。
  4. 最后,她将这些伪随机比特与明文进行异或运算,得到密文。

爱丽丝发送给鲍勃的数据包括两部分:加密得到的密文,以及用于本次加密的初始化向量。

鲍勃的解密过程:

  1. 鲍勃收到公开的初始化向量。
  2. 他使用共享的秘密密钥和收到的初始化向量共同作为种子,初始化伪随机数生成器。这会生成与爱丽丝加密时完全相同的伪随机比特流。
  3. 他将此比特流与密文进行异或运算,即可恢复原始明文。

为了加密多条消息,我们必须引入初始化向量,并且切记为每条消息使用不同的初始化向量。这样就能安全地工作。

本节课中我们一起学习了流密码的定义和工作原理。我们了解到,流密码通过伪随机数生成器模拟一次性密码本,实现了高效的对称加密。同时,为了支持多消息加密并保证安全,必须为每次加密引入一个唯一的初始化向量。

138:流密码特性 🔐

在本节课中,我们将要学习流密码的几个关键特性。我们将探讨流密码的不同构建方式、其安全性证明,以及它们在实际应用中的优势,例如支持流式处理和随机访问解密。


流密码的构建方式

流密码有多种构建方式。仔细观察,你可以认为AES CTR模式就是一种流密码。在加密流程的上半部分,它使用分组密码生成大量伪随机输出,然后将其作为一次性密码本方案中的密钥流。因此,可以说这也是一种流密码。

流密码的安全性

只要假设伪随机生成器的输出是安全的,流密码就可以被证明是IND-CPA安全的。其证明过程与我们证明一次性密码本安全性的过程非常相似。

一个非常次要的注意事项是,必须小心不要一次性加密过多数据。例如,在AES CTR模式中,如果加密的消息过长,可能会导致计数器回绕到零,从而开始重复使用密钥流,这会破坏安全性。虽然这种情况在实践中不常发生,但务必注意不要加密大到导致AES CTR计数器回绕的数据。

流密码的优势:流式处理

正如其名,流密码的一个好处是支持流式处理。这意味着你可以随着数据的流入,逐步加密和解密额外的数据。

以下是流式处理的具体应用场景:

  • 场景一:解密大文件:假设你下载了一个大文件的一半。你可以生成足够的伪随机数生成器输出,来解密你当前拥有的这一半文件。当文件的另一半稍后被下载时,你只需从上次停止的地方继续运行PRNG,它就会生成更多字节,用于解密文件的第二部分。
  • 场景二:加密流数据:如果你正在加密数据,但目前没有全部数据,你可以运行PRNG来加密现有的部分。当后续数据到达时,你只需继续运行PRG,它就会从上次中断的地方继续生成输出,从而允许你加密不断流入的剩余数据。

流密码的优势:随机访问

某些流密码的另一个好处是,它们允许你加密或解密消息的任意部分,而无需处理消息的其余部分。

例如,假设你有一个1GB大小的密文,但你只关心最后128字节。一些流密码允许你直接跳转到末尾,仅解密你关心的部分,而无需处理整个密文。

AES CTR模式支持此功能。如果你只想解密第 i 个数据块,操作如下:

  1. 取随机数 nonce
  2. 将其与代表第 i 块的数字 i 连接起来。
  3. 将结果传入分组密码加密函数。
  4. 得到的输出就是第 i 块对应的密钥流。
  5. 用这个密钥流与密文进行异或操作,即可得到原始明文,而无需处理密文的任何其他部分。

然而,并非所有流密码都支持此功能。例如,如果使用基于HMAC的流密码,并且你想解密最后一个块,你必须生成直到最后一个块的所有PRNG输出。因为PRNG的工作原理是反复调用HMAC。所以,要得到最后一个块,你必须多次调用HMAC来生成直到最后一个块的所有输出,因此无法任意跳转到末尾。


在本节课中,我们一起学习了流密码的特性。我们了解了其构建思路与安全性基础,并重点探讨了流式处理和随机访问解密这两个实用优势。这些特性使得流密码在特定场景下非常高效。接下来,我们将可以进入迪菲-赫尔曼密钥交换的学习。

139:安全颜色共享(类比)

在本节课中,我们将学习迪菲-赫尔曼密钥交换的基本思想。我们将从一个简单的颜色混合类比开始,理解两个人在不安全的信道上如何协商出一个共享的秘密,而窃听者却无法获知这个秘密。

上一节我们介绍了对称密钥方案需要一个共享的秘密密钥。本节中我们来看看,如何在不安全的信道上安全地建立这个共享密钥。

颜色共享类比

为了理解迪菲-赫尔曼密钥交换的工作原理,我们从一个类比开始。在这个类比中,爱丽丝和鲍勃希望在一个不安全的信道上共享一个秘密颜色。爱丽丝不能直接将秘密颜色发送给鲍勃,因为那样窃听者夏娃也会知道这个秘密。

因此,他们将使用一种众所周知的公共颜料来伪装这个秘密。以下是具体的步骤:

  1. 建立公共颜色:爱丽丝、鲍勃和夏娃都知道一个公共颜色,例如黄色。这个颜色用于帮助伪装秘密。
  2. 生成个人秘密:爱丽丝生成她那一半的秘密,例如红色。鲍勃生成他那一半的秘密,例如青色。
  3. 交换伪装后的秘密
    • 爱丽丝将她的秘密红色与公共黄色混合,得到橙色。她将橙色发送给鲍勃。
    • 鲍勃将他的秘密青色与公共黄色混合,得到蓝色。他将蓝色发送给爱丽丝。
  4. 计算共享秘密
    • 爱丽丝收到鲍勃的蓝色(青色+黄色)后,加入自己的秘密红色,最终混合得到红色+青色+黄色。
    • 鲍勃收到爱丽丝的橙色(红色+黄色)后,加入自己的秘密青色,最终也混合得到红色+青色+黄色。

这样,爱丽丝和鲍勃就协商出了相同的最终颜色(红色+青色+黄色),这个颜色就是他们的共享秘密。

类比的安全性分析

现在,如果你是窃听者夏娃,你会看到什么?你只能看到在信道上传输的橙色和蓝色。即使你知道公共颜色是黄色,并且知道橙色是红色和黄色的混合物,你也无法将黄色从橙色中分离出来以还原出原始的红色。分离颜料在物理上是非常困难的。同样,你也不能从蓝色中分离出青色。

这个类比的关键在于假设分离混合颜料是困难的。因此,夏娃即使看到了伪装后的秘密,也无法恢复出爱丽丝和鲍勃的个人秘密,从而无法计算出最终的共享秘密。

重新表述颜色共享方案

让我们用略微不同的颜色再次阐述这个方案,以便为后续的数学解释做铺垫。

以下是步骤的详细说明:

  1. 生成个人秘密:爱丽丝生成她的秘密颜色 A(琥珀色)。鲍勃生成他的秘密颜色 B(蓝色)。
  2. 确定公共颜色:所有人(包括夏娃)都知道公共颜色 G(绿色)。
  3. 交换伪装秘密
    • 爱丽丝将她的秘密 A 与公共颜色 G 混合,得到 G A(绿琥珀色),并将其发送给鲍勃。
    • 鲍勃将他的秘密 B 与公共颜色 G 混合,得到 G B(绿蓝色),并将其发送给爱丽丝。
  4. 计算共享秘密
    • 爱丽丝收到 G B,然后加入自己的秘密 A,得到 G A B(绿琥珀蓝色)。
    • 鲍勃收到 G A,然后加入自己的秘密 B,也得到 G A B(绿琥珀蓝色)。

爱丽丝和鲍勃成功协商出了相同的共享秘密 G A B

夏娃面临的挑战

夏娃知道公共颜色 G,也看到了信道上的 G AG B。她可能试图混合这些颜色来获得秘密,但即使她将 G AG B 混合,得到的结果也是 G A G B,其中包含了过多的绿色成分,与爱丽丝和鲍勃得到的 G A B 并不相同。

这个类比在此处略有延伸,但核心思想是:即使夏娃知道公共成分和伪装后的秘密,由于无法逆向分离出原始的个人秘密,她也就无法计算出最终的共享秘密。

本节课中我们一起学习了迪菲-赫尔曼密钥交换的核心思想,通过一个颜色混合的类比,理解了双方如何在不安全的信道上建立一个共享的秘密,而第三方无法破解。这个类比的关键在于“混合容易,分离难”的特性。接下来,我们将用实际的数学运算来替换这个颜色类比,正式学习迪菲-赫尔曼密钥交换算法。

140:离散对数问题与Diffie-Hellman问题 🔐

在本节课中,我们将要学习两个核心的密码学难题:离散对数问题和Diffie-Hellman问题。它们是现代非对称加密和密钥交换协议(如Diffie-Hellman密钥交换)的数学基础。我们将通过简单的比喻和直观的图表来理解它们为何难以解决。

上一节我们介绍了“混合颜料”的比喻,本节中我们来看看其背后的数学原理。

离散对数问题

离散对数问题是密码学中的一个核心难题。它描述了一个在模运算下看似简单,实则极难逆向计算的过程。

首先,让我们描述一下这个问题是什么。所有人都知道一个大质数 P 和一个数字 G。技术上,这个数字 G 必须是模 P 下的一个“生成元”,但这对于我们的理解并非最关键。

问题是这样陈述的:所有人都知道数字 GP。现在,如果我选择一个秘密数字 a,计算 G^a mod P,并把这个结果给你。对你来说,要找出我选择了哪个 a 值来计算这个结果是极其困难的。这就是离散对数问题:给定 G^a mod P,很难找到 a

起初,这看起来可能有些矛盾,似乎你只需要取对数就能找到原始的 a。为了理解为什么离散对数问题如此困难,让我们看一些图片。

在右边的图中,我绘制了 G^a(没有取模 P)的曲线。如果我们不是在模运算的空间里工作,你说得对,解决这个问题会相当容易。如果我选择一个大的秘密 a,那么 G^a 也会很大,你就能告诉我我选择了多大的 a 值。例如,如果我选择了一个大的 a 值,我会输出这些大数字中的一个,你就能告诉我我一定选择了一个大的 a 值。如果我选择了一个小的 a 值,我会输出这些较小的值中的一个,你就能告诉我我选择了一个小的 a 值。

然而,如果我们在模 P 的空间里工作,即我们取所有这些数字并计算它们对 P 取模的结果,情况就突然变了。这个图不再有任何可识别的模式。如果我取一个小的 a 值并计算 G^a mod P,结果可能是一个大数字。或者,如果我取一个大数字并计算 G^a mod P,它也可能是一个大数字。又或者,如果我取一个中等大小的 a 值并计算 G^a mod P,它同样可能是一个大数字。所以,仅仅因为我告诉你一个大数字,并不意味着我选择的 a 是大、是小还是中等,这根本不清楚。

无论我告诉你 G^a mod P 的哪个值,你都很难从这个图中识别出任何能帮助你找到原始 a 值的模式。所以,如果我告诉你我计算了 G^a mod P,并且结果是这里的这个值(Y轴上的值),你无法知道我在X轴上为 a 选择了哪个值。它可能是一个大值,但也可能是这些较小的值之一,这并不明确。这就是离散对数问题,而这张图解释了它为何如此难以解决。

Diffie-Hellman问题

离散对数问题有一个近亲,叫做Diffie-Hellman假设。同样在这个假设中,所有人都知道一些公共值 GP

现在将要发生的是:我将秘密选择两个值 ab(你不知道),然后计算 G^a mod PG^b mod P,并将这两个值呈现给你。Diffie-Hellman假设指出,你无法计算出 G^(a*b) mod P。如果我给你 G^(a*b) mod P 和一个随机数 R,你无法告诉我哪个是正确答案,哪个是随机值。

这个问题之所以困难的直觉在于:如果你想计算 G^(a*b) mod P,最好的方法是先计算出 a,然后用 G^ba 次方来得到 G^(a*b) mod P。但当然,这样做是困难的,因为给定 G^a mod P 去得到原始的 a 就是在解决离散对数问题,而我们刚刚说过这非常困难。

所以,两者的区别非常微妙。你可以把离散对数问题和Diffie-Hellman假设看作是近亲。而这些问题的难度,正是为我们解锁“安全颜色共享”的数学版本的关键。


本节课中我们一起学习了离散对数问题和Diffie-Hellman问题。我们了解到,在模运算下,指数运算的结果会变得混乱无序,使得从结果逆向推导出指数变得极其困难。离散对数问题是直接寻找秘密指数 a,而Diffie-Hellman问题则是要求在已知 G^aG^b 的情况下计算 G^(a*b),其安全性同样依赖于离散对数问题的困难性。这两个难题构成了许多现代安全通信协议的基础。

141:Diffie-Hellman密钥交换 🔑

在本节课中,我们将要学习Diffie-Hellman密钥交换协议。这是一种允许两个人在不安全的公开信道上安全地建立一个共享密钥的方法。其安全性基于我们上一节介绍的离散对数问题的计算困难性。

上一节我们介绍了离散对数问题,本节中我们来看看如何利用它来构建一个安全的密钥交换协议。

协议概述与公开参数

Diffie-Hellman密钥交换本质上是我们之前讨论过的“安全颜色混合”方案的数字版本,它使用离散对数问题来伪装交换过程中的秘密。

协议开始前,所有参与者(例如Alice、Bob和窃听者Eve)都知道一些公开的数值。这些参数包括:

  • 一个大的质数 P
  • 一个整数 G,它是模P下的一个原根

这些参数是公开的,任何人都可以知道。

生成与交换“伪装”的秘密

现在,Alice和Bob各自生成自己的一半秘密。

以下是他们各自的操作步骤:

  • Alice 生成一个私密的随机数 A。这是她的一半秘密。为了伪装这个秘密,她计算 G^A mod P,并将这个结果通过公开信道发送给Bob。
  • Bob 生成一个私密的随机数 B。这是他的一半秘密。同样,他计算 G^B mod P,并将这个结果通过公开信道发送给Alice。

此时,窃听者Eve可以观察到在信道上传输的两个值:G^A mod PG^B mod P

计算共享密钥

在收到对方发送的“伪装”秘密后,Alice和Bob可以分别计算出相同的共享密钥。

具体计算过程如下:

  • Alice 收到了Bob发来的 G^B mod P。她利用自己的私密数字A进行计算:(G^B)^A mod P = G^(B*A) mod P
  • Bob 收到了Alice发来的 G^A mod P。他利用自己的私密数字B进行计算:(G^A)^B mod P = G^(A*B) mod P

由于 G^(A*B) mod PG^(B*A) mod P 的结果完全相同,因此Alice和Bob成功地获得了一个共享的秘密值,这个值可以作为他们后续通信的对称密钥。

安全性分析:为什么Eve无法获得密钥?

Diffie-Hellman密钥交换的安全性基于一个核心假设:给定 G^A mod PG^B mod P,在计算上不可能推导出 G^(A*B) mod P

其背后的原理是离散对数问题的困难性。如果Eve想要计算出共享密钥,她首先需要从 G^A mod P 中反推出A,或者从 G^B mod P 中反推出B,然后再进行计算。而求解A或B的过程正是困难的离散对数问题。

Eve也无法通过其他简单操作(如将两个公开值相乘)来获得密钥,因为:
(G^A mod P) * (G^B mod P) = G^(A+B) mod P
这与我们需要的 G^(A*B) mod P 完全不同。指数运算不满足这种线性关系。

总结

本节课中我们一起学习了Diffie-Hellman密钥交换协议。总结如下:

  1. 该协议利用离散对数问题的计算困难性,允许Alice和Bob在不安全的信道上建立一个共享的秘密密钥。
  2. 双方通过交换经过指数运算伪装的公开值,并利用各自的私密数进行计算,最终得到相同的共享密钥。
  3. 窃听者Eve虽然能截获所有公开信息,但由于无法解决离散对数问题,她无法推导出Alice和Bob共享的那个秘密密钥。

这就是Diffie-Hellman密钥交换的完整故事。

142:Diffie-Hellman 具有前向保密性 🔐

在本节课中,我们将学习 Diffie-Hellman 密钥交换协议的一个重要特性:前向保密性。我们将了解“短暂性”的含义,以及它如何保护过去的通信内容,即使系统在未来被攻破。


Diffie-Hellman 密钥交换协议的一个有用特性是它具有短暂性。

“短暂性”是一个专业术语,意思是短期或临时的。

Diffie-Hellman 之所以是短暂的,是因为你可以在使用完相关数值后立即丢弃它们。

例如,一旦 Alice 和 Bob 完成交换并推导出 G^(AB) mod P,他们现在就可以丢弃他们各自持有的原始秘密部分 A 和 B,这些值不再有用。

同样地,如果 Alice 和 Bob 使用 G^(AB) mod P 作为共享密钥,一旦他们使用完这个密钥,他们也可以销毁它,因为不再需要了。

因此,密钥 K 有时被称为会话密钥。你用它来进行一次会话,以交换消息并进行加密和解密。一旦会话结束,Alice 和 Bob 注销,他们都可以销毁 K。

那么,为什么 Diffie-Hellman 的短暂性是有用的呢?

这是因为一个叫做“前向保密性”的特性。

前向保密性是指:即使未来的攻击者入侵了你的系统,也无法解密过去的消息。

场景是这样的。假设 Alice 和 Bob 现在使用 Diffie-Hellman 密钥交换来协商一个共享密钥,然后他们使用这个共享密钥加密了一系列消息。

如果你是现在的 Eve,你无法破解 Alice 和 Bob 的加密方案。你看到了 G^A mod P,可以记下来。你也看到了 G^B mod P,同样可以记下来。你还看到了使用共享密钥加密的消息,你可以记下所有这些数据,但你现在无法解密任何内容,因为你无法生成共享密钥,也无法破解加密方案。所以现在的 Eve 束手无策。

前向保密性意味着,即使是未来的 Eve,如果她能够入侵我们的系统,仍然无法解密这些被记录下来的消息。

假设一周后,Eve 入侵了 Alice 的电脑并窃取了她所有的秘密。由于 Diffie-Hellman 是短暂的,A、B 和 K 都已经被销毁了。

这意味着 Eve 无法解密她上周所做的记录。

这表明 Diffie-Hellman 确实具有前向保密性,因为它会丢弃所有使用完毕的数值。一个不具备前向保密性的方案,将允许像 Eve 这样的未来攻击者窃取某些秘密,然后利用这些秘密来解密之前记录的内容,而这正是我们不希望发生的。

因此,Diffie-Hellman 的一个有用特性是它提供了前向保密性,使得未来入侵我们系统的攻击者无法解密他们之前记录的消息。


在本节课中,我们一起学习了 Diffie-Hellman 密钥交换的“短暂性”特性,以及它如何实现“前向保密性”。我们了解到,通过在使用后立即销毁临时密钥和秘密值,可以确保即使系统在未来被攻破,过去的加密通信内容仍然是安全的。

143:Diffie-Hellman中间人攻击 👥🔓

在本节课中,我们将要学习Diffie-Hellman密钥交换协议的一个关键弱点:中间人攻击。我们将详细拆解攻击者Mallory如何通过篡改通信消息,破坏Alice和Bob之间通信的机密性和完整性。


协议回顾与攻击场景

上一节我们介绍了Diffie-Hellman密钥交换的基本流程。本节中我们来看看,当存在一个能够篡改消息的攻击者Mallory时,协议的安全性如何被破坏。

Diffie-Hellman密钥交换对窃听者Eve是安全的,但我们尚未考虑攻击者Mallory。Mallory能够篡改我们传递的消息。事实证明,Mallory可以对Diffie-Hellman密钥交换发起一种攻击。

攻击过程详解

以下是Mallory实施中间人攻击的详细步骤:

  1. Alice发起通信
    Alice像往常一样生成她的私钥 a,计算 g^a mod p 并将其发送到通信信道。

  2. Mallory拦截并篡改发给Bob的消息
    正常情况下,该消息会被Bob接收。但由于Mallory的存在,她可以拦截该消息并将其替换为一个不同的值。具体来说,Mallory会将其替换为 g^m mod p。Mallory选择她自己的私钥 m,计算 g^m mod p,并将这个消息发送给Bob。因此,Bob期望收到 g^a mod p,但由于Mallory的篡改,他实际收到了 g^m mod p

  3. Mallory在相反方向进行同样的篡改
    Bob生成私钥 b,计算 g^b mod p 并希望将其发送给Alice。然而,Mallory再次介入。她收到 g^b mod p,拦截它,将其替换为 g^m mod p,并将该值发送给Alice。因此,Alice期望收到 g^b mod p,但她实际收到了 g^m mod p

  4. Alice和Bob计算错误的共享密钥
    Alice和Bob并不知道值已被篡改。他们如何区分 g^bg^m?他们并不知道自己应该收到哪一个。因此,他们双方都感觉一切正常。

    • Alice会将她收到的值(她以为是 g^b,但实际是 g^m mod p)进行 a 次幂运算,从而计算出 g^(a*m) mod p。这个值是不正确的,但这就是Alice推导出的密钥。
    • 同样,Bob会将他收到的值(他以为是 g^a mod p,但实际是 g^m mod p)进行 b 次幂运算,从而推导出 g^(b*m) mod p。这个值也是不正确的。
  5. Mallory掌握双方密钥
    Mallory导致Alice和Bob推导出了不同的秘密。更糟糕的是,Mallory自己可以推导出这两个秘密。
    查看Mallory知道的所有值:她知道 m(她自己选择的),她知道Alice发送的 g^a mod p,也知道Bob发送的 g^b mod p。利用这三个值,她可以推导出Alice和Bob推导出的秘密:

    • 她可以取 g^a mod p,用她自己的私钥 m 进行幂运算,从而推导出 g^(a*m) mod p,即Alice推导出的秘密。
    • 同样,她从Bob那里收到了 g^b mod p,她知道自己的秘密 m,因此可以推导出 g^(b*m) mod p,即Bob的秘密。

攻击后果

现在情况变得非常严重。Mallory成为了一个完全的中间人。

例如,如果Alice用她认为的共享密钥加密某条消息并发送给Bob,Mallory可以拦截它,用她知道的 g^(a*m) mod p 解密,随心所欲地篡改消息,然后用 g^(b*m) mod p 重新加密,再发送给Bob,而Bob对此一无所知。

然后,当Bob发送消息时,他会用 g^(b*m) mod p 加密,Mallory可以拦截、解密、阅读并任意修改。当她完成后,可以用 g^(a*m) mod p 加密,将这条被篡改的消息发送给Alice,而Alice也对此一无所知。

因此,Mallory现在拥有读取和修改Alice与Bob之间通信的全部能力,而Alice和Bob却毫不知情。

攻击的本质与协议缺陷

我们已经展示了存在一种攻击,使得能够篡改消息的Mallory可以破坏Diffie-Hellman的安全性,并破坏任何使用Diffie-Hellman生成密钥的方案的完整性和机密性。

所以,这是可以对Diffie-Hellman密钥交换实施的一种攻击。

Diffie-Hellman密钥交换的一个主要问题是,它无法抵御使用刚才所见的攻击的中间人对手。换一种方式表述同一点:Diffie-Hellman不提供身份认证

换句话说,你成功地与某人完成了一次交换,但你并不知道你是与谁完成的交换。

以另一种等价的方式来看我们之前看到的图示:这里发生了两次成功的Diffie-Hellman密钥交换。

  • Alice进行了一次密钥交换。她以为她是和Bob进行的,但实际上,Mallory介入了。Alice和Mallory完成了一次成功的交换,双方都知道了秘密 g^(a*m) mod p。所以Alice确实成功进行了一次密钥交换,只是她以为对象是Bob,而实际对象是Mallory。
  • 同样,Bob以为他成功地与Alice进行了一次密钥交换,但实际上,他与Mallory完成了一次成功的密钥交换,因为Bob和Mallory现在共享密钥 g^(b*m) mod p

因此,看待这张图的另一种方式是:发生了两次成功的密钥交换,但它们不是发生在Alice和Bob之间,而是发生在Alice与Mallory、以及Mallory与Bob之间,因为Mallory介入了他们中间。

这就是当我们说Diffie-Hellman不提供身份认证时的含义:你不知道你是在与Bob交换密钥,还是Mallory已经介入,导致你实际上是在与Mallory交换密钥。

Diffie-Hellman的其他限制

Diffie-Hellman的另一个问题是它是一个主动协议。Alice和Bob需要同时在线。他们需要主动交换 g^a mod pg^b mod p 才能推导出共享秘密。如果只有一方在线,则无法推导出共享秘密。

因此,在Alice当前不在线(例如在度假、小睡)而Bob想要加密某些内容并发送给Alice供其稍后阅读的场景中,Diffie-Hellman无法支持此类操作。我们必须设计一些不同的方案来支持双方不同时在线的通信。


总结

本节课中我们一起学习了Diffie-Hellman密钥交换协议的中间人攻击。我们了解到:

  1. 攻击者Mallory可以通过拦截并替换通信双方交换的公开参数(g^a mod pg^b mod p),使双方分别与她建立不同的共享密钥。
  2. 由于Mallory知道所有中间值,她能够解密双方发送的任何加密消息,并进行读取和篡改。
  3. 该攻击的根本原因在于Diffie-Hellman协议本身缺乏身份认证机制,无法确保通信对象的真实性。
  4. 此外,Diffie-Hellman是一个需要双方同时在线的主动协议,这限制了其在异步场景下的应用。

为了解决这些安全问题,在实际应用中,Diffie-Hellman通常需要与数字签名等身份认证技术结合使用(例如在TLS/SSL协议中),以抵御中间人攻击并确认通信双方的身份。

144:椭圆曲线与迪菲-赫尔曼密钥交换总结 🔐

在本节课中,我们将总结迪菲-赫尔曼密钥交换协议,并简要介绍另一种能实现相同功能的数学工具——椭圆曲线密码学。我们将了解其核心思想、优势,并回顾迪菲-赫尔曼协议的关键特性与局限性。


椭圆曲线密码学简介 🔮

上一节我们介绍了基于离散对数问题的迪菲-赫尔曼密钥交换。本节中我们来看看,数学的其他领域也能提供类似的属性,帮助隐藏秘密,使得攻击者即使看到被伪装后的秘密,也无法得知原始秘密是什么。

其中一个数学领域被称为椭圆曲线密码学。我们不会深入探讨其工作原理,你可以自行查阅。对于本课程而言,只需将其视为一种具有与离散对数问题相似特性的“魔法数学”,因此你也可以使用椭圆曲线数学来构建迪菲-赫尔曼密钥交换,以伪装秘密。

使用椭圆曲线密码学的一个好处是,它可以用更小的密钥尺寸提供同等级别的安全性。

例如,如果我们使用约3000位的大数进行离散对数迪菲-赫尔曼密钥交换,那么使用300位的椭圆曲线数字就能获得相同的安全性。这里的“相同安全性”是指,破解这两种方案所需的暴力计算量是相同的。椭圆曲线版本使用更小的密钥,是因为在某种意义上,其底层数学问题更难解决。

关于椭圆曲线密码学,我们就讲这么多。如果你好奇,可以随时去查阅相关资料。只需知道,其他数学领域也可用于构建迪菲-赫尔曼密钥交换,并且其中一些能以更小的密钥尺寸提供相同的安全级别。


迪菲-赫尔曼密钥交换协议总结 📋

现在,我们来总结迪菲-赫尔曼密钥交换协议。该协议流程如下:

以下是协议的具体步骤:

  1. 爱丽丝选择她的秘密部分 A,使用公开值 GP 将其伪装为 G^A mod P,并将结果发送给鲍勃。
  2. 鲍勃选择他的秘密部分 B,同样将其伪装为 G^B mod P 并发送给爱丽丝。
  3. 双方收到对方发来的伪装秘密后,用各自的秘密指数对其进行运算。
  4. 爱丽丝计算 (G^B mod P)^A mod P = G^(A*B) mod P
  5. 鲍勃计算 (G^A mod P)^B mod P = G^(A*B) mod P
  6. 最终,双方都计算出相同的共享密钥:G^(A*B) mod P

迪菲-赫尔曼协议提供了前向安全性。这意味着,即使攻击者未来攻破了我们的系统,他们也无法回过头来解密之前被记录下来的通信内容。原因在于,AB 甚至共享密钥在使用完毕后都可以被丢弃。


迪菲-赫尔曼密钥交换的局限性 ⚠️

然而,迪菲-赫尔曼密钥交换也存在一些问题。

以下是其主要局限性:

  • 无法抵御中间人攻击:该协议不提供身份认证,通信双方无法确认正在与谁交谈。这一点在我们演示的攻击中已经体现,马洛里可以导致爱丽丝和鲍勃推导出不同的秘密,而马洛里自己却知道这两个秘密。
  • 等效视角:看待这个问题的另一种方式是,马洛里介入了中间。爱丽丝与马洛里成功进行了一次交换,同时马洛里与鲍勃也成功进行了一次交换。双方都完成了交换,只是不知道交换对象是谁。因此我们说它缺乏真实性。
  • 双方必须在线:协议的另一个问题是,通信双方必须同时在线才能完成密钥交换。我们将在下次课程中解决这个问题。

本节课中,我们一起学习了椭圆曲线密码学的核心概念及其在密钥交换中的优势,并全面回顾了迪菲-赫尔曼密钥交换协议的工作原理、重要特性(如前向安全性)以及其固有的局限性(如易受中间人攻击)。希望这解答了你关于随机数生成以及通信双方如何最初获得对称密钥的一些核心疑问。我们下次再见!

145:公钥密码学入门 🗝️

在本节课中,我们将学习公钥密码学的基本概念。我们将回顾伪随机数生成器和Diffie-Hellman密钥交换,然后重点介绍公钥密码学的核心思想、其组成部分以及它与对称密钥密码学的区别。

回顾:伪随机数生成器与Diffie-Hellman密钥交换

上一节我们介绍了伪随机数生成器(PRNG)。由于真正的随机性代价高昂,我们设计了PRNG算法。该算法输入少量真正的随机性,能够高效生成大量看似随机的输出。从攻击者的角度来看,当PRNG的输出在计算上与真正的随机比特无法区分时,它就是安全的。我们展示了PRNG的几种不同构造方法,例如基于HMAC的构造。

在上一节的后半部分,我们讨论了Diffie-Hellman密钥交换。Alice选择她的秘密部分A,将其伪装成 G^A mod P 发送给Bob。Bob选择他的秘密部分B,将其伪装成 G^B mod P 发送给Alice。双方收到对方伪装的秘密后,用自己持有的秘密指数进行计算,最终双方都能推导出共享密钥 G^(A*B) mod P。离散对数问题确保了像Eve这样的攻击者无法推导出共享密钥。

然而,Diffie-Hellman密钥交换存在一些问题。它无法抵抗中间人攻击,不提供真实性。我们在演示Mallory的攻击时展示了这一点,Mallory能让Alice和Bob推导出不同的密钥,并且这些密钥Mallory都知道。另一个问题是,双方必须同时在线。

公钥密码学概述

本节我们将探讨公钥密码学。观察我们的密码学路线图,我们现在位于表格的右侧部分。我们将研究使用非对称密钥模型的方案。其中一些方案提供机密性,另一些则提供完整性和身份验证。

在所有我们将看到的公钥密码学方案中,每个人都拥有两个密钥,它们构成一个关联对。每个人都拥有一个众所周知的公钥和一个只有自己知道的私钥。密钥成对出现,每个公钥恰好对应一个私钥,不能互换。每个人都只拥有一对密钥。

公钥密码学的许多方案涉及大量数论知识,例如模运算、大数分解以及我们上次提到的离散对数问题。公钥密码学的一个好处是,我们不再需要假设Alice和Bob事先神奇地共享了一个秘密。仅使用每个人的公钥和私钥,这些方案就能工作。

然而,公钥密码学的一个主要缺点是它比对称密钥密码学慢得多。回想一下,在对称密钥密码学中,我们只是对比特进行混洗,这是计算机非常擅长的事情。相比之下,在公钥密码学中,我们使用了所有这些花哨的数论知识,这些你可能在CS70课程中见过。我不知道你怎么想,但我从未完成过任何一次CS70考试,我总是时间不够用。所以,这或许表明,与对称密钥密码学相比,数论运算相当慢。

总结

本节课我们一起回顾了伪随机数生成器和Diffie-Hellman密钥交换的机制与局限。随后,我们重点学习了公钥密码学的核心模型,即每个人都拥有一对关联的公钥和私钥。我们了解到公钥密码学基于复杂的数论问题,其优势在于无需预先共享秘密,但代价是计算速度远慢于对称密钥密码学。

146:公钥加密

在本节课中,我们将学习公钥加密方案。公钥加密方案提供机密性,但不提供完整性或真实性。这些方案的运作方式是:任何人都可以加密消息,但只有接收者才能使用其私钥解密。

概述

公钥加密是一种允许任何人使用接收者的公钥加密消息,但只有拥有对应私钥的接收者才能解密的加密方法。它解决了对称加密中密钥分发的问题。

公钥加密的工作原理

上一节我们介绍了公钥加密的基本概念,本节中我们来看看其具体的工作方式。

任何人想要发送消息给我,都可以使用我的公钥来加密要发送给我的消息。然而,唯一能够解密这些消息的人是我,即消息的接收者,因为解密消息需要使用私钥。当消息被加密后,希望像Eve这样的窃听者无法弄清楚消息的内容。

公钥加密的形式化定义

更正式地,我们可以将公钥加密定义为需要实现的三个不同函数。

以下是构成公钥加密方案的三个核心算法:

  1. 密钥生成算法 (Key Generation):如果有人想要一个公钥-私钥对,你需要描述如何生成这些密钥。这里我们使用 SK 来表示私钥(因为“公钥”和“私钥”的英文首字母相同,我们使用不同的缩写,用 SK 表示私钥)。这个细节不重要,只是我们采用的一种表示方式。
  2. 加密算法 (Encryption):一旦有了密钥,任何人都可以加密消息。加密算法输入一个公钥 PK 和一个消息 M,输出一个密文 C。因为输入是公钥,所以任何人都能够加密消息。
  3. 解密算法 (Decryption):另一方面,解密方法输入一个私钥 SK 和密文 C,输出原始消息 M。因为解密函数输入的是私钥,这意味着只有消息的接收者,即拥有私钥的人,才能解密消息。

公钥加密方案的要求

了解了基本构成后,我们来看看一个好的公钥加密方案需要满足哪些要求。

一个好的公钥加密方案应该是正确的。所谓正确性,是指如果有人用公钥加密一条消息,我们用对应的私钥解密它(请记住,密钥是成对生成的,当你生成一个密钥对时,你会得到一对密钥),那么你应该总是能取回原始消息。

公钥加密应该是相对高效的。我们不会正式定义这意味着什么,但它应该花费合理的时间。

安全定义IND-CPA(选择明文攻击下的不可区分性),这与对称密钥加密方案的安全定义相同。

总结

本节课中我们一起学习了公钥加密。我们了解到公钥加密通过使用一对数学上相关的密钥(公钥和私钥)来提供机密性。公钥可以公开分享用于加密,而私钥必须由接收者秘密保存用于解密。一个好的方案需要满足正确性、效率,并达到IND-CPA的安全标准。这为在不安全信道上的安全通信奠定了基础。

147:ElGamal 加密方案 🧮

在本节课中,我们将要学习第一个公钥加密方案——ElGamal 加密。我们将看到它如何解决Diffie-Hellman密钥交换的两个主要问题,并允许在接收方离线时发送加密消息。

概述与背景

上一节我们介绍了公钥密码学的概念,本节中我们来看看ElGamal加密方案的具体实现。

ElGamal加密方案受到Diffie-Hellman密钥交换的启发。Diffie-Hellman允许Alice和Bob在不安全的信道上共享一个秘密,但它存在两个问题。首先,Diffie-Hellman本身不传输消息,它只生成一个共享秘密值 G^(AB) mod P。其次,它要求通信双方必须同时在线。ElGamal加密旨在解决这两个问题,它支持直接加密和解密消息,并且允许接收方(Bob)离线。

密钥生成 🔑

以下是Bob生成密钥对的步骤:

  1. Bob选择一个随机数作为私钥:b(一个随机整数)。
  2. Bob计算对应的公钥:B = G^b mod P

至此,Bob生成了一个密钥对:私钥 b 由自己秘密保存,公钥 B 则公开给所有人。这本质上就是Bob预先完成了Diffie-Hellman密钥交换中他自己那一半的计算。

加密过程 🔒

现在,假设Bob正在离线“睡觉”。Alice如何加密消息并发送给他呢?

以下是Alice加密消息 M 的步骤:

  1. Alice生成一个随机数:r
  2. Alice计算 R = G^r mod P。这相当于Alice完成了Diffie-Hellman密钥交换中她自己那一半的计算。
  3. 利用Bob的公钥 B 和自己的随机数 r,Alice可以推导出共享秘密:S = B^r mod P = G^(br) mod P
  4. Alice使用这个共享秘密来加密消息。加密操作是乘法:C2 = M * S mod P
  5. Alice将密文发送给Bob。密文由两部分组成:(R, C2)。其中 R 是Alice的临时公钥,C2 是加密后的消息。

整个过程可以在Bob离线的情况下完成。

解密过程 🔓

最终,Bob醒来并收到了密文 (R, C2)。他需要解密以获取原始消息 M

以下是Bob解密的步骤:

  1. 为了解密,Bob需要“撤销”Alice的加密操作。Alice将消息乘以了共享秘密 S = G^(br)。要恢复消息,Bob需要乘以 S 的模逆元,即 S^{-1} = G^(-br) mod P
  2. Bob利用自己的私钥 b 和收到的 R 来计算这个逆共享秘密:S^{-1} = R^{-b} mod P = G^(-br) mod P
  3. 得到逆共享秘密后,Bob将其与密文 C2 相乘:M = C2 * S^{-1} mod P = (M * G^(br)) * G^(-br) mod P = M mod P

这样,共享秘密与其逆元相互抵消,Bob就成功恢复出了原始消息 M

核心公式与代码描述

以下是ElGamal加密方案的核心数学描述:

  • 密钥生成
    • 私钥:sk = b (随机整数)
    • 公钥:pk = B = G^b mod P
  • 加密 (输入:消息 M, 公钥 B):
    1. 选择随机数 r
    2. 计算 R = G^r mod P
    3. 计算共享秘密 S = B^r mod P = G^(b*r) mod P
    4. 计算密文 C2 = M * S mod P
    5. 输出密文 (R, C2)
  • 解密 (输入:密文 (R, C2), 私钥 b):
    1. 计算逆共享秘密 S^{-1} = R^{-b} mod P = G^(-b*r) mod P
    2. 恢复消息 M = C2 * S^{-1} mod P

用伪代码表示如下:

# 密钥生成
sk = random_int()          # 私钥 b
pk = pow(G, sk, P)        # 公钥 B

# 加密
def encrypt(M, pk):
    r = random_int()
    R = pow(G, r, P)
    S = pow(pk, r, P)     # 共享秘密 S = B^r
    C2 = (M * S) % P
    return (R, C2)

# 解密
def decrypt(ciphertext, sk):
    R, C2 = ciphertext
    S_inv = pow(R, -sk, P) # 逆共享秘密 S^{-1} = R^{-b}
    M = (C2 * S_inv) % P
    return M

总结

本节课中我们一起学习了ElGamal公钥加密方案。我们了解到,ElGamal本质上是Diffie-Hellman密钥交换的一种变体,通过调整操作顺序并引入乘法加密,实现了两个重要改进:支持直接加密任意消息,以及允许接收方离线。发送方(Alice)利用接收方(Bob)预先发布的公钥完成“半次”密钥交换并加密消息;接收方随后使用自己的私钥完成另“半次”交换,计算出逆共享秘密来解密密文。这个方案清晰地展示了如何将密钥交换协议转化为一个完整的公钥加密系统。

148:ElGamal加密的安全性、可延展性与局限性 🔐

在本节课中,我们将要学习ElGamal加密方案的安全性基础、其存在的可延展性攻击漏洞,以及该方案在更高级安全定义下的局限性。


安全性论证:基于Diffie-Hellman问题

上一节我们介绍了ElGamal加密的流程,本节中我们来看看其安全性的理论基础。ElGamal加密的安全性论证与Diffie-Hellman密钥交换的安全性论证非常相似,这很合理,因为ElGamal加密正是基于Diffie-Hellman密钥交换构建的。

Diffie-Hellman密钥交换的安全性依赖于Diffie-Hellman问题,而该问题本身又基于离散对数问题的困难性。Diffie-Hellman问题描述如下:

公式:
给定 g^a mod pg^b mod p,计算 g^(ab) mod p 是困难的。

我们假设这个问题是成立的,因为离散对数问题本身是困难的,目前除了求解离散对数外,尚未发现解决此问题的有效方法。

在ElGamal加密中,攻击者Eve可以在不安全的信道上看到以下值:

  1. Bob的公钥 B = g^b mod p(公开给所有人)。
  2. Alice发送的密文,它包含两个部分:
    • R = g^r mod p(用于完成Diffie-Hellman密钥交换)。
    • C2 = M * (g^(br) mod p)(用共享密钥加密的消息)。

代码表示:

B = pow(g, b, p)      # Bob的公钥
R = pow(g, r, p)      # 密文第一部分
C2 = M * pow(g, b*r, p) % p  # 密文第二部分

观察 R = g^r mod pB = g^b mod p,这正是Diffie-Hellman问题中给出的两个值。根据Diffie-Hellman问题的假设,即使Eve知道这两个值,她也无法计算出共享秘密 g^(br) mod p

由于Eve无法推导出 g^(br) mod p,她就无法逆转加密过程来获取原始消息 M。如果她想得到 M,就需要将 C2 乘以 g^(-br) mod p,但她并不知道这个值。因此,窃听者Eve无法在ElGamal加密中恢复原始消息。这并非一个严格的证明,但解释了ElGamal加密提供机密性的直观原因。


可延展性攻击:缺乏完整性的证明

虽然ElGamal加密提供了机密性,但它不提供完整性。攻击者可以对ElGamal加密实施一种名为“可延展性”的攻击。

在这种攻击中,中间人攻击者Mallory可以篡改消息,导致Bob解密出一个可预测的、被修改过的消息。这是一个微妙的概念,让我们通过一个例子来理解。

假设Mallory看到一段密文,她并不知道密文的具体内容。但基于可延展性攻击,她能够以某种可预测的方式修改这段密文,使得Bob解密出的消息也以某种可预测的方式被改变。

例如,如果攻击的目的是使解密后的消息值翻倍,那么这意味着:

  • 如果Alice发送的消息是数字25,Mallory执行攻击后,Bob解密会看到50。
  • 如果Alice的原始消息是70,Bob解密后会看到140。

无论Alice发送的原始值是什么,也无论Mallory是否知道密文内容,她都能执行攻击,使得Bob解密出的数字总是原始值的两倍。这就是可延展性攻击:即使不知道密文,也能使Bob获得一个可预测的、被修改过的消息。


攻击演示:如何使消息翻倍

了解了攻击的概念后,我们具体看看Mallory是如何实现让消息翻倍的。

回顾ElGamal加密方案,Alice发送的密文是两个值 (R, C2),其中 C2 = M * (g^(br) mod p)

以下是Mallory的攻击步骤:

  1. Mallory拦截密文 (R, C2)
  2. 她将密文的第二部分 C2 替换为 2 * C2(即将其值翻倍)。
  3. 她将篡改后的密文 (R, 2*C2) 转发给Bob。

当Bob解密时,他会计算:

解密结果 = (2 * C2) * (R^{-b} mod p) mod p
        = 2 * [M * (g^(br) mod p)] * (g^{-br} mod p) mod p
        = 2 * M mod p

公式推导:
Decrypt(R, 2*C2) = 2 * C2 * R^{-b} = 2 * M * g^{br} * g^{-br} = 2 * M

因此,Bob最终解密得到的是 2 * M,即原始消息的两倍。所以,ElGamal加密容易受到这种可延展性攻击,攻击者只需修改密文的第二部分,就能可预测地改变解密后的消息内容。


重要警告:IND-CPA安全性

最后需要强调的一个重要警告是:我们目前所展示的ElGamal加密方案,实际上并不具备IND-CPA(在选择明文攻击下的不可区分性)安全性

我们之前没有讨论的一些非常微妙的细节,导致我们展示的这个基础方案并不完全安全。如果你希望ElGamal加密达到IND-CPA安全级别,必须添加一些额外的、我们在此不会深入讨论的修正措施。

因此,请记住:我们展示的基础ElGamal方案并非IND-CPA安全的,但经过适当修改后,它可以被增强以达到该安全标准。


总结

本节课中我们一起学习了:

  1. ElGamal加密的安全性:其机密性基于Diffie-Hellman问题的困难性,攻击者无法从公钥和密文中计算出共享秘密。
  2. 可延展性攻击:ElGamal加密不提供消息完整性。攻击者可以在不知道明文的情况下篡改密文,使接收方解密出一个可预测的、被修改的值(例如原始值的两倍)。
  3. 方案的局限性:基础的ElGamal加密方案不具备IND-CPA安全性,需要额外的修正才能满足更高级别的安全定义。

理解这些安全属性和局限性,对于在实际应用中正确、安全地使用加密方案至关重要。

149:RSA加密算法 🧮

在本节课中,我们将学习第二种公钥加密方案——RSA加密。我们将了解其密钥生成、加密和解密过程,并探讨其背后的数学原理,确保初学者也能理解这一经典算法的工作原理。


密钥生成 🔑

上一节我们介绍了公钥加密的基本概念,本节中我们来看看RSA加密的具体实现。首先,我们需要生成一对密钥:公钥和私钥。

以下是生成RSA密钥的步骤:

  1. 选择两个大质数:随机选取两个大质数 PQ。在实际操作中,可以通过随机选取大数并使用高效的素性测试来完成。
  2. 计算模数 n:计算 n = P * Q。通常 PQ 足够大,使得 n 的长度在2000到4000比特之间。
  3. 选择公钥指数 e:选择一个整数 e,它必须与 (P-1)*(Q-1) 互质(即最大公约数为1)。e 不能等于 (P-1)*(Q-1)10
  4. 计算私钥指数 d:计算 d,使其满足 d ≡ e^{-1} mod (P-1)*(Q-1)de 在模 (P-1)*(Q-1) 下的乘法逆元,可以使用扩展欧几里得算法高效计算。

完成计算后,公钥 是数字对 (n, e),需要公开。私钥 是数字 d,必须严格保密。


加密与解密过程 🔐

现在我们已经生成了密钥,接下来看看如何使用它们进行加密和解密。这个过程比密钥生成更直观。

加密算法 接收公钥 (e, n) 和明文消息 M,然后计算密文 C

C ≡ M^e mod n

解密算法 接收私钥 d 和密文 C,然后计算还原的明文:

M ≡ C^d mod n

简单来说,加密是进行 e 次方运算(e 代表加密),解密是进行 d 次方运算(d 代表解密)。


正确性证明 📐

前面的定义可能显得有些神秘。为了证明RSA加密确实有效,我们需要证明:对任何消息 M,先加密再解密总能得到原始消息。

也就是说,我们需要证明:

(M^e mod n)^d mod n = M

根据加密和解密公式,这等价于证明:

M^{e*d} mod n = M

由于我们在密钥生成时确保了 de(P-1)*(Q-1) 的逆元,即 e*d ≡ 1 mod (P-1)*(Q-1),根据欧拉定理和数论原理,可以推导出上述等式成立。这确保了RSA加解密的正确性。


本节课中我们一起学习了RSA加密算法。我们了解了其密钥生成的详细步骤,包括选择质数、计算模数和指数。我们明确了加密和解密的数学操作,并理解了其正确性所依赖的数论基础。RSA是当今广泛使用的公钥加密算法,理解其原理是掌握现代密码学的关键一步。

150:RSA正确性证明 🔐

在本节课中,我们将学习RSA加密算法为何正确的证明。这是一个包含较多数学推导的过程,但我们会一步步拆解,确保初学者也能理解。

概述 📋

RSA加密的正确性证明,核心在于证明:对于任意消息 M,先进行加密运算 M^E mod N,再进行解密运算 (M^E)^D mod N,最终能得到原始消息 M。我们将借助一些已知的数论定理来完成这个证明。

预备知识 📚

在开始证明前,我们需要接受两个已证明的数论定理作为事实。这些定理通常在离散数学课程中学习,我们在此直接使用。

定理一:中国剩余定理的推论
如果一个数 X 满足:

  • X ≡ y (mod P)
  • X ≡ y (mod Q)
    那么,这个数 X 也必然满足:
  • X ≡ y (mod P*Q)
    其中 PQ 是互质的素数。在RSA中,N = P*Q

定理二:费马小定理
如果 P 是一个素数,那么对于任意整数 aa 不是 P 的倍数),有:

  • a^(P-1) ≡ 1 (mod P)
    在RSA中,我们使用的 PQ 都是大素数。

密钥关系的推导 🔑

上一节我们介绍了两个核心定理,本节中我们来看看RSA公钥 (E, N) 和私钥 (D, N) 之间定义的关系如何为我们提供证明的关键线索。

RSA私钥指数 D 被定义为公钥指数 E 在模 (P-1)(Q-1) 下的乘法逆元。这意味着:

  • E * D ≡ 1 (mod (P-1)(Q-1))

根据模运算的定义,上式等价于:

  • E * D - 1 = k * (P-1)(Q-1),其中 k 是某个整数。

这个等式表明,(E*D - 1)(P-1)(Q-1) 的整数倍。这个结论将是后续证明的基石。

证明策略 🧠

我们已经掌握了定理和密钥关系,现在可以规划证明路线。我们的目标是证明 (M^E mod N)^D mod N = M,即 M^(E*D) mod N = M

直接证明 mod N 比较困难,因为我们的定理和推导关系主要涉及 PQ。因此,我们采用分而治之的策略:

  1. 首先证明 M^(E*D) ≡ M (mod P)
  2. 接着证明 M^(E*D) ≡ M (mod Q)
  3. 最后,利用定理一(中国剩余定理推论),由以上两点得出结论:M^(E*D) ≡ M (mod N)

证明步骤详解(模 P)➗

现在,让我们执行证明策略的第一步:在模 P 的世界里进行推导。

我们从目标式 M^(E*D) mod P 开始。

步骤 1:拆分指数
利用指数运算法则 a^(b+c) = a^b * a^c,我们可以将表达式重写:

  • M^(E*D) = M^((E*D - 1) + 1) = M^(E*D - 1) * M

步骤 2:代入密钥关系
根据上一节的推导,我们知道 (E*D - 1)(P-1)(Q-1) 的倍数,因此也必然是 (P-1) 的倍数。我们可以将其表示为:

  • E*D - 1 = (P-1) * t,其中 t 是某个整数(t = k*(Q-1))。
    代入上式:
  • M^(E*D) mod P = M^((P-1)*t) * M mod P

步骤 3:应用费马小定理
根据定理二(费马小定理),对于素数 P,有 M^(P-1) ≡ 1 (mod P)
因此:

  • M^((P-1)*t) mod P = [M^(P-1)]^t mod P = 1^t mod P = 1

步骤 4:得出结论
将结果代回:

  • M^(E*D) mod P = 1 * M mod P = M mod P
    至此,我们成功证明了 M^(E*D) ≡ M (mod P)

证明步骤详解(模 Q)➗

上一节我们在模 P 下完成了证明,本节中我们以完全相同的逻辑处理模 Q 的情况。过程是对称的。

步骤 1:拆分指数

  • M^(E*D) = M^(E*D - 1) * M

步骤 2:代入密钥关系
由于 (E*D - 1) 也是 (Q-1) 的倍数,可表示为:

  • E*D - 1 = (Q-1) * s,其中 s 是某个整数。
    代入:
  • M^(E*D) mod Q = M^((Q-1)*s) * M mod Q

步骤 3:应用费马小定理
对素数 Q 应用费马小定理:M^(Q-1) ≡ 1 (mod Q)
因此:

  • M^((Q-1)*s) mod Q = [M^(Q-1)]^s mod Q = 1^s mod Q = 1

步骤 4:得出结论

  • M^(E*D) mod Q = 1 * M mod Q = M mod Q
    我们证明了 M^(E*D) ≡ M (mod Q)

最终整合与结论 🎯

我们已经分别证明了以下两个同余式成立:

  • M^(E*D) ≡ M (mod P)
  • M^(E*D) ≡ M (mod Q)

现在,应用我们在开头介绍的定理一(中国剩余定理推论)。因为 M^(E*D)M 这两个数,在模 P 和模 Q 下都同余,那么在它们模 PQ 的乘积 N 下也必然同余。即:

  • M^(E*D) ≡ M (mod N)

这正是我们需要证明的结论:(M^E mod N)^D mod N = M。因此,RSA的加密和解密过程是正确的,能够完好地恢复原始消息。

总结 📝

本节课中我们一起学习了RSA加密算法的正确性证明。我们回顾一下核心步骤:

  1. 利用定义:从私钥 DE(P-1)(Q-1) 的逆元出发,得到关键等式 E*D - 1 = k*(P-1)(Q-1)
  2. 分而治之:分别证明在模 P 和模 Q 下,M^(E*D) 都等于 M。证明中核心用到了费马小定理
  3. 合二为一:利用中国剩余定理的推论,将模 P 和模 Q 下的结论合并,最终得到在模 NM^(E*D) ≡ M,从而完成证明。

这个证明虽然涉及一些数学推导,但逻辑链条清晰,展示了RSA算法背后优雅而坚实的数学基础。

151:RSA安全性与OAEP填充

在本节课中,我们将要学习RSA加密方案的安全性基础,以及为什么我们之前介绍的基础RSA方案在实际应用中并不安全。我们还将探讨一种名为OAEP的填充方案,它通过引入随机性来增强RSA的安全性,使其达到IND-CPA安全标准。

RSA的安全性基础

上一节我们介绍了RSA加密和解密的基本过程。本节中我们来看看其安全性的理论依据。

RSA加密的安全性基于一个被称为RSA问题的数学难题。RSA问题可以这样描述:如果有人告诉你模数 N 和密文 C = M^E mod N,那么对于你来说,反向运算并找到原始明文 M 是非常困难的。

需要稍加注意的是,这个问题看起来有点像离散对数问题或Diffie-Hellman问题,但它并非基于离散对数的难度。在离散对数问题中,困难在于找到指数 a(在 g^a mod p 中)。而在RSA问题中,困难在于找到底数 M(在 M^E mod N 中)。此外,在Diffie-Hellman问题中,我们使用一个质数 p 作为模数,而这里我们使用的是两个质数的乘积 N。因此,尽管它们看起来有些相似,但存在微妙的差异。

总之,RSA问题说明了我们所需要的安全性:如果有人给你密文,你很难找到原始明文。我们不会证明为什么这很难,但直观地说,使RSA变难的底层问题实际上不是离散对数问题,而是因式分解问题

目前已知的解决此问题并恢复原始 M 的最佳方法是将 N 分解为 p 和 q。不幸的是,目前没有高效的算法可以将一个数 N 分解成它的两个质因数 pq。因此,由于因式分解是困难的,我们也假设这个RSA问题是困难的。这不是一个严格的证明,但大致说明了为什么RSA加密是困难的。这一切都基于一个共识:因式分解被认为是一个难题。

基础RSA方案的安全缺陷

在您开始在实际世界中使用RSA加密之前,有一个重要的警告:我们目前介绍的RSA加密方案并不完全安全。

一个明显的不安全原因是它是确定性的。在我们的方案中,没有使用任何随机性。因此,如果你使用我们介绍的方案对同一消息加密两次,你会得到完全相同的输出。这破坏了IND-CPA安全性。

此外,还存在一些我们不会深入探讨的其他非常微妙的问题。要修复所有这些问题,你需要比我们现在所讲的更进一步。这再次印证了“不要在家自己编写密码学”的原则。我们向你展示了RSA的基础,但还不足以构建一个工业级的、IND-CPA安全的加密方案。

通过OAEP填充实现安全

为了使RSA加密完全安全,你需要添加一些随机性。一种添加随机性的方案被称为最优非对称加密填充,简称OAEP

这里有一点让我感到困惑:他们在两种不同的加密方式中使用了“填充”这个词。在对称密钥加密中,我们说填充是添加虚拟字节,以使消息达到合适的长度。而在公钥加密中,不知为何,“填充”这个词被用来指代随机性,类似于对称密钥加密方案中的IV或Nonce。老实说,这有点令人困惑,我也不知道他们为什么这么说。但当你在非对称密钥加密的上下文中看到“填充”时,请把它想象成非对称加密中等同于IV或Nonce的东西,即我们添加随机性的地方。抱歉,名字确实容易让人困惑。

总之,如果你使用RSA并添加这种用于引入随机性的额外填充方案,你最终应该能获得IND-CPA安全性。其中还有一些我们不会讨论的微妙之处,但这就是基础RSA所缺失的部分。如果你感兴趣,可以随时查阅相关资料。

我们还包含了一些额外的幻灯片,如果你想了解填充的实际样子,你可以看到它包含了一些帮助我们实现IND-CPA安全性的随机性。请记住,随机性本身并不能免费给你带来安全性,但它是实现IND-CPA安全的必要前提。

以下是一些你可以自行查阅的公式和结构要点:

  • 该结构被称为Feistel网络,如果你好奇可以查阅。
  • 核心在于,填充过程混合了明文、随机数和哈希函数,然后再进行RSA加密。

但重要的结论是:正如所展示的那样,RSA本身并不安全。它存在一些微妙的问题,并且缺乏随机性。OAEP通过添加随机性和修复我们的一些微妙问题,来帮助实现IND-CPA安全性。

总结

本节课中我们一起学习了RSA加密方案的安全性基础。我们了解到其安全性依赖于因式分解难题,而非离散对数问题。更重要的是,我们认识到基础RSA方案由于缺乏随机性而是确定性的,因此不满足IND-CPA安全标准。为了解决这个问题,实践中必须使用像OAEP这样的填充方案来引入随机性,从而构建出安全的RSA加密实现。

152:混合加密

在本节课中,我们将要学习混合加密的概念。这是一种结合了公钥加密和对称加密优势的技术,旨在解决公钥加密在处理速度和消息长度上的限制。

公钥加密的优势与挑战

上一节我们介绍了公钥加密方案。公钥加密非常出色,它允许爱丽丝和鲍勃在没有共享密钥的情况下进行安全通信。

然而,公钥加密也存在一些问题。第一个问题是速度慢。我们之前讨论过,公钥加密涉及的操作,如模幂运算、大素数乘法或选择大素数,都远比对称加密中的位移或异或等操作要慢得多。

第二个问题,我们之前没有明确指出,但在此需要强调的是,公钥加密只能加密小消息。为了理解原因,让我们回顾一下这些方案的定义方式。

以下是具体分析:

  • ElGamal加密的限制:在ElGamal加密中,消息被定义为模 p 下的值。这意味着你只能加密 0p-1 之间的消息。如果你尝试加密更大的消息,鲍勃解密时会产生歧义。因为爱丽丝加密 MM + pM + 2p 等消息时,鲍勃解密后都会得到 M mod p。这些消息在模 p 下是等价的,无法确定爱丽丝原本想发送的是哪一个。因此,爱丽丝能发送的消息被限制在 0p-1 之间的数字。

  • RSA加密的限制:RSA加密存在同样的问题。消息被定义为模 n 下的值,这意味着所有发送的消息必须在 0n-1 之间。由于 n 是一个2000到4000比特的数字,这意味着爱丽丝发送的消息最多只能有几千比特。同样,ElGamal中的消息也被限制在几千比特以内。这对于大量数据来说是不够的,这是公钥密码学的一个主要问题:你无法一次性加密大量数据。

混合加密的解决方案

为了解决上述问题,我们可以使用一种称为混合加密的技术。它试图同时获得公钥加密和对称加密的好处。

其工作原理如下:当爱丽丝想给鲍勃发送消息时,她首先生成一个对称密钥 K。她使用公钥加密来加密这个密钥 K,这是可行的,因为 K 本身是一个很小的值。然后,她将这个加密后的密钥发送给鲍勃。

接下来,她使用对称密钥 K 来快速加密她真正想要发送的长消息。这一步利用了对称加密速度快的优势。最后,她将加密后的消息发送给鲍勃。

当鲍勃收到消息时,他收到两部分内容。首先,他收到加密的密钥。他使用公钥密码学来解密,得到对称密钥 K。一旦他拥有了对称密钥 K,他就可以用它来快速解密爱丽丝发送的原始长消息。

整个过程分为两步:

  1. 使用公钥加密来加密一个对称密钥。
  2. 使用对称密钥来加密消息本身。

鲍勃解密时,先使用公钥解密得到对称密钥 K,然后用 K 解密整个消息。这就是混合加密。

总结

本节课中,我们一起学习了混合加密。我们了解到,公钥加密虽然解决了密钥分发问题,但在速度和消息长度上存在限制。混合加密巧妙地结合了两种技术的优点:它使用公钥加密来安全地传输一个小的对称密钥,然后利用对称加密的高效性来加密实际的长消息。这种方法既保证了安全性,又实现了高效的大数据加密,是现代许多密码系统的实际应用方式。

153:数字签名 - 定义 📜

在本节课中,我们将要学习数字签名的基本概念。数字签名是公钥密码学的一个重要应用,它能够提供信息的完整性和认证性,而无需通信双方预先共享密钥。


上一节我们介绍了公钥密码学的整体框架,本节中我们来看看数字签名的具体定义和工作原理。

数字签名的工作方式都遵循一个相似的流程。如果我想发送一条消息,我会用我的私钥对其进行签名。这意味着只有我知道私钥,因此只有我能为这条消息生成签名。当我将消息和签名一起发送出去后,任何人都可以使用我对应的公钥来验证消息是否被篡改。签名应具备这样的特性:如果消息在传输中被篡改,或者签名本身被篡改,那么当验证者使用公钥进行验证时,验证算法会输出“否”,表明签名无效。

更正式地说,一个数字签名方案由三个算法定义。

以下是构成数字签名方案的三个核心算法:

  1. 密钥生成算法(pk, sk) <- Gen()。当用户需要一个密钥对时,此算法会生成一对公钥 pk 和对应的私钥 sk
  2. 签名算法σ <- Sign(sk, m)。此算法使用私钥 sk 对消息 m 进行签名,并输出签名 σ
  3. 验证算法b <- Verify(pk, m, σ)。此算法使用公钥 pk、消息 m 和签名 σ 进行验证。如果签名有效,则返回 true;否则返回 false

因此,要定义一个数字签名方案,必须完整地设计这三个算法。


一个优秀的数字签名方案需要满足几个关键属性。

以下是数字签名方案需要满足的核心属性:

  • 正确性:如果某人用私钥正确签署了一条未被篡改的消息,那么使用对应的公钥进行验证时,结果应为 true
  • 高效性:签名和验证算法应该足够快速,以便于实际应用。
  • 安全性:方案必须是不可伪造的。这与我们之前讨论消息认证码时的概念非常相似。其核心思想是:攻击者即使能够获取到对一些任意消息的签名(用于学习签名模式),最终也必须能在不知道私钥的情况下,为一个新的消息伪造出一个有效的签名。如果攻击者无法做到这一点,那么这个方案就是安全的。

关于数字签名,还有几个重要的注意事项。

以下是使用数字签名时需要注意的几个要点:

  • 消息长度限制:数字签名基于与公钥加密相同的数论原理,这意味着它能直接签名的消息长度是有限的(例如,在模 n 运算下,只能签名 0n-1 范围内的消息)。为了解决这个问题,标准的做法是先对消息进行哈希运算,然后对哈希值进行签名。哈希值长度固定且较短,从而可以支持任意长度的消息。
  • 提供完整性与认证性:在我们的威胁模型下,数字签名同时提供了完整性和认证性。回想消息认证码,当多人共享对称密钥时,你无法确切知道消息和MAC来自谁。但在数字签名中,由于私钥只由一人持有,因此如果签名验证通过,你就可以确信消息一定来自持有该私钥的人。

本节课中我们一起学习了数字签名的定义、核心算法、所需属性以及关键注意事项。数字签名通过公钥密码学实现了对信息来源的强认证和消息完整性的保护,是构建安全通信和信任体系的基础工具。

154:数字签名 - 实现

概述

在本节课中,我们将要学习数字签名的具体实现方式,包括基于RSA的数字签名方案,并简要了解基于Diffie-Hellman的DSA签名。我们还将回顾公钥密码学的核心概念。


RSA数字签名实现

上一节我们介绍了数字签名的概念,本节中我们来看看如何用RSA算法实现它。

RSA数字签名的核心思想与RSA加密相同,但步骤顺序相反。回顾RSA加密:取消息M,计算 M^E mod N 得到密文,再用私钥D计算 (M^E)^D mod N 恢复出M。数学上,先计算D次方再计算E次方同样能恢复原消息:(M^D)^E ≡ M (mod N)。RSA签名正是利用了这种可交换性。

密钥生成

密钥生成算法与RSA加密完全相同:

  • 公钥是 (N, E),其中 N 是两个大素数的乘积。
  • 私钥是 D,满足 E * D ≡ 1 (mod φ(N)),其中 φ(N) = (P-1)(Q-1)

签名过程

使用私钥D对消息M进行签名:

  1. 计算消息的哈希值:H = Hash(M)
  2. 计算签名:σ = H^D mod N

注意:直接对M进行运算仅限于短消息。对于任意长度的消息,我们总是先计算其哈希值H(M),再对哈希值进行签名运算 H(M)^D mod N。这解决了消息长度限制问题。

验证过程

任何人可以使用公钥(E, N)验证签名:

  1. 收到消息M和签名σ
  2. 计算消息的哈希值:H = Hash(M)
  3. 从签名中恢复哈希值:H' = σ^E mod N
  4. 比较 HH'。如果相等,则签名有效;否则无效。

验证的原理是:σ^E = (H(M)^D)^E = H(M)^(D*E) ≡ H(M) (mod N)


其他数字签名方案简介

除了RSA,还有其他基于不同数学难题的数字签名方案。

以下是另一种常见的数字签名方案:

  • DSA (Digital Signature Algorithm):这是一种基于Diffie-Hellman密钥交换原理的数字签名方案。与ElGamal加密方案类似,DSA的安全性也依赖于离散对数问题的困难性。该方案比RSA签名更复杂,涉及更多的计算步骤和参数。

请注意:DSA的具体细节不在本课程考核范围内。如果您对此感兴趣,可以查阅课程提供的额外资料幻灯片。


公钥密码学总结

本节课中我们一起学习了公钥密码学的核心组成部分及其实现。

回顾整个公钥密码学体系,其核心是每个人都拥有一对密钥:一个公开的公钥和一个保密的私钥。

  • 为了加密信息,发送方使用接收方的公钥进行加密。
  • 为了解密信息,接收方使用自己的私钥进行解密。
  • 我们期望公钥加密能提供与对称加密(如AES)类似的安全性,例如IND-CPA安全。

我们介绍了两种主要的公钥加密方案:

  1. ElGamal加密:基于Diffie-Hellman密钥交换。可以想象加密时Bob在“睡觉”,由Alice完成所有工作;解密时Bob“醒来”完成他那部分的Diffie-Hellman计算。
  2. RSA加密:基于大整数分解的困难性。我们证明了其正确性,但其安全性依赖于分解大数N的难度。

重要提示:课程中展示的基础版ElGamal和RSA方案本身并不满足IND-CPA安全。在实际应用中,必须引入随机填充等机制来修复这些细微的安全缺陷,才能达到真正的IND-CPA安全。

公钥加密方案功能强大,但通常计算较慢,且对加密消息的长度有限制。因此,我们引入了混合加密方案来结合双方优点:

  • 使用公钥加密一个临时的对称密钥
  • 使用这个对称密钥加密实际要发送的长消息
  • 这样既获得了公钥加密的便利性(无需预先共享密钥),又获得了对称加密的高效性。

最后,我们探讨了数字签名,它在非对称密钥模型中提供了完整性和真实性保障。我们详细讲解了RSA数字签名,它使用了与RSA加密相同的数学原理,但顺序相反:先用私钥签名,再用公钥验证。同样,通过先对消息进行哈希处理,可以实现对任意长度消息的签名。

这就是公钥密码学的主要内容。希望本课程能帮助你建立起清晰的理解。我们下次再见!

posted @ 2026-03-29 09:26  布客飞龙II  阅读(1)  评论(0)    收藏  举报