面向开发者的-Web-安全-全-

面向开发者的 Web 安全(全)

原文:zh.annas-archive.org/md5/83da3d74e87517797d845eb1d9bffab1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

image

网络是一个充满挑战的地方。人们很容易产生这样的印象:互联网是由专家精心设计的,一切的运作方式都有其合理的原因。事实上,互联网的发展既迅速又杂乱无章,我们今天在网络上所做的事情远远超出了最初发明者的设想。

因此,确保你的网站安全可能会显得是一项艰巨的任务。网站是一种独特的软件类型,在发布后立即向数百万用户开放,包括一个活跃且动机十足的黑客社区。大公司经常遭遇安全失误,每周都会宣布新的数据泄露事件。在这种情况下,独立的网页开发者如何保护自己呢?

关于本书

网络安全的一个大秘密是,网络漏洞的数量实际上相当少——巧合的是,大约刚好可以容纳在一本书里——而且这些漏洞每年变化不大。本书将教你每个你需要了解的关键威胁,我将分解出你应该采取的实际步骤来保护你的网站。

谁应该阅读本书

如果你是刚刚起步的网页开发者,这本书是你了解网络安全的理想指南。无论你刚刚完成计算机科学学位、刚从训练营毕业,还是自学成才,我都建议你从头到尾阅读本书。本书中的每一项内容都是必备的知识,且以最简洁的方式和清晰的示例进行讲解。现在就为你将面临的威胁做好充分准备,将为你将来的工作节省很多麻烦。

如果你是一个更有经验的程序员,这本书对你也会很有帮助。你总是可以通过复习安全知识来受益,因此可以利用本书填补任何知识空白。把它当作一本参考书,翻阅你感兴趣的章节。你并不总是知道自己不知道什么!像你这样的资深程序员有责任以身作则,带领团队前进,对于网络开发人员来说,这意味着遵循安全最佳实践。

你会注意到,本书并不针对任何特定的编程语言(尽管我会根据需要为主要语言提供各种安全建议)。对网络安全的深入理解将对你有所帮助,无论你选择用哪种语言进行编程。许多程序员在职业生涯中会使用多种语言,因此,学习网络安全的基本原则要比过于专注于某个特定库更为重要。

互联网简史

在我开始介绍本书内容之前,回顾一下互联网是如何发展到今天的状态将是有益的。许多聪明的工程师为互联网的爆炸性增长做出了贡献,但与大多数软件项目一样,安全考虑往往在新增功能时被忽视。理解安全漏洞是如何悄悄出现的,将帮助你在学习如何修复这些漏洞时获得必要的背景知识。

万维网(World Wide Web)是由蒂姆·伯纳斯-李(Tim Berners-Lee)在欧洲核子研究组织(CERN)工作时发明的。CERN 的研究主要是通过撞击亚原子粒子,期望它们会裂解成更小的亚原子粒子,从而揭示宇宙的本质结构,并且明知这种研究有可能在地球上制造黑洞。

伯纳斯-李(Berners-Lee)显然对终结宇宙的事情兴趣较少,他在 CERN 的时间则用于发明今天我们所知道的互联网,作为大学之间共享研究成果的一种手段。他发明了第一个网页浏览器和第一个网页服务器,并创造了超文本标记语言(HTML)和超文本传输协议(HTTP)。世界上第一个网站于 1993 年上线。

早期的网页仅包含文本。第一个能够显示内联图片的浏览器是 Mosaic,由国家超级计算应用中心(National Center for Supercomputing Applications)开发。Mosaic 的开发者最终加入了 Netscape Communications 公司,并帮助创建了 Netscape Navigator,这是第一个广泛使用的网页浏览器。在早期的互联网中,大多数网页是静态的,且流量传输没有加密。那时是一个更简单的时代!

浏览器中的脚本语言

快进到 1995 年,Netscape Communications 公司新聘的布伦丹·艾克(Brendan Eich)花了 10 天时间发明了 JavaScript,这是第一个能够嵌入网页的编程语言。在开发过程中,这门语言最初被称为 Mocha,随后改名为 LiveScript,再次更名为 JavaScript,最终正式定名为 ECMAScript。没有人喜欢这个名字,尤其是艾克,他曾表示这个名字听起来像是一种皮肤病;因此,除了在最正式的场合,大家依旧称其为 JavaScript。

JavaScript 的最初版本结合了 Java 编程语言(虽然与其无关)那种笨拙的命名约定、C 语言的结构化编程语法、Self 语言的晦涩原型继承以及艾克自己设计的恶梦般的类型转换逻辑。无论好坏,JavaScript 成了网页浏览器的事实标准语言。突然间,网页变得可以互动,并且一系列安全漏洞随之而来。黑客们通过跨站脚本攻击(XSS)找到了将 JavaScript 代码注入页面的方法,互联网变得更加危险。

新挑战者进入竞技场

Netscape Navigator 的第一个真正竞争对手是微软的 Internet Explorer。Internet Explorer 具有一些竞争优势——它是免费的,并且预装在 Microsoft Windows 系统中。Explorer 迅速成为全球最受欢迎的浏览器,Explorer 图标成为一代用户学习如何浏览网页时的“互联网按钮”。

微软试图“主宰”互联网,导致其在浏览器中引入了 ActiveX 等专有技术。不幸的是,这导致了恶意软件的增加——一种感染用户计算机的恶意程序。Windows 曾经(并且依然是)计算机病毒的主要目标,而互联网则成为了有效的传播渠道。

Internet Explorer 的主导地位在许多年里没有受到挑战,直到 Mozilla 的 Firefox 发布,再到由年轻搜索初创公司 Google 创建的时髦新浏览器 Chrome。这些新浏览器加速了互联网标准的增长和创新。然而,到现在为止,黑客攻击已经变成了一个盈利的生意,任何安全漏洞在被发现后都会立即被利用。确保浏览器安全成为供应商的首要任务,而网站所有者必须时刻关注最新的安全动态,以便保护他们的用户。

用于编写 HTML 的机器

网络服务器的发展速度与浏览器技术的进步一样迅速。在互联网的早期,托管网站是学术界的一个小众爱好。大多数大学都使用开源操作系统 Linux。1993 年,Linux 社区实现了通用网关接口(CGI),它允许网站管理员轻松创建由相互关联的静态 HTML 页面组成的网站。

更有趣的是,CGI 允许通过 Perl 或 PHP 等脚本语言生成 HTML——因此,网站所有者可以从数据库中存储的内容动态创建页面。PHP 最初代表个人主页(Personal Home Page),当时的梦想是每个人都能运行自己的网页服务器,而不是将所有个人信息上传到一个数据隐私政策存疑的社交媒体巨头。

PHP 使模板文件的概念变得流行:嵌入处理标签的 HTML,可以通过 PHP 运行时引擎进行处理。动态 PHP 网站(比如 Facebook 的最早版本)在互联网中蓬勃发展。然而,动态服务器代码引入了一类全新的安全漏洞。黑客通过注入攻击以新颖的方式在服务器上运行他们自己的恶意代码,或者通过目录遍历探索服务器的文件系统。

一系列管道

网络技术的不断革新意味着,今天的互联网很大程度上依赖于我们认为的“旧”技术。软件通常会达到一个工作得足够好的程度,然后进入“维护”模式,仅在绝对必要时才进行更改。对于网页服务器来说尤其如此,因为它们需要全天候在线。黑客扫描网络,寻找运行老旧技术的易受攻击网站,因为这些网站通常存在安全漏洞。我们仍在修复十年前首次发现的安全问题,这就是为什么在本书中我会描述每一个可能影响网站的主要安全漏洞。

与此同时,互联网的增长速度比以往任何时候都快!将日常设备(如汽车、门铃、冰箱、灯泡和猫砂盘)连接到互联网的趋势为攻击提供了新的途径。连接到物联网的设备越简单,就越不可能具备自动更新的安全功能。这导致了大量未加保护的互联网节点,这些节点为僵尸网络提供了丰富的托管环境,僵尸网络是由黑客远程安装和控制的恶意软件代理。这为攻击者提供了大量潜在的火力,如果他们针对你的网站进行攻击。

首先要担心的是什么

一名网页开发人员可能会因正确保护网站的困难而感到灰心。不过你应该有信心:有一支安全研究人员的队伍正在勇敢地发现、记录和修复安全漏洞。保护你的网站所需的工具都是免费且通常容易使用的。

了解最常见的安全漏洞,并知道如何修复它们,将保护你的系统免受 99%的攻击。尽管总有技术高超的对手能够入侵你的系统,但除非你在经营一个伊朗核反应堆或一个美国的政治竞选,否则这些想法不必让你夜不能寐。

本书内容

这本书分为两部分。第一部分介绍了互联网的工作原理。第二部分深入探讨了你需要防范的具体漏洞。内容如下:

第一章:让我们攻击一个网站

在本章中,你将学习到攻击一个网站是多么简单。提示:真的很简单,所以你做得对,买了这本书。

第二章:互联网是如何工作的

互联网的“管道”基于互联网协议(Internet Protocol),这是一系列允许全球计算机无缝通信的网络技术。你将学习 TCP、IP 地址、域名和 HTTP,并了解如何在网络上安全传输数据。

第三章:浏览器是如何工作的

用户通过浏览器与你的网站互动,许多安全漏洞会在这里显现。你将了解浏览器如何渲染网页,以及 JavaScript 代码如何在浏览器安全模型中执行。

第四章:Web 服务器如何工作

你为网站编写的大部分代码都将在 Web 服务器环境中运行。Web 服务器是黑客的主要目标。本章描述了它们如何提供静态内容,以及如何利用动态内容(如模板)将数据库和其他系统中的数据结合起来。你还将了解一些用于 Web 编程的主要编程语言,并回顾每种语言的安全考虑事项。

第五章:程序员如何工作

本章解释了你应如何进行网站代码编写的过程,以及你可以养成哪些良好习惯,以减少错误和安全漏洞的风险。

第六章:注入攻击

我们将通过查看你将遇到的最具威胁性的漏洞之一开始对网站漏洞的调查:黑客注入代码并在你的服务器上执行。这通常发生在你的代码与 SQL 数据库或操作系统交互时;或者攻击可能包括远程代码注入到 Web 服务器进程中。你还将看到文件上传功能如何允许黑客注入恶意脚本。

第七章:跨站脚本攻击

本章回顾了用于将恶意 JavaScript 代码注入浏览器环境的攻击方式,以及如何防范这些攻击。跨站脚本攻击有三种不同的方式(存储型、反射型和基于 DOM 的),你将学习如何防范每一种。

第八章:跨站请求伪造攻击

你将看到黑客如何利用伪造攻击欺骗用户执行不希望发生的操作。这是互联网上常见的烦恼,你需要为用户提供相应的保护。

第九章:身份验证的破坏

如果用户注册了你的网站,确保安全处理他们的账户是至关重要的。你将回顾黑客用于绕过登录屏幕的各种方法,从暴力破解密码到用户枚举。你还将回顾如何在数据库中安全地存储用户凭证。

第十章:会话劫持

你将看到用户在登录后如何可能会遭遇账户被劫持的情况。你将学习如何构建你的网站并安全地处理 cookies,以减轻这一风险。

第十一章:权限

了解如何防止恶意攻击者利用特权提升访问你网站的禁区。特别是,如果你在 URL 中引用了文件,黑客将尝试使用目录遍历来探索你的文件系统。

第十二章:信息泄露

你可能无意中通过泄露信息而在你的网站上宣传漏洞。本章将告诉你如何立即停止这种情况。

第十三章:加密

本章展示了如何正确使用加密,并解释了它在互联网上的重要性。准备好一些简单的数学知识。

第十四章:第三方代码

你将学习如何管理他人代码中的漏洞。你运行的大多数代码都将是由别人编写的,你应该知道如何确保它的安全!

第十五章:XML 攻击

你的网页服务器可能解析 XML,并且可能容易受到本章描述的攻击。XML 攻击已经是黑客们过去几十年来常用的攻击方式之一,因此要小心!

第十六章:不要当帮凶

你可能在不知情的情况下充当了对他人黑客攻击的帮凶,正如你将在本章中看到的。作为一个良好的互联网公民,确保你关闭这些安全漏洞。

第十七章:拒绝服务攻击

在这一章中,我将向你展示大规模的网络流量如何作为拒绝服务攻击的一部分使你的网站瘫痪。

第十八章:总结

最后一章是一个备忘单,回顾了你在整本书中学到的安全关键要素,并总结了在注重安全时应应用的高层次原则。把它背下来,每晚睡觉前复习一下这些教训。

第一章:让我们黑客攻击一个网站

image

本书将教你作为一个有效的网页开发者所需要的基本安全知识。在开始之前,一个有用的练习是看看你将如何攻击一个网站。让我们站在对手的角度,看看我们面对的是什么。 本章将展示黑客是如何操作的,以及开始黑客攻击有多么容易。

软件漏洞和暗网

黑客利用软件中的安全漏洞,比如网站。在黑客社区中,用来展示如何利用安全漏洞的代码称为exploit(漏洞利用代码)。一些黑客——也就是所谓的白帽黑客——会为了乐趣而发现安全漏洞,并且会在公开漏洞之前通知软件厂商和网站所有者。这样的黑客通常会因为发现漏洞而获得经济奖励。

负责任的软件厂商会尽快发布补丁来修复零日漏洞(那些被公开不足一天或根本没有公开的漏洞)。然而,即使软件厂商发布了修复漏洞的补丁,许多使用该漏洞的软体实例仍会在一段时间内没有得到修复。

那些伦理观念较弱的黑客——黑帽黑客——会囤积漏洞利用代码,最大化他们能利用漏洞的时间,或者甚至将漏洞利用代码在黑市上以比特币出售。在今天的互联网环境中,漏洞利用代码迅速被武器化,并融入到黑客社区广泛使用的命令行工具中。

对于使用这些漏洞利用工具的黑帽黑客来说,存在强大的经济激励。针对被盗信用卡信息、被黑用户账户以及零日漏洞的黑市在暗网上存在,暗网是通过特殊的网络节点来匿名化 IP 地址的网络。像图 1-1 中展示的这种暗网站点,进行着盗取信息和侵入服务器的生意。

image

图 1-1:你好,是的,我想购买一些被盗的信用卡号码,因为你显然是一个高级的俄罗斯黑客,而不是在暗网进行卧底行动的 FBI 特工。

可以利用最新漏洞的黑客工具是免费的,并且很容易设置。你甚至不需要访问暗网,因为你需要的一切只需要通过快速的 Google 搜索就能找到。让我们看看如何做。

如何黑客攻击一个网站

开始进行黑客攻击非常简单。下面是如何做的:

  1. 在 Google 上搜索kali linux 下载Kali Linux是一个专门为黑客打造的 Linux 操作系统版本。它预装了 600 多种安全和黑客工具。它完全免费,由 Offensive Security 的一个小团队维护,致力于专业的安全研究。

  2. 在您的计算机上安装一个虚拟容器。虚拟容器是主机环境,允许您在计算机上安装其他操作系统,而不会覆盖当前的操作系统。Oracle 的 VirtualBox 可以免费使用,并且可以在 Windows、macOS 或 Linux 上安装。这应该能让您在计算机上运行 Kali Linux,而无需太多配置。

  3. 在容器中安装 Kali Linux。下载并双击安装程序以开始安装。

  4. 启动 Kali Linux 并打开 Metasploit 框架。Metasploit,如图 1-2 所示,是最流行的命令行工具,用于测试网站的安全性并检查漏洞。

    image

    图 1-2:黑客攻击只有在足够的 ASCII 艺术奶牛帮助下才能实现。

  5. 在 Metasploit 命令行上运行 wmap 工具,扫描目标网站并查看您能找到哪些漏洞。输出应该类似于图 1-3 所示。wmap 工具将扫描一系列 URL,测试 Web 服务器是否存在安全漏洞。确保您只在自己拥有的网站上运行此工具!

    image

    图 1-3:黑客攻击已启动——预计不久后会收到执法部门的拜访。

  6. 从 Metasploit 数据库中选择一个漏洞利用程序,允许您利用该漏洞。

到此为止,我们将停止黑客教程,因为下一步可能构成犯罪。然而,主要的观点应该很明显:启动黑客攻击网站真的很容易!Metasploit 和 Kali Linux 被现实中的黑客使用,可以在几分钟内设置完成。它们不需要任何特别的专业知识,但在识别和利用网站漏洞方面异常出色。

这就是我们今天作为 Web 开发人员所面对的现实。我们构建的网站对任何拥有互联网连接的人都可用,而用于攻击它们的黑客工具也是如此。不过,不用惊慌!到书的最后,您将(希望)对安全性了解得像黑客一样多,并为他们攻击您的网站做好充分准备。那么,让我们开始讨论互联网协议套件的构建模块吧。

第一部分

基础知识

第二章:互联网如何工作

image

要成为网络安全专家,你需要扎实掌握互联网基础的网络技术和协议。本章将探讨互联网协议套件,它规定了计算机如何在网络上交换数据。你还将了解有状态连接和加密,这些都是现代网络的关键元素。我将重点指出安全漏洞可能出现的地方。

互联网协议套件

在互联网早期,数据交换并不可靠。首条通过高级研究计划局网络(ARPANET)发送的消息是一个LOGIN命令,目标是斯坦福大学的远程计算机。网络发送了前两个字母LO,然后崩溃了。这对美国军方来说是个问题,因为他们希望找到一种方式将远程计算机连接起来,以便即使苏联的核打击使网络的各个部分下线,也能继续交换信息。

为了解决这个问题,网络工程师开发了传输控制协议(TCP),以确保计算机之间的信息可靠交换。TCP 是约 20 种网络协议中的一种,这些协议共同构成了互联网协议套件。当计算机通过 TCP 向另一台计算机发送消息时,消息会被拆分成数据包,并带有目标地址,朝着最终的目的地发送。组成互联网的计算机将每个数据包推向目标,而无需处理整个消息。

一旦接收计算机收到数据包,它会根据每个数据包上的序列号将它们重新组装成可用的顺序。每次接收方收到一个数据包时,它都会发送确认。如果接收方未能确认收到某个数据包,发送方会重新发送该数据包,可能通过不同的网络路径。在这种方式下,TCP 使计算机能够在预期不可靠的网络中传输数据。

随着互联网的发展,TCP 经历了显著的改进。现在,数据包会带有校验和,使接收方能够检测数据损坏并判断是否需要重新发送数据包。发送方还会根据数据的消费速度预先调整发送速率。(互联网服务器通常比接收消息的客户端强大得多,因此它们需要小心不要超负荷客户端的处理能力。)

注意

TCP 由于其交付保障仍然是最常用的协议,但如今,互联网还使用了其他几种协议。比如用户数据报协议 (UDP)* 是一种更新的协议,故意允许丢失数据包,以便可以保持数据以恒定速率流传。UDP 通常用于直播视频流,因为用户更愿意接受几帧丢失,而不是在网络拥堵时视频延迟。*

互联网协议地址

互联网中的数据包会被发送到互联网协议 (IP) 地址,这些地址分配给每一台联网的计算机。每个 IP 地址必须是唯一的,因此新的 IP 地址会以结构化的方式发布。

在最高层级,互联网名称与数字地址分配机构 (ICANN) 将 IP 地址块分配给区域性管理机构。这些区域性管理机构再将地址块分配给其区域内的互联网服务提供商 (ISP) 和托管公司。当你连接到互联网时,ISP 会为你的计算机分配一个固定几个月的 IP 地址。(ISP 通常会定期轮换客户端的 IP 地址。)同样,托管互联网内容的公司会为它们连接到网络的每个服务器分配一个 IP 地址。

IP 地址是二进制数字,通常采用IP 版本 4 (IPv4) 语法表示,允许 2³²(4,294,967,296)个地址。例如,Google 的域名服务器的地址是 8.8.8.8。由于 IPv4 地址的使用速度不可持续,互联网正在转向IP 版本 6 (IPv6) 地址,以支持更多连接的设备,IPv6 地址由八组四个十六进制数字组成,用冒号分隔(例如:2001:0db8:0000:0042:0000:8a2e:0370:7334)。

域名系统

浏览器和其他联网软件能够识别并将流量路由到 IP 地址,但 IP 地址对人类来说并不容易记住。为了让网站地址对用户更友好,我们使用一个全球目录——域名系统 (DNS),将人类可读的域名(例如 example.com) 翻译为 IP 地址,如 93.184.216.119。域名只是 IP 地址的占位符。域名和 IP 地址一样是唯一的,在使用之前必须通过叫做域名注册商的私人机构进行注册。

当浏览器第一次遇到一个域名时,它们会使用本地域名服务器(通常由 ISP 托管)进行查询,然后将结果缓存,以防止未来进行耗时的查询。这种缓存行为意味着新域名或现有域名的更改需要一段时间才能在互联网上传播。具体传播时间由生存时间(TTL)变量控制,该变量设置在 DNS 记录上并指示 DNS 缓存何时过期。DNS 缓存使得一种名为DNS 劫持的攻击成为可能,即本地 DNS 缓存被故意破坏,导致数据被路由到攻击者控制的服务器。

除了为特定域名返回 IP 地址外,域名服务器还托管可以通过规范名称(CNAME)记录描述域别名的记录,从而允许多个域名指向同一个 IP 地址。DNS 还可以通过使用邮件交换(MX)记录帮助路由电子邮件。我们将在第十六章中探讨 DNS 记录如何帮助应对垃圾邮件(spam)。

应用层协议

TCP 允许两台计算机在互联网上可靠地交换数据,但它并没有规定发送的数据应该如何解释。为了实现这一点,双方计算机需要通过协议套件中的另一个更高层的协议达成一致来交换信息。建立在 TCP(或 UDP)之上的协议被称为应用层协议。图 2-1 展示了应用层协议如何位于 TCP 之上,构成互联网协议套件的一部分。

互联网协议套件的低层协议提供网络上的基本数据路由,而应用层的高层协议为应用程序交换数据提供了更多的结构。许多类型的应用程序在互联网上使用 TCP 作为传输机制。例如,电子邮件通过简单邮件传输协议(SMTP)发送,即时通讯软件通常使用可扩展消息与状态协议(XMPP),文件服务器通过文件传输协议(FTP)提供下载,网页服务器则使用超文本传输协议(HTTP)。由于我们主要关注的是 Web,接下来我们将更详细地了解 HTTP。

image

图 2-1:构成互联网协议套件的各个层次

超文本传输协议

Web 服务器使用 超文本传输协议(HTTP) 将网页及其资源传输到 用户代理,例如 web 浏览器。在 HTTP 会话中,用户代理会生成特定资源的 请求。Web 服务器在接收到这些请求后,会返回 响应,其中包含请求的资源,或者如果请求无法完成,则返回错误代码。HTTP 请求和响应都是纯文本消息,尽管它们通常以压缩和加密的形式发送。本书中描述的所有攻击方法都以某种方式使用 HTTP,因此了解构成 HTTP 会话的请求和响应的工作原理是很有价值的。

HTTP 请求

浏览器发送的 HTTP 请求包含以下元素:

方法 也叫做 动词,用于描述用户代理希望服务器执行的操作。

统一资源定位符(URL) 这描述了被操作或获取的资源。

头部 这些提供了元数据,如用户代理期望的内容类型或是否接受压缩响应。

主体 这个可选组件包含任何需要发送到服务器的额外数据。

清单 2-1 显示了一个 HTTP 请求。

   GET❶ http://example.com/❷
❸ User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6)
   AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36
❹ Accept: text/html,application/xhtml+xml,application/xml; */*
   Accept-Encoding: gzip, deflate
   Accept-Language: en-GB,en-US;q=0.9,en;q=0.8

清单 2-1:一个简单的 HTTP 请求

方法 ❶ 和 URL ❷ 出现在第一行。接下来是 HTTP 头,它们分别出现在不同的行。User-Agent 头 ❸ 告诉网站发出请求的浏览器类型。Accept 头 ❹ 告诉网站浏览器期望的内容类型。

使用 GET 方法的请求——简称为 GET 请求——是互联网上最常见的请求类型。GET 请求会获取 web 服务器上的特定资源,该资源通过特定的 URL 进行标识。GET 请求的响应会包含一个资源:可能是一个网页、一张图片,甚至是搜索请求的结果。清单 2-1 中的示例请求表示尝试加载 example.com 的首页,并且当用户在浏览器的导航栏中输入 example.com 时会生成该请求。

如果浏览器需要向服务器发送信息,而不仅仅是获取数据,它通常会使用 POST 请求。当你在网页上填写表单并提交时,浏览器会发送一个 POST 请求。因为 POST 请求包含发送到服务器的信息,浏览器会将这些信息放在 请求体 中,在 HTTP 头之后发送。

在 第八章 中,你将看到为什么在向服务器发送数据时,使用 POST 而不是 GET 请求很重要。错误地使用 GET 请求来执行除获取资源外的其他操作的站点容易受到跨站请求伪造攻击。

在编写网站时,你还可能遇到 PUTPATCHDELETE 请求。这些请求分别用于上传、编辑或删除服务器上的资源,通常由嵌入在网页中的 JavaScript 触发。表 2-1 记录了其他一些值得了解的方法。

表 2-1: 较少为人知的 HTTP 方法

HTTP 方法 功能和实现
HEAD HEAD 请求检索与 GET 请求相同的信息,但指示服务器返回没有主体的响应(换句话说,就是没有有效的部分)。如果你在 web 服务器上实现了 GET 方法,服务器通常会自动响应 HEAD 请求。
CONNECT CONNECT 启动双向通信。如果你需要通过代理连接,您将在 HTTP 客户端代码中使用它。
OPTIONS 发送 OPTIONS 请求允许用户代理询问某个资源支持哪些其他方法。你的 web 服务器通常会根据你实现的其他方法自动响应 OPTIONS 请求。
TRACE TRACE 请求的响应将包含原始 HTTP 请求的精确副本,这样客户端就可以看到是否有中间服务器进行了修改。听起来很有用,但通常建议你在 web 服务器中禁用 TRACE 请求,因为它们可能成为安全漏洞。例如,它们可能允许注入到页面中的恶意 JavaScript 访问故意对 JavaScript 隐藏的 cookies。

一旦 web 服务器收到 HTTP 请求,它会用 HTTP 响应回复用户代理。我们来解析一下响应的结构。

HTTP 响应

由 web 服务器返回的 HTTP 响应以协议描述、三位数的 状态码 开头,通常还包括 状态信息,表示请求是否能够被完成。响应还包含头部,提供指示浏览器如何处理内容的元数据。最后,大多数响应包含一个主体,其中包含所请求的资源。清单 2-2 显示了一个简单的 HTTP 响应的内容。

   HTTP/1.1❶ 200❷ OK❸
❹ Content-Encoding: gzip
   Accept-Ranges: bytes
   Cache-Control: max-age=604800
   Content-Type: text/html
   Content-Length: 606

❺ <!doctype html>
   <html>
      <head>
         <title>Example Domain</title>
     ❻ <style type="text/css">
            body {
               background-color: #f0f0f2;
               font-family: "Open Sans", "Helvetica Neue", Helvetica, sans-serif;
            }
            div {
               width: 600px;
               padding: 50px;
               background-color: #fff;
               border-radius: 1em;
            }
         </style>
      </head>
  ❼ <body>
         <div>
            <h1>Example Domain</h1>
            <p>This domain is established to be used for illustrative examples.</p>
            <p>
              <a href="http://www.iana.org/domains/example">More information...</a>
            </p>
         </div>
      </body>
   </html>

清单 2-2:来自 example.com 的 HTTP 响应,世界上最无聊的网站

响应以协议描述 ❶、状态码 ❷ 和状态信息 ❸ 开头。状态码格式为 2xx 表示请求被理解、接受并已回应。格式为 3xx 的代码会将客户端重定向到不同的 URL。格式为 4xx 的代码表示客户端错误:浏览器生成了一个看似无效的请求。(这种错误最常见的是 HTTP 404 Not Found)。格式为 5xx 的代码表示服务器错误:请求是有效的,但服务器无法完成请求。

接下来是 HTTP 头部 ❹。几乎所有 HTTP 响应都包括一个 Content-Type 头部,表示返回的数据类型。响应 GET 请求时,通常还会包含一个 Cache-Control 头部,指示客户端应当将大资源(例如图像)本地缓存。

如果 HTTP 响应成功,正文将包含客户端试图访问的资源——通常是超文本标记语言(HTML) ❺,描述了请求的网页的结构。在这种情况下,响应包含了样式信息 ❻ 以及页面内容本身 ❼。其他类型的响应可能返回 JavaScript 代码、用于样式化 HTML 的层叠样式表(CSS)或正文中的二进制数据。

有状态连接

Web 服务器通常同时处理多个用户代理,但 HTTP 本身并没有区分哪些请求来自哪个用户代理。在互联网的早期,这并不是一个重要的考虑因素,因为网页大多是只读的。然而,现代网站通常允许用户登录,并会跟踪他们在访问和与不同页面互动时的活动。为了实现这一点,HTTP 会话需要变得有状态。当客户端和服务器之间的连接或会话是有状态的,它们会进行“握手”,并继续交换数据包,直到其中一方决定终止连接。

当一个 Web 服务器想要跟踪它响应的用户,并从而实现有状态的 HTTP 会话时,它需要建立一种机制来跟踪用户代理在后续请求中的活动。特定用户代理与 Web 服务器之间的整个对话称为HTTP 会话。最常见的跟踪会话的方式是,服务器在初始 HTTP 响应中返回一个 Set-Cookie 头部。这要求接收响应的用户代理存储一个cookie,它是与该特定 Web 域相关的小段文本数据。然后,用户代理会在任何后续 HTTP 请求的 Cookie 头部中返回相同的数据给 Web 服务器。如果实现正确,往返传递的 cookie 内容唯一标识了用户代理,从而建立了 HTTP 会话。

存储在 cookies 中的会话信息是黑客的一个美味目标。如果攻击者窃取了另一个用户的 cookie,他们就可以冒充该用户在网站上活动。同样,如果攻击者成功说服网站接受伪造的 cookie,他们就能冒充任何他们想要的用户。在第十章中,我们将探讨窃取和伪造 cookie 的各种方法。

加密

当互联网最初发明时,HTTP 请求和响应是以明文形式发送的,这意味着任何拦截数据包的人都可以读取它们;这种拦截方式被称为中间人攻击。由于现代网络中私人通信和在线交易变得普遍,网页服务器和浏览器通过使用加密来保护用户免受此类攻击,方法是通过传输过程中对消息内容进行编码,以防止被窥探者读取。

为了确保通信安全,网页服务器和浏览器使用传输层安全(TLS)来发送请求和响应,这是一种提供隐私和数据完整性的加密方法。TLS 确保被第三方拦截的数据包在没有适当加密密钥的情况下无法解密。它还确保任何篡改数据包的尝试都可以被检测到,从而保证数据完整性。

使用 TLS 进行的 HTTP 对话被称为HTTP 安全(HTTPS)。HTTPS 要求客户端和服务器执行TLS 握手,在此过程中双方就加密方法(即密码算法)达成一致并交换加密密钥。一旦握手完成,任何进一步的消息(包括请求和响应)都将对外界不可见。

加密是一个复杂的话题,但对于保护你的网站至关重要。我们将在第十三章中讨论如何为你的网站启用加密。

总结

在本章中,你了解了互联网的基础架构。TCP 使得具有 IP 地址的互联网连接计算机之间可以可靠地进行通信。域名系统为 IP 地址提供了人类可读的别名。HTTP 建立在 TCP 之上,从用户代理(如网页浏览器)向网页服务器发送 HTTP 请求,网页服务器再回复 HTTP 响应。每个请求都发送到特定的 URL,并且你学习了各种类型的 HTTP 方法。网页服务器通过状态码回应,并发送回 cookies 以建立有状态连接。最后,加密(以 HTTPS 的形式)可用于保护用户代理和网页服务器之间的通信安全。

在下一章,你将了解当网页浏览器接收到 HTTP 响应时会发生什么——网页是如何呈现的,以及用户的操作如何生成更多的 HTTP 请求。

第三章:浏览器是如何工作的

image

大多数互联网用户通过浏览器与网站进行互动。要构建安全的网站,你需要理解浏览器如何将用来描述网页的超文本标记语言(HTML)转换为你在屏幕上看到的互动式视觉表现。本章将介绍现代浏览器如何渲染网页,并重点讲解它为保护用户所采取的安全措施——浏览器安全模型。我们还将探讨黑客试图突破这些安全措施的各种方式。

网页渲染

浏览器内负责将网页 HTML 转换为你在屏幕上看到的视觉表示的组件被称为渲染管道。渲染管道负责解析页面的 HTML,理解文档的结构和内容,并将其转换为操作系统可以理解的一系列绘制操作。

在互联网早期,网站的这一过程相对简单。网页的 HTML 包含的样式信息很少(如颜色、字体和字体大小),因此渲染大多是加载文本和图片,并按 HTML 文档中的顺序将它们绘制到屏幕上。HTML 被视为一种标记语言,意味着它通过将网页分解为语义元素并注释信息结构的方式来描述网页。早期的网络看起来比较粗糙,但对于传递文本内容来说非常高效。

如今,网页设计更为精致,视觉效果也更具吸引力。网页开发者将样式信息编码到单独的Cascading Style Sheets (CSS) 文件中,指示浏览器如何精确地显示每个页面元素。像谷歌 Chrome 这样的现代化超优化浏览器,包含数百万行代码,能够以快速、统一的方式正确解析和渲染 HTML,并处理冲突的样式规则。了解渲染管道的各个阶段将有助于你理解这一复杂性。

渲染管道概览

我们稍后会详细介绍渲染管道的每个阶段,但首先让我们来看一下高层次的过程。

当浏览器收到 HTTP 响应时,它会将响应体中的 HTML 解析为文档对象模型(DOM):一种内存中的数据结构,表示浏览器对页面结构的理解。在解析 HTML 和将其绘制到屏幕上之间,生成 DOM 是一个过渡步骤。在现代 HTML 中,页面的布局无法确定,直到整个 HTML 被解析完毕,因为 HTML 标签的顺序并不一定决定其内容的位置。

一旦浏览器生成了 DOM,但在任何内容显示到屏幕上之前,必须先对每个 DOM 元素应用样式规则。这些样式规则声明了每个页面元素的显示方式——前景色和背景色、字体样式和大小、位置和对齐方式等。最后,在浏览器完成页面结构并确定如何应用样式信息后,它会将网页绘制到屏幕上。所有这些都发生在一瞬间,并且随着用户与页面的交互不断重复。

浏览器在构建 DOM 时还会加载并执行它遇到的任何 JavaScript 代码。JavaScript 代码可以动态地修改 DOM 和样式规则,无论是在页面渲染之前还是响应用户的操作。

现在让我们更详细地看一下每个步骤。

文档对象模型

当浏览器首次接收到包含 HTML 的 HTTP 响应时,它会将 HTML 文档解析成 DOM,一个描述 HTML 文档为一系列嵌套元素的数据结构,这些元素被称为DOM 节点。DOM 中的某些节点对应于需要在屏幕上渲染的元素,如输入框和段落文本;其他节点,如脚本和样式元素,控制页面的行为和布局。

每个 DOM 节点大致相当于原始 HTML 文档中的一个标签。DOM 节点可以包含文本内容,或者包含其他 DOM 节点,类似于 HTML 标签可以嵌套在一起的方式。由于每个节点可以以分支的方式包含其他节点,网页开发者称之为DOM 树

一些 HTML 标签,如 <script><style><image><font><video> 标签,可以在属性中引用外部 URL。当这些标签被解析到 DOM 中时,它们会导致浏览器导入外部资源,这意味着浏览器必须发起进一步的 HTTP 请求。现代浏览器会并行执行这些请求与页面渲染,以加快页面加载时间。

从 HTML 构建 DOM 的设计目标是尽可能强大。浏览器对于格式错误的 HTML 是宽容的;它们会自动关闭未闭合的标签,插入缺失的标签,并根据需要忽略损坏的标签。浏览器厂商不会因为网站的错误而惩罚网页用户。

样式信息

一旦浏览器构建了 DOM 树,它需要确定哪些 DOM 节点对应于屏幕上的元素,如何布局这些元素以及应该应用哪些样式信息。虽然这些样式规则可以在 HTML 文档中内联定义,但网页开发者更喜欢将样式信息编码到单独的 CSS 文件中。将样式信息与 HTML 内容分离使得重新样式化现有内容更加容易,并且保持 HTML 内容尽可能干净和语义化。它还使得 HTML 对于屏幕阅读器等替代浏览技术更容易解析。

在使用 CSS 时,网页开发者会创建一个或多个样式表来声明页面元素的渲染方式。HTML 文档会通过使用<style>标签来导入这些样式表,该标签引用了托管样式表的外部 URL。每个样式表包含选择器,它们会挑选出 HTML 文档中的标签,并为每个标签分配样式信息,如字体大小、颜色和位置等。选择器可能很简单:例如,它可能会说明<h1>标签中的标题文本应以蓝色显示。对于更复杂的网页,选择器会变得更加复杂:一个选择器可能会描述当用户将鼠标移到超链接上时,超链接的颜色变化速度。

渲染管道实施了很多逻辑来解读最终的样式,因为关于如何应用样式需要遵循严格的优先级规则。每个选择器可以应用于多个页面元素,而且每个页面元素通常会有多个选择器提供的样式信息。早期互联网的一大难题是如何创建一个在不同类型的浏览器中渲染时看起来相同的网站。现代浏览器通常在渲染网页时保持一致性,但它们之间仍然存在差异。行业对网页标准合规性的基准是 Acid3 测试,如图 3-1 所示。只有少数浏览器能获得 100 分。你可以访问acid3.acidtests.org/来体验 Acid3 测试。

image

图 3-1:Acid3,自 2008 年以来确保浏览器能够正确渲染彩色矩形

DOM 树的构建和样式规则的应用与网页中包含的 JavaScript 代码的处理是并行进行的。这个 JavaScript 代码可以在页面渲染之前改变页面的结构和布局,所以让我们快速了解一下 JavaScript 执行是如何与渲染管道交织在一起的。

JavaScript

现代网页使用 JavaScript 来响应用户的操作。JavaScript是一种完整的编程语言,当网页渲染时,它会由浏览器的 JavaScript 引擎执行。JavaScript 可以通过使用<script>标签嵌入到 HTML 文档中;代码可以直接嵌入到 HTML 文档内,或者更常见的是,<script>标签引用一个从外部 URL 加载的 JavaScript 文件。

默认情况下,任何 JavaScript 代码都会在相关的<script>标签解析为 DOM 节点后立即由浏览器执行。对于从外部 URL 加载的 JavaScript 代码,这意味着代码一旦加载就会立即执行。

如果渲染管道尚未完成解析 HTML 文档,这种默认行为会引发问题;JavaScript 代码将尝试与可能尚未存在于 DOM 中的页面元素进行交互。为了避免这种情况,<script> 标签通常会标记 defer 属性。这会导致 JavaScript 仅在整个 DOM 构建完成后才执行。

正如你所想象的那样,浏览器会急切地执行它遇到的任何 JavaScript 代码,这带来了安全隐患。黑客的最终目标通常是在另一台用户的机器上远程执行代码,而互联网使这一目标变得更容易,因为很少有计算机不是以某种方式连接到网络。因此,现代浏览器通过浏览器安全模型严格限制 JavaScript。这一模型规定,JavaScript 代码必须在一个沙箱中执行,在这里,它不能执行以下任何操作:

  • 启动新进程或访问其他现有进程。

  • 读取任意的系统内存块。作为一个托管内存语言,JavaScript 无法读取其沙箱之外的内存。

  • 访问本地磁盘。现代浏览器允许网站在本地存储少量数据,但这种存储被从文件系统本身进行了抽象。

  • 访问操作系统的网络层。

  • 调用操作系统功能。

在浏览器沙箱中执行的 JavaScript 被允许执行以下操作:

  • 读取和操作当前网页的 DOM。

  • 通过注册事件监听器,监听并响应当前页面上用户的操作。

  • 代表用户发起 HTTP 请求。

  • 打开新的网页或刷新当前页面的 URL,但仅能响应用户的操作。

  • 向浏览器历史记录中写入新条目,并在历史记录中向前和向后导航。

  • 请求用户的位置信息。例如,“Google Maps 想要使用您的位置。”

  • 请求发送桌面通知的权限。

即使有这些限制,攻击者仍然可以通过将恶意 JavaScript 注入到你的网页中,利用跨站脚本攻击来读取用户输入的信用卡信息或凭证。即使是少量注入的 JavaScript 代码也构成威胁,因为注入的代码可以在 DOM 中添加 <script> 标签来加载恶意有效载荷。我们将在第七章中讨论如何防范这种类型的跨站脚本攻击。

渲染前后:浏览器所做的一切

浏览器不仅仅是一个渲染管线和 JavaScript 引擎。除了渲染 HTML 和执行 JavaScript,现代浏览器还包含了处理其他多项任务的逻辑。浏览器与操作系统连接以解析和缓存 DNS 地址,解释和验证安全证书,如果需要,使用 HTTPS 编码请求,并根据 Web 服务器的指示存储和传输 cookie。为了理解这些责任是如何协调工作的,我们来看看用户登录 Amazon 的幕后过程:

  1. 用户在他们喜欢的浏览器中访问www.amazon.com

  2. 浏览器尝试将域名(amazon.com)解析为 IP 地址。首先,浏览器查询操作系统的 DNS 缓存。如果没有结果,它会请求互联网服务提供商(ISP)查看提供商的 DNS 缓存。如果 ISP 中的任何人之前没有访问过 Amazon 网站,ISP 将通过权威 DNS 服务器来解析该域名。

  3. 现在浏览器已经解析了 IP 地址,尝试与对应 IP 地址的服务器发起 TCP 握手,以建立安全连接。

  4. 一旦 TCP 会话建立,浏览器会构建一个 HTTP GET请求发送到www.amazon.com。TCP 将 HTTP 请求拆分为数据包并将其发送到服务器进行重组。

  5. 在此时,HTTP 会话升级为 HTTPS 以确保安全通信。浏览器和服务器进行 TLS 握手,商定加密算法,并交换加密密钥。

  6. 服务器通过安全通道返回包含 Amazon 首页 HTML 的 HTTP 响应。浏览器解析并显示该页面,通常会触发多个其他 HTTP GET请求。

  7. 用户进入登录页面,输入登录凭证并提交登录表单,生成一个POST请求发送到服务器。

  8. 服务器验证登录凭证并通过返回Set-Cookie头部信息来建立会话。浏览器将这个 cookie 存储在规定的时间内,并在后续请求中发送给 Amazon。

所有这些完成后,用户可以访问他们的 Amazon 账户。

总结

本章回顾了浏览器如何将用来描述网页的 HTML 转换为你在屏幕上看到的互动、视觉化展示。浏览器的渲染管线将 HTML 文档解析为文档对象模型(DOM),应用来自层叠样式表(CSS)文件的样式信息,然后在屏幕上布局 DOM 节点。

你还了解了浏览器安全模型。浏览器在严格的安全规则下执行<script>标签中的 JavaScript。你还回顾了一个简单的 HTTP 对话,展示了浏览器在呈现页面之外的许多其他职责:从 TCP 数据包重建 HTTP、验证安全证书并使用 HTTPS 确保通信安全,以及存储和传输 Cookies。

在下一章,你将了解 HTTP 对话的另一端:Web 服务器。

第四章:Web 服务器的工作原理

image

在上一章中,你了解了浏览器如何通过互联网进行通信,并渲染组成网站的 HTML 页面和其他资源。在这一章中,你将学习 Web 服务器如何构建这些 HTML 页面。

从最简单的定义来看,Web 服务器 是一个计算机程序,它在响应 HTTP 请求时返回 HTML 页面。然而,现代 Web 服务器所包含的功能远远超出了这一点。当浏览器发出 HTTP 请求时,现代 Web 服务器允许执行代码以动态生成网页的 HTML,且通常会结合来自数据库的内容。作为 Web 开发者,你将大部分时间都花费在编写和测试这种类型的代码上。

本章将介绍开发者如何在 Web 服务器中组织代码和资源。我还将指出 Web 服务器中常见的弱点,这些弱点可能导致安全漏洞的发生,并讨论如何避免这些陷阱。

静态资源与动态资源

Web 服务器在响应 HTTP 请求时提供两种类型的内容:静态资源和动态资源。静态资源 是 HTML 文件、图像文件或其他类型的文件,Web 服务器在 HTTP 响应中返回这些文件时不做任何修改。动态资源 是代码、脚本或模板,Web 服务器会在响应 HTTP 请求时执行或解析这些内容。现代 Web 服务器能够同时托管静态资源和动态资源。服务器执行或返回哪个资源,取决于 HTTP 请求中的 URL。你的 Web 服务器会根据一个配置文件来解析 URL,将 URL 模式映射到特定资源。

让我们来看看 Web 服务器如何处理静态资源和动态资源。

静态资源

在互联网的早期,网站大多由静态资源组成。开发者手动编写 HTML 文件,网站由单个 HTML 文件组成,这些文件被部署到 Web 服务器上。网站的“部署”要求开发者将所有 HTML 文件复制到 Web 服务器并重启服务器进程。当用户想访问网站时,他们会在浏览器中输入网站的 URL。浏览器会向托管网站的 Web 服务器发出 HTTP 请求,服务器会将传入的 URL 解析为对磁盘上某个文件的请求。最终,Web 服务器会按原样返回 HTML 文件作为 HTTP 响应。

一个例子是 1996 年电影 Space Jam 的网站。它完全由静态资源组成,而且至今仍在线,网址为 spacejam.com。浏览这个网站让我们回到了一个简单、审美上也不那么精致的网页开发时代。如果你访问该网站,你会注意到每个像 www.spacejam.com/cmp/sitemap.html 这样的 URL 都以 .html 后缀结尾,表明每个网页都对应服务器上的一个 HTML 文件。

提姆·伯纳斯-李(Tim Berners-Lee)最初的网络愿景看起来与Space Jam网站非常相似:一个由静态文件组成的网络,这些文件托管在网络服务器上,包含了全世界的信息。

URL 解析

现代网络服务器处理静态资源的方式与其旧版服务器非常相似。要在浏览器中访问一个资源,您需要在 URL 中包含资源名称,网络服务器将按请求从磁盘返回资源文件。为了显示图 4-1 中的图片,URL 包含资源名称/images/hedgehog_in_spaghetti.png,网络服务器从磁盘返回相应的文件。

image

图 4-1:静态资源示例

现代网络服务器还有一些额外的技巧。现代网络服务器允许将任何 URL 映射到特定的静态资源。我们预期hedgehog_in_spaghetti.png资源应该是位于网络服务器的/images目录中的一个文件,但实际上,开发者可以将其命名为任何他们选择的名称。通过将 URL 与文件路径解绑,网络服务器为开发者提供了更多组织代码的自由。例如,这可能允许每个用户使用相同路径,但具有不同的个人资料图片。

在返回静态资源时,现代网络服务器通常会在 HTTP 响应中添加数据,或在返回资源之前处理静态资源。例如,网络服务器通常会使用gzip算法动态压缩大型资源文件,以减少响应中使用的带宽,或者在 HTTP 响应中添加缓存头,指示浏览器在用户在定义的时间窗口内再次查看该静态资源时,缓存并使用静态资源的本地副本。这使得网站对用户更加响应迅速,并减少了服务器需要处理的负载。

由于静态资源只是某种形式的文件,它们本身并不容易出现安全漏洞。然而,解析 URL 到文件的过程可能会引入漏洞。如果用户指定某些类型的文件为私密(例如,他们上传的图片),则需要在网络服务器上定义访问控制规则。我们将在第十一章中查看黑客如何尝试绕过访问控制规则。

内容分发网络

一项旨在提高静态文件传输速度的现代创新是内容分发网络(CDN),它会在全球各地的数据中心存储静态资源的重复副本,并从最近的物理位置快速将这些资源传递到浏览器。像 Cloudflare、Akamai 或 Amazon CloudFront 这样的 CDN 将大资源文件(如图像)的传输负担交给第三方。因此,它们使得即便是小公司也能在没有庞大服务器支出的情况下,制作响应式网站。将 CDN 集成到你的站点中通常是直接的,CDN 服务会根据你部署的资源量收取月费。

使用 CDN 还会引入安全复杂性。与 CDN 集成实际上允许第三方以你的安全证书为依据提供内容,因此你需要安全地设置 CDN 集成。我们将在第十四章中研究如何安全地集成第三方服务,如 CDN。

内容管理系统

许多网站仍然主要由静态内容构成。这些网站通常不是手动编写的,而是通过内容管理系统(CMS)构建的,这些系统提供了几乎不需要技术知识的创作工具,用于编写内容。CMS 通常对页面强制执行统一样式,并允许管理员直接在浏览器中更新内容。

CMS 插件还可以提供分析功能来跟踪访问者,增加预约管理或客户支持功能,甚至创建在线商店。这种插件方法是网站利用来自第三方公司的专业服务构建定制功能的一个更大趋势。例如,网站通常使用 Google Analytics 进行客户追踪,使用 Facebook 登录进行身份验证,使用 Zendesk 提供客户支持。你只需要几行代码和一个 API 密钥,就可以添加这些功能,从而使从零开始构建功能丰富的网站变得更加容易。

使用他人的代码来构建你的网站,无论是通过集成 CMS 还是使用插件服务,从理论上讲可以让你更加安全,因为这些第三方雇用了安全专业人员,并且有动机确保其服务的安全性。然而,这些服务和插件的普及也使它们成为黑客的攻击目标。例如,许多自托管的 WordPress 实例(最受欢迎的 CMS)很少进行修补。你可以通过简单的 Google 搜索轻松发现 WordPress 漏洞,正如图 4-2 所示。

当你使用第三方代码时,需要关注安全公告,并在安全补丁发布后尽快部署。我们将在第十四章中调查第三方代码和服务相关的风险。

image

图 4-2:来获取你未加固的 WordPress 实例。

动态资源

尽管使用静态资源更简单,但手动编写单独的 HTML 文件却非常耗时。试想一下,如果零售网站每次新增库存商品时都必须编写一个新的网页,那将浪费每个人的时间(尽管这会确保网页开发人员的工作安全)。

大多数现代网站则使用动态资源。通常,动态资源的代码从数据库中加载数据,以填充 HTTP 响应。通常,动态资源输出 HTML,尽管根据浏览器的预期,其他内容类型也可以被返回。

动态资源使零售网站能够实现一个能够展示多种类型产品的单一产品网页。每次用户查看网站上的某个特定产品时,网页都会从 URL 中提取产品代码,从数据库加载产品价格、图片和描述,并将这些数据插入到 HTML 中。然后,向零售商库存中添加新产品只需在数据库中添加新行。

动态资源有许多其他用途。如果你访问你的银行网站,它会查找你的账户信息并将其集成到 HTML 中。像 Google 这样的搜索引擎从 Google 庞大的搜索索引中返回匹配的结果,并将其显示在动态页面中。许多网站,包括社交媒体和网页邮箱网站,在每个用户登录后显示不同的内容,因为它们会在用户登录后动态生成 HTML。

动态资源虽然非常有用,但它们也带来了新的安全漏洞。将内容动态插入 HTML 的过程可能会受到攻击。我们将在第七章中探讨如何防止恶意注入的 JavaScript,并在第八章中了解来自其他网站的 HTTP 请求如何造成危害。

模板

第一个动态资源是简单的脚本文件,通常是用 Perl 语言编写的,网站服务器在用户访问特定 URL 时执行这些脚本文件。这些脚本文件会输出构成特定网页的 HTML。

以这种方式构成动态资源的代码通常不容易阅读。如果一个网页由静态资源组成,你可以查看静态的 HTML 文件来了解它是如何组织的,但如果是包含一千行 Perl 代码的动态资源,就很难做到这一点。本质上,你有一种语言(Perl)在用另一种语言(HTML)输出内容,而浏览器最终会将这些内容渲染到屏幕上。在编辑 Perl 代码时,要时刻考虑最终渲染输出的样子,这是一项艰巨的任务。

为了解决这个问题,web 开发人员通常使用模板文件来构建动态网页。模板主要是 HTML,但其中穿插了程序逻辑,包含了对 web 服务器的指令。这些逻辑通常很简单,通常执行以下三种操作之一:从数据库或 HTTP 请求中提取数据并将其插入到 HTML 中,条件性地渲染 HTML 模板中的某些部分,或者遍历数据结构(例如,项目列表)以重复渲染一段 HTML。所有现代 web 框架都使用模板文件(语法上有所不同),因为将代码片段嵌入 HTML 中通常能让代码更简洁和易读。

数据库

当一个 web 服务器执行动态资源中的代码时,它通常会从数据库中加载数据。如果你访问一个零售网站,web 服务器会在数据库中查找产品 ID,并使用存储在数据库中的产品信息来构建页面。如果你登录一个社交媒体网站,web 服务器会从底层数据库中加载你的时间线和通知,以便生成 HTML。事实上,大多数现代网站都使用数据库来存储用户信息,而 web 服务器与数据库之间的接口是黑客攻击的常见目标。

数据库技术早于 web 的发明。随着计算机在 1960 年代的普及,企业开始认识到数字化和集中存储记录的重要性,以便更容易地进行搜索和维护。随着 web 的诞生,将一个 web 前端加到产品库存数据库上,成为企业向在线零售拓展的自然发展。

数据库对于身份验证也至关重要。如果一个网站想要识别回访用户,它需要记录哪些用户已经注册,并在用户返回时通过与存储的凭证核对,来验证或认证他们的登录信息。

最常用的两种数据库类型是 SQL 和 NoSQL。让我们来看一下这两者。

SQL 数据库

目前最常用的数据库是关系型数据库,它们实现了结构化查询语言 (SQL),这是一种声明式编程语言,用于维护和提取数据。

注意

SQL 的发音可以是“ess-qew-ell”或者“sequel”,不过如果你想看看你的数据库管理员会不会感到不舒服,你可以尝试把它发音为“squeal”。

SQL 数据库是关系型的,这意味着它们将数据存储在一个或多个中,并且这些表之间有正式规定的关系。你可以把表想象成类似 Microsoft Excel 的电子表格,包含行和列,每一行代表一个数据项,每一列代表该数据项的一个数据点。SQL 数据库中的列有预定义的数据类型,通常是文本字符串(通常是固定长度)、数字或日期。

关系型数据库中的数据库表通过彼此关联。通常,每个表中的每一行都有一个唯一的数字主键,并且表格可以通过外键引用其他表的行。例如,如果你在存储用户订单的数据库记录,那么orders表会有一个名为user_id的外键列,表示下订单的用户。与其直接在orders表中存储用户信息,不如让user_id列包含引用users表中特定行主键(即id列)值的外键。这样可以确保你不能在没有存储用户的情况下在数据库中存储订单,并且确保每个用户只有唯一的真实数据来源。

关系型数据库还具有数据完整性约束,可以防止数据损坏,并使得对数据库的统一查询成为可能。像外键一样,其他类型的数据完整性约束也可以在 SQL 中定义。例如,你可以要求email_address列在users表中只包含唯一值,从而强制每个数据库中的用户拥有不同的电子邮件地址。你还可以要求表格中的字段不能为 null,这样数据库必须为每个用户指定一个电子邮件地址。

SQL 数据库也展现了事务性和一致性行为。一个数据库事务是一组批量执行的 SQL 语句。如果每个事务是“全有或全无”的,那么这个数据库就是事务性的:也就是说,如果批量中的任何 SQL 语句执行失败,整个事务将失败,并保持数据库状态不变。SQL 数据库是一致性的,因为每次成功的事务都会将数据库从一个有效状态带到另一个有效状态。任何尝试在 SQL 数据库中插入无效数据的行为都会导致整个事务失败,并且数据库保持不变。

由于存储在 SQL 数据库中的数据通常高度敏感,黑客会攻击数据库,试图将其内容销往黑市。黑客还经常利用构造不安全的 SQL 语句。我们将在第六章中详细讨论这一点。

NoSQL 数据库

SQL 数据库往往是 Web 应用性能的瓶颈。如果大多数 HTTP 请求都需要访问数据库,那么数据库服务器将承受巨大的负载,导致网站的性能变慢,影响所有用户的使用体验。

这些性能问题导致了 NoSQL 数据库的日益流行——这些数据库牺牲了传统 SQL 数据库的严格数据完整性要求,以实现更好的可扩展性。NoSQL 涵盖了多种存储和访问数据的方法,但其中一些趋势已逐渐显现。

NoSQL 数据库通常是无模式的,允许你在不需要升级任何数据结构的情况下为新记录添加字段。为了实现这种灵活性,数据通常以键值形式存储,或者以JavaScript 对象表示法 (JSON) 存储。

NoSQL 数据库技术通常更侧重于数据的广泛复制而非绝对一致性。SQL 数据库保证不同客户端程序的同时查询将得到相同的结果;而 NoSQL 数据库则通常放宽这一约束,仅保证 最终一致性

NoSQL 数据库使得存储非结构化或半结构化数据变得非常容易。提取和查询数据则相对复杂一些——一些数据库提供了编程接口,而其他数据库则实现了自己的查询语言,将类似 SQL 的语法适应到其数据结构中。NoSQL 数据库同 SQL 数据库一样容易受到注入攻击,尽管攻击者必须正确猜测数据库类型才能成功发起攻击。

分布式缓存

动态资源也可以从内存中的分布式 缓存 加载数据,这是一种实现大型网站所需巨大可扩展性的常见方法。缓存 指的是将其他地方存储的数据副本以易于检索的形式存储,以加速数据的检索。像 Redis 或 Memcached 这样的 分布式缓存 使得缓存数据变得简单,并允许软件在不同的服务器和进程间共享数据结构,以一种与语言无关的方式。分布式缓存可以在 Web 服务器之间共享,非常适合存储频繁访问的数据,这些数据原本需要从数据库中提取。

大型互联网公司通常将其技术栈实施为一系列 微服务——简单、模块化的服务,按需执行单一操作——并使用分布式缓存来实现它们之间的通信。服务通常通过存储在分布式缓存中的 队列 进行通信:这些数据结构可以将任务置于等待状态,从而让多个工作进程一次完成一个任务。服务还可以使用 发布-订阅 通道,允许多个进程注册对某种事件的兴趣,并在事件发生时一次性通知它们。

分布式缓存和数据库一样容易受到黑客攻击。幸运的是,Redis 和 Memcached 是在这些威胁已经广为人知的时代开发的,因此最佳实践通常已经内置于 软件开发工具包(SDKs) 中,这些是你用来连接缓存的代码库。

Web 编程语言

Web 服务器会在评估动态资源的过程中执行代码。大量编程语言可以用来编写 Web 服务器代码,每种语言都有不同的安全考虑。

让我们来看一些更常用的语言。我们将在后续章节中使用这些语言进行代码示例。

Ruby (on Rails)

Ruby 编程语言像 龙珠 Z 和汤姆·塞立克主演的电影 棒球先生 一样,是在 90 年代中期由日本发明的。不同于 龙珠 Z 或汤姆·塞立克,它直到 Ruby on Rails 平台发布后才开始流行,整整过了十年。

Ruby on Rails 融合了许多构建大规模 Web 应用的最佳实践,并且使它们能够以最少的配置轻松实现。Rails 社区也非常重视安全。Rails 是最早实现防止跨站请求伪造攻击(CSRF)的 Web 服务器之一。然而,Rails 的普及性也使它成为黑客的常见攻击目标。近年来,发现了几个重大的安全漏洞(并迅速修补)。

简化版的 Ruby Web 服务器,通常被称为 微框架(例如 Sinatra),近年来已成为 Rails 的流行替代品。微框架允许你将执行单一功能的代码库组合在一起,使得你的 Web 服务器在设计上非常简洁。这与 Rails 的“包罗万象”部署模型形成了鲜明对比。使用微框架的开发者通常通过使用 RubyGems 包管理器来找到他们所需的额外功能。

Python

Python 语言是在 1980 年代末期发明的。其简洁的语法、灵活的编程范式以及丰富的模块库使得该语言异常流行。Python 的新手经常会惊讶于空格和缩进具有语义含义,这在编程语言中是比较少见的。空格在 Python 社区中如此重要,以至于他们为是否应该使用制表符或空格进行缩进展开了“圣战”。

Python 被广泛应用于各种领域,常常成为数据科学和科学计算项目的首选语言。Web 开发者可以选择多种活跃维护的 Web 服务器(如流行的 Django 和 Flask)。这些 Web 服务器的多样性也充当了安全特性,因为黑客不太可能针对某一特定平台。

JavaScript 和 Node.js

JavaScript 最初是作为一种在浏览器中执行小脚本的简单语言,但后来成为编写 Web 服务器代码的流行语言,并通过 Node.js 运行时迅速发展。Node.js 运行在 V8 JavaScript 引擎 之上,V8 是 Google Chrome 用来在浏览器中解释 JavaScript 的软件组件。尽管 JavaScript 仍然存在许多怪癖,但在客户端和服务器端使用同一种语言的前景使得 Node 成为增长最快的 Web 开发平台。

Node 中最大的安全风险来源于其快速增长——每天都会增加数百个模块。在使用第三方代码时,你需要特别小心。

PHP

PHP 语言源自一组用于在 Linux 上构建动态网站的 C 二进制文件。PHP 后来发展成了一门完整的编程语言,尽管该语言的无计划演化在其杂乱无章的特性中有所体现。PHP 在实现许多内建函数时不一致。例如,变量名区分大小写,而函数名则不区分。尽管存在这些怪癖,PHP 仍然很受欢迎,曾一度支持了互联网 10% 的网站。

如果你在编写 PHP,通常是在维护一个遗留系统。由于较老的 PHP 框架存在一些最为严重的安全漏洞,你应该更新遗留的 PHP 系统以使用现代的库。每种类型的漏洞,无论是命令执行、目录遍历,还是缓冲区溢出,都曾让 PHP 程序员夜不能寐。

Java

JavaJava 虚拟机(JVM) 在企业领域得到了广泛使用和实现,使得你可以跨多个操作系统运行 Java 编译后的字节码。当性能是一个重要考虑因素时,Java 通常是一个可靠的工作语言。

开发者曾使用 Java 来做各种事情,无论是机器人技术、移动应用开发、大数据应用,还是嵌入式设备。尽管作为一种 Web 开发语言,它的流行度有所下降,但数百万行 Java 代码仍然驱动着互联网。从安全角度来看,Java 因其过去的流行而“受到困扰”;遗留应用程序包含大量运行旧版 Java 语言和框架的 Java 代码。Java 开发者需要及时更新到安全版本,以免成为黑客的目标。

如果你是一个更具冒险精神的开发者,你会发现其他流行的运行在 JVM 上并与 Java 庞大的第三方库生态系统兼容的语言。Clojure 是一种流行的 Lisp 方言;Scala 是一种具有静态类型的函数式语言;Kotlin 是一种较新的面向对象语言,旨在与 Java 向后兼容,同时使脚本编写更为简便。

C#

C# 是微软为 .NET 计划设计的语言。C#(以及其他 .NET 语言,如 VB.NET)使用一种名为 公共语言运行时(CLR) 的虚拟机。C# 相对于操作系统的抽象程度低于 Java,并且你可以愉快地将 C++ 代码与 C# 混合使用。

微软晚年转向开源布道,值得庆幸的是,C# 的参考实现现在已经开源。Mono 项目使得 .NET 应用可以在 Linux 和其他操作系统上运行。然而,大多数使用 C# 的公司依然部署在 Windows 服务器和典型的微软堆栈上。Windows 在安全方面有着令人不安的历史——例如,它是 病毒 最常见的攻击平台——因此任何计划采用 .NET 作为平台的人都需要意识到其风险。

客户端 JavaScript

作为一名 Web 开发者,你可以选择多种语言来编写 Web 服务器代码。但当你的代码需要在浏览器中执行时,你只有一个选择:JavaScript。正如我之前提到的,JavaScript 作为服务器端语言的流行部分归功于 Web 开发者在客户端编程中对它的熟悉。

浏览器中的 JavaScript 已经远远超越了早期网页中用于简单表单验证逻辑和动画小部件的功能。像 Facebook 这样复杂的网站使用 JavaScript 在用户与页面交互时重新绘制页面区域——例如,当用户点击图标时渲染菜单,或者当他们点击照片时打开对话框。网站还经常在后台事件发生时更新用户界面,例如在别人留言或发布新帖时添加通知标记。

实现这种无需刷新整个页面且不打断用户体验的动态用户界面,需要客户端 JavaScript 在内存中管理大量的状态。已经开发出多个框架来组织内存状态并高效地渲染页面。它们还允许在网站的多个页面中模块化重用 JavaScript 代码,这是当你需要管理数百万行 JavaScript 时的重要设计考虑因素。

其中一个 JavaScript 框架是 Angular,最初由 Google 以开源许可证发布。Angular 借鉴了服务器端的范式,并使用客户端模板来渲染网页。Angular 模板引擎——在页面加载时执行——解析服务器提供的模板 HTML,并处理出现的任何指令。由于模板引擎本质上是在浏览器中执行的 JavaScript,它可以直接写入 DOM,并绕过浏览器渲染管道的一些步骤。随着内存状态的变化,Angular 会自动重新渲染 DOM。这种分离方式使代码更加简洁,Web 应用程序也更易于维护。

由 Facebook 开发团队发布的开源 React 框架,采取了与 Angular 略有不同的方法。React 鼓励开发者将类似 HTML 的标签直接写入 JavaScript,而不是将代码插入到 HTML 模板中。React 开发者通常创建 JavaScript XML (JSX) 文件,并通过预处理器将其编译成 JavaScript,然后再发送到浏览器中。

初次编写像 return <h1>Hello, {format(user)}</h1> 这样的 JavaScript 代码时,对于习惯将 JavaScript 和 HTML 文件分开的开发者来说,可能会觉得很奇怪,但通过将 HTML 作为 JavaScript 语法的第一类元素,React 实现了一些有用的功能(例如,语法高亮和代码补全),这些功能否则会很难支持。

富有表现力的客户端 JavaScript 框架,如 Angular 和 React,适用于构建和维护复杂的网站。然而,直接操作 DOM 的 JavaScript 代码容易受到一种新的安全漏洞的影响:基于 DOM 的跨站脚本攻击,关于这一点我们将在第七章中详细探讨。

请注意,尽管 JavaScript 是浏览器通常执行的唯一语言,但这并不意味着你必须把所有客户端代码都写成 JavaScript。许多开发者使用像 CoffeeScript 或 TypeScript 这样的语言,这些语言在构建过程中会被转换成 JavaScript,然后再发送到浏览器。这些语言在执行时会受到与 JavaScript 相同的安全漏洞的影响,所以在本书中,我会主要讨论纯粹的 JavaScript。

总结

Web 服务器在响应 HTTP 请求时提供两种类型的内容:静态资源,如图片,以及动态资源,后者执行自定义代码。

静态资源是我们可以直接从文件系统或内容分发网络提供的资源,用来提高网站的响应速度。网站所有者通常使用内容管理系统(CMS)编写完全由静态资源构成的网站,这允许非技术管理员在浏览器中直接编辑它们。

动态资源是我们通常以模板的形式定义的资源,HTML 中夹杂了需要由服务器解释的编程指令。它们通常从数据库或缓存中读取数据,以决定页面的渲染方式。最常见的数据库类型是 SQL 数据库,它以表格形式存储数据,并对数据的结构有严格的定义规则。大型网站通常使用 NoSQL 数据库,这是一种较新的数据库类型,放宽了传统 SQL 数据库的一些约束,以实现更大的可扩展性。我们用一种 Web 编程语言编写动态资源,而这类语言有很多种。

在下一章,你将会学习到编写代码的过程。编写安全、无 bug 的代码的关键在于有条理的开发流程;我将向你展示你应该如何编写、测试、构建和部署代码。

第五章:程序员如何工作

image

构建和维护一个网站是一个迭代过程,而非最终目标。很少有网页开发人员能够在第一次构建时就完全做好所有功能。(除非你是我的朋友 Dave;别再让我们看起来像个失败者,Dave。)在网页开发中,产品会不断发展,代码库变得越来越复杂,要求开发人员不断添加新功能、修复 BUG 并重构代码。重新设计是必然发生的事情。

作为一名网页开发人员,你需要以有序且规范的方式对代码库进行更改和发布。由于在面对截止日期时采取了快捷方式,安全漏洞和 BUG 常常随着时间的推移悄然出现。大多数安全漏洞的产生,并非因为缺乏开发知识,而是因为忽视了细节。

本章重点讲解如何正确编写安全的代码,通过遵循软件开发生命周期(SDLC)这一术语来描述开发团队在设计新网站功能、编写代码、测试以及发布更改时所遵循的过程。一个混乱且杂乱无章的 SDLC 将无法追踪你运行的代码及其漏洞,最终导致一个存在 Bug 和不安全的网站。然而,一个结构合理的 SDLC 能够帮助你在早期阶段就根除 Bug 和漏洞,从而保护你的最终产品免受攻击。

我们将介绍 SDLC 的五个阶段:设计与分析、编写代码、预发布测试、发布过程以及发布后的测试和观察。我们还将简要讨论如何保护依赖项,即我们在网站中使用的第三方软件。

阶段 1:设计与分析

SDLC(软件开发生命周期)并不是从编写代码开始的;它是从思考你应该编写什么样的代码开始的。我们将这一初始阶段称为设计与分析阶段:你需要分析要添加的功能,并设计其实现方式。在项目开始时,这可能仅仅是简要的设计目标草图。但当你的网站上线并运行时,你需要对更改进行更多的深思熟虑,因为你不想破坏现有用户的功能。

该阶段最重要的目标是识别代码试图解决的需求。一旦开发团队完成了代码,所有人应该能够判断新的代码更改是否正确地解决了这些需求。如果你是在为客户编写代码,这一阶段意味着与利益相关者会面,并让他们达成一致的目标清单。而对于公司或组织内部开发,主要是开发并记录你正在构建的内容的共享愿景。

问题追踪软件在设计和分析中非常有帮助,尤其是在你诊断和修复现有网站的 bug 时。(问题追踪器因此也被称为bug 追踪器。)问题追踪器将每个开发目标描述为问题——例如“构建客户结账页面”或“修复首页的拼写错误”。这些问题会被分配给具体的开发人员,开发人员可以根据优先级对问题进行排序,编写代码修复问题,并标记为完成。开发人员还可以将特定的代码更改与问题关联,目的是修复 bug 或添加描述在问题中的功能。对于大团队来说,经理可以使用项目管理软件对问题进行调度,以便于报告。

在写代码之前,你应该在纸上花费多少时间来解决问题是有差异的。为固件设备或核反应堆等关键系统编写软件的团队不出所料会在设计阶段花费大量时间,因为他们很少有机会在部署后修复代码。而网页开发人员则通常会更快地推进进度。

第二阶段:编写代码

完成设计和分析后,你可以进入软件开发生命周期(SDLC)的第二阶段:编写代码。你可以使用许多工具来编写代码,但应该始终将任何非一次性脚本的代码保存在版本控制软件(也称为源代码管理)中,这可以让你存储代码库的备份副本、浏览代码库的历史版本、跟踪更改,并注释你所做的代码更改。你可以通过将代码更改推送到代码仓库,通常通过命令行工具或其他开发工具的插件,来与团队的其他成员共享更改,然后再将它们发布到世界上。将代码更改推送到集中式仓库,使其他团队成员可以审查这些更改。发布更改意味着将它们部署到你的生产网站——也就是你的真实用户将看到的网站。

使用版本控制还允许你浏览当前在生产网站上运行的代码版本,这对于诊断漏洞以及调查和解决发布后发现的安全问题至关重要。当开发团队识别并解决安全问题时,他们应该检查引入漏洞的代码更改,查看这些更改是否影响了网站的其他部分。

版本控制是所有开发团队都需要使用的首要工具。(即使是只有一个开发人员的团队!)大公司通常会运行自己的版本控制服务器,而小公司和开源开发者则通常使用第三方托管服务。

分布式版本控制 vs. 集中式版本控制

存在多种源代码管理软件,每种软件的语法和功能不同。目前最流行的工具是 Git,它最初由 Linux 创始人林纳斯·托瓦兹(Linus Torvalds)创建,旨在帮助组织 Linux 内核的开发。Git 是一个分布式版本控制系统,这意味着在 Git 管理下的每一个代码副本都是一个完整的代码库。当一个新开发者第一次从团队代码库中拉取(下载)代码的本地副本时,他们不仅获得了代码库的最新版本,还得到了代码库的完整历史记录。

分布式源代码管理工具会跟踪开发者所做的更改,并在开发者推送代码时仅传输这些更改。这种源代码管理模型与旧的软件有所不同,后者实施了一个集中式服务器,开发者从中下载并向其上传整个文件。

Git 的流行,部分原因是GitHub,一个使得设置在线 Git 代码库并邀请团队成员变得简单的网站。用户可以在浏览器中查看存储在 GitHub 中的代码,并且可以轻松地用 Markdown 语言对其进行文档化。GitHub 还包括自己的问题跟踪器和管理竞争代码更改的工具。

分支与合并代码

源代码管理软件允许你精确控制每次更新网站时推送的代码更改。通常,代码发布是通过分支来管理的。一个分支是代码库的逻辑副本,可以存储在源代码控制服务器或开发者的本地代码库中。开发者可以在自己的分支上做本地更改,而不影响代码库,然后在完成他们正在开发的功能或修复的 bug 后,合并该分支回主代码库。

注意

较大的开发团队可能会有更复杂的分支方案。源代码管理软件允许你从分支中创建更多的分支,因为分支操作是廉价的。一个大团队可能会有多个开发者为同一个功能分支贡献代码,以进行复杂的代码更新。

在发布之前,多个开发者可能会将不同的分支合并到主代码库中。如果他们对同一个文件做了不同的编辑,源代码管理软件会自动尝试合并这些更改。如果不同的更改无法自动合并,就会发生合并冲突,这时开发团队需要手动完成合并过程,一行一行地选择如何应用相互竞争的代码更改。解决合并冲突是开发者的痛苦来源:这是一项额外的工作,通常发生在你以为已经解决了一个问题之后。而通常情况下,这个问题是因为 Dave 决定在几千个 Python 文件中修改格式。(谢谢你,Dave。)

合并时机是进行代码审查的绝佳时机,在这期间一个或多个团队成员会检查代码更改并提供反馈。一个很好的方式来发现潜在的安全漏洞是遵循四眼原则,即要求两个人在发布之前查看每个代码更改。通常,一个新视角的审查者可以看到原作者没有预料到的问题。(独眼巨人是糟糕的编码者,因此建议你对他们的审查进行双重确认。)

基于 Git 的工具可以通过使用拉取请求来规范代码审查。拉取请求是开发者请求将代码合并到主代码库中的请求,这允许像 GitHub 这样的工具在合并发生之前确保其他开发者批准更改。(源代码管理软件通常会使拉取请求的批准取决于所有测试在持续集成系统中通过,这一点我们将在下一节讨论。)

阶段 3:发布前测试

SDLC 的第三个阶段是测试。只有在彻底测试并捕获所有潜在的错误,确保代码正确运行后,你才应该发布代码。一个好的测试策略是捕获软件缺陷,尤其是安全漏洞的关键,它能确保用户体验之前或黑客利用漏洞之前发现问题。任何进行代码更改的人都应该在合并或发布代码之前手动测试网站功能。这是你应当对团队所有成员期待的基本尽责行为。

在开发生命周期的早期捕获软件缺陷可以节省大量时间和精力,因此你应该用单元测试来补充手动测试。单元测试是代码库中的小脚本,通过执行代码库的不同部分并测试输出,来进行基本的验证。你应该在构建过程中运行单元测试,并为代码中特别敏感或频繁更改的部分编写单元测试。

保持单元测试简单,确保它们只测试代码中的独立功能。过于复杂的单元测试,一次性测试多个功能模块,往往脆弱,在代码变动时容易崩溃。一个好的单元测试,例如,可能会验证只有经过身份验证的用户才能访问网站的特定区域,或者密码必须满足最低复杂性要求。良好的单元测试同时也充当文档的角色,展示了代码在正确实现时应如何操作。

覆盖率与持续集成

当你运行单元测试时,它会调用你主代码库中的函数。当你运行所有单元测试时,它们执行的代码库百分比被称为你的覆盖率。虽然追求 100%的测试覆盖率值得称赞,但通常是不切实际的,因此在选择代码库中要编写单元测试的部分时要小心。(此外,完全的测试覆盖率并不能保证代码的正确性;仅仅因为每条代码路径都被执行了,并不意味着所有场景都已覆盖。)编写好的单元测试是一种判断力的体现,应该是更大风险评估策略的一部分。一个好的经验法则是:当你发现一个 bug 时,先编写一个单元测试来验证正确的行为,然后再修复 bug。这可以防止问题的再次发生。

一旦你有足够的测试覆盖率,你应该设置一个持续集成服务器。一个持续集成服务器连接到你的源代码管理库,每当代码发生变化时,它会检查出最新版本的代码,并在执行单元测试的同时运行构建过程。如果构建过程失败——可能是因为单元测试开始失败——你的开发团队会收到警报。持续集成确保你能早期发现软件缺陷并及时解决。

测试环境

一旦你完成了发布的所有代码更改,你应该将它们部署到测试环境中进行最终测试。一个测试环境(通常称为预生产环境预发布环境质量保证环境)应是一个完全可操作的网站副本,运行在专用服务器上。测试环境对于在发布之前发现软件缺陷(如安全漏洞)至关重要。大型开发团队通常会雇佣专门从事此类环境下软件测试的质量保证(QA)人员。如果你正在将不同的代码更改集成在一起,这有时被称为集成测试

一个好的测试环境应尽可能与生产环境相似,以确保测试结果具有意义。你应该在相同的服务器和数据库技术上运行你的测试环境,唯一的区别应该是配置和运行的代码版本。(你仍然需要运用常识。例如,测试环境不应能够向真实用户发送电子邮件,因此根据需要对测试环境施加故意的限制。)

这个过程类似于一个戏剧演出团队在第一次面对真实观众之前进行彩排。他们在一个小型测试观众前,穿着全套戏服演出。这让他们能够在一个低风险的环境中解决演出中的最后一些问题,其中每个细节都尽可能地与真实的首演表现相似。

测试环境是安全发布的关键部分,但如果管理不当,它们本身也会带来安全风险。测试和生产环境需要在网络层面上适当隔离,意味着两个环境之间的通信是不可能的。你不能让攻击者通过允许他们从不安全的测试环境跳跃到生产环境,进而妥协你的网站。

测试环境通常有自己的数据库,这需要看起来真实的测试数据,以便全面测试网站的功能。生成良好测试数据的常见方法是从生产系统中复制数据。如果你这样做,要特别小心清洗这类数据复制,去除敏感信息,包括姓名、支付详情和密码。近年来,许多高调的数据泄露事件都是由于攻击者在测试环境中发现未完全清洗的数据而引发的。

阶段 4:发布过程

如果你编写的网站代码从未推送出去,那么它就没有多大意义,因此我们来讨论 SDLC 的第四阶段:发布过程。网站的发布过程涉及从源代码管理中提取代码,将其复制到 Web 服务器上,并(通常)重启 Web 服务器进程。实现这一过程的方式取决于你托管网站的方式以及所使用的技术。无论你采用什么方法,发布过程都需要可靠、可复现且可回滚。

可靠的发布过程意味着你可以确保在发布过程中部署的代码、依赖项、资源和配置文件是什么。如果发布过程不可靠,你可能并没有运行你认为正在运行的代码版本,这会构成严重的安全风险。为了确保网站能够可靠地部署文件,发布脚本通常会使用校验和—数字“指纹”,确保复制到服务器上的文件与源代码管理中的文件完全一致。

可复现的发布过程是指你可以在不同的环境中或使用不同版本的代码时重新运行,并且得到相同的结果。可复现性意味着在发布过程中减少人为错误的空间。如果发布过程需要管理员按正确的顺序完美地执行 24 个步骤,你可以预见到他们会犯错误。尽可能编写脚本并自动化发布过程!可复现的过程对于设置良好的测试环境也至关重要。

可回退的发布流程允许你回滚发布。有时,意外的突发情况会使你想要“撤销”最近的发布,并恢复到之前的版本。这个过程应该尽可能无缝。部分回滚的代码是灾难的根源,因为可能会留下不安全的配置或具有已知漏洞的软件依赖项。无论你选择什么发布流程,都需要能够可靠地回退到先前的代码版本,且操作尽量简便。

发布期间标准化部署的选项

托管公司发明了平台即服务(PaaS)解决方案,使得发布代码变得既简单又可靠。如果“云端”指的是在他人服务器上运行代码,那么使用“即服务”提供的解决方案指的是在他人服务器上运行代码,同时配备一些有用的自动化和管理网站。(托管公司在发明可怕的营销缩略语方面有着悠久的历史。)

Microsoft Azure、Amazon Web Services Elastic Beanstalk、Google App Engine 和 Heroku 都是 PaaS 提供商,允许开发者通过单一命令行调用发布代码。该平台几乎负责发布过程中所需的所有其他操作:设置虚拟化服务器、安装操作系统和虚拟机、运行构建过程(稍后详细介绍)、加载依赖项、将代码部署到磁盘以及重启 Web 服务器进程。你可以通过 Web 控制台或命令行监控和回滚发布,平台还会执行各种安全检查,以确保代码能顺利部署。使用基于 PaaS 的发布过程可以最小化站点的停机时间,确保代码的干净部署,并生成完整的审计日志。

PaaS 解决方案会施加一些限制。为了提供这种便利性和可靠性,它们只支持某些编程语言和操作系统。它们允许有限的服务器配置,并且不支持复杂的网络布局。因此,有时将传统应用程序调整为在这种平台上部署可能会遇到困难。

基础设施即服务与 DevOps

如果你没有使用 PaaS,因为你的应用程序太复杂、太陈旧,或者成本过于高昂,通常会将代码部署到单独的服务器上。这些服务器可能是自托管的、托管在数据中心的,或者托管在基础设施即服务(IaaS)解决方案中,如 Amazon Elastic Compute Cloud (EC2)。在这种情况下,你需要负责编写自己的发布流程。

历史上,公司通常雇佣专职的系统管理员团队来设计和执行发布过程。然而,随着 DevOps(即 开发运维)工具的兴起,这些职责变得模糊,开发者也因此获得了更多的控制权来管理代码的部署方式。DevOps 工具(如 Puppet、Chef 和 Ansible 等具有富有表现力名字的工具)使得描述标准化的部署场景和模块化的发布脚本变得简单,从而赋予开发团队设计自己部署策略的能力。这种方法通常比编写自定义发布脚本来下载和复制文件到服务器上要可靠得多。DevOps 工具使得遵循最佳实践变得容易,因为大多数部署场景都有现成的“食谱”或脚本可供使用。

容器化

另一种标准化部署的方法是使用容器化。容器化 技术如 Docker 允许你创建被称为 镜像 的配置脚本,这些脚本描述了服务器应该使用的操作系统、磁盘布局和第三方软件,以及应该在软件堆栈上部署的 Web 应用程序。你将镜像部署到 容器 中,容器抽象了底层操作系统的各种功能,以实现一致的部署;发布所需的一切都在镜像中进行描述,而容器则是一个完全通用的组件。

你可以以可重复的方式将 Docker 镜像部署到真实或虚拟化的服务器上,从而确保发布过程的可靠性。开发者在本地测试代码时,可以使用与生产环境完全相同的 Docker 镜像,这样在代码正式发布时就能减少意外情况。

容器化是一项相对较新的技术,但它有望使复杂应用的部署更加可靠和标准化。一系列相关技术(例如 Docker Swarm 和 Kubernetes)使得可以通过机器可读的配置文件描述复杂的多服务器网络配置,这使得重新构建整个环境变得更加简单。举个例子,一个团队可以轻松启动一个全新的测试环境,其中包含多个 Web 服务器和一个数据库,因为这些单独的服务以及它们之间的通信方式都会在配置文件中描述,而该文件是托管服务可以理解的。

构建过程

大多数代码库都有一个 构建过程,通常通过命令行或开发工具触发,该过程将静态代码准备好以便部署。像 Java 和 C# 这样的语言在构建过程中将源代码编译成可部署的二进制格式,而使用包管理器的语言在执行构建过程时会下载并验证第三方代码,也就是所谓的 依赖项

网站的构建过程通常会对客户端资源进行预处理,以便部署。许多开发者使用如 TypeScript 和 CoffeeScript 等语言,这些语言需要通过构建过程编译成 JavaScript。无论 JavaScript 是手写的还是生成的,构建过程通常会对 JavaScript 文件进行 压缩 或混淆,以生成一个压缩的、不可读性更高但功能上等效的 JavaScript 文件,从而使其在浏览器中加载更快。

网站的样式信息通常保存在 CSS 文件中,如 第三章 所述。管理大型网站的 CSS 文件可能是一项繁重的工作(因为样式信息通常在不同地方重复,并需要同步更新)。Web 开发人员通常使用 CSS 预处理器,例如 Sass 和 SCSS——这些语言旨在使样式表更易于管理,并需要在构建时将其预处理为 CSS 文件。

每种编程语言都有一个首选的构建工具,您的开发团队应该熟练掌握这个工具。您应该在将代码提交到源代码管理之前,在本地运行构建过程,这样您可以确保在发布过程中重新运行时构建过程能够正常工作。如前所述,使用持续集成服务器来确保这一过程。

数据库迁移脚本

为网站添加新功能通常需要新的数据库表格或现有表格的更新。数据库存储需要在发布之间保持持久的数据,因此您不能每次发布时都简单地清空并安装一个新的数据库。您需要创建并运行数据库 迁移脚本,以便在部署代码之前更新数据库结构;如果回滚代码,还需要撤销这些脚本。

一些技术(例如,Ruby on Rails)允许您将迁移脚本作为构建过程的一部分运行。如果您不能在构建过程中运行它们,您应该将脚本保存在源代码管理中,然后在发布窗口期间通过临时提升权限在数据库上运行它们。在一些公司,尤其是大型和复杂的数据库中,通常会有专门的 数据库管理员(DBA) 来管理此过程,并且他们通常不情愿地充当其所钟爱的数据库存储的守门人。

如果员工能够在发布之外更改数据库结构,那就是一种安全隐患。我们将在 第十一章 中讨论各种方法来锁定权限。

阶段 5:发布后测试与观察

一旦你部署了代码,你应该进行发布后测试,以确保代码正确部署,并验证你关于代码在生产环境中执行方式的假设是否正确。理论上,如果你有一个良好的测试环境和可靠的发布流程,这种发布后测试(通常称为冒烟测试)可以相当简单。不过,做每个阶段的测试时,最好关注直觉并避免冒险。有人说过,“继续测试,直到恐惧变成无聊。”这句话准确地表达了这一适当的心态。

渗透测试

安全专家和道德黑客经常进行渗透测试,它通过外部探测网站来检测安全漏洞。渗透测试对于发布前和发布后的测试都非常有用。此外,开发团队还可以使用复杂的自动化渗透测试工具,通过分析各种 URL 并尝试构造恶意 HTTP 请求来测试网站的常见安全漏洞。渗透测试可能非常昂贵且耗时,但它比被黑客攻击要便宜得多,便宜得多,因此强烈建议将其加入你的测试流程中。

监控、日志记录和错误报告

一旦你发布了代码,生产环境需要在运行时可观测。这有助于管理员发现异常或潜在的恶意行为,并在问题发生时进行诊断。发布后观察应通过三项活动进行:日志记录、监控和错误报告。

日志记录,即让代码在软件应用执行操作时写入日志文件,帮助管理员查看在任何特定时刻网站服务器的运行情况。你的代码应记录每个 HTTP 请求(包括时间戳、URL 和 HTTP 响应码),以及用户(例如,身份验证和密码重置请求)和站点本身(例如,发送电子邮件和调用 API)执行的重要操作。

你应该在运行时让管理员能够访问日志(通过命令行或 Web 控制台),并将其归档以供后续查看(以防需要进行事后分析)。在代码中添加日志语句有助于诊断网站上发生的问题,但要小心,不要将敏感信息(如密码和信用卡信息)写入日志,以防万一攻击者成功获取了日志。

监控是在网站运行时测量响应时间和其他指标的实践。监控你的 Web 服务器和数据库有助于管理员发现高负载场景或性能下降,并在网络速度变慢或数据库查询时间过长时发出警报。你应该将 HTTP 和数据库响应时间传递到监控软件中,这样当服务器和数据库响应时间超过某个阈值时,它会发出警报。许多云平台内置了监控软件,因此请花时间配置好错误条件和警报系统。

你应该使用错误报告来捕获并记录代码中的意外错误。你可以通过从日志中提取或在代码中捕获并记录它们来建立错误条件。然后,你可以将这些错误条件汇总到一个数据存储中,供管理员使用。许多安全入侵都会利用错误条件处理不当的漏洞,所以务必注意发生的意外错误。

第三方服务,如 Rollbar 和 Airbrake,提供插件,可以让你用几行代码收集错误信息。因此,如果你没有时间或兴趣自己设置错误报告系统,可以考虑使用这些服务。或者,像 Splunk 这样的日志抓取工具可以帮助你从日志文件中提取错误并理解它们。

依赖管理

你需要考虑的一个方面是依赖管理。现代 Web 开发的一个有趣事实是,你编写的代码可能只是你网站运行代码的少数部分。你的网站通常依赖于操作系统代码、编程语言运行时及其相关库,可能还有虚拟机,以及运行第三方代码库的 Web 服务器进程。所有这些你必须依赖的第三方工具被称为依赖项。(换句话说,你的软件运行所依赖的软件。)

每一个依赖项都是该领域的专家编写的,节省了你自己编写内存管理或低级 TCP 语义的负担。这些专家也有强烈的动力去保持对安全漏洞的关注,并在问题出现时发布补丁,所以你应该利用他们提供的资源!

使用他人的代码需要你自己付出努力。一个安全的 SDLC(软件开发生命周期)应该包括一个审查第三方库的过程,并确定何时需要应用补丁。这通常需要在常规开发周期之外进行,因为黑客不会等到你下次预定的发布日期才开始尝试利用安全漏洞。保持领先于安全公告并为他人的代码部署补丁和确保团队编写的代码一样重要。我们将在第十四章中讨论如何做到这一点。

总结

在本章中,您了解到了一个良好结构化的软件开发生命周期可以帮助您避免程序错误和软件漏洞。

  • 您应当使用问题追踪软件来记录设计目标。

  • 您应当将代码保存在源代码控制中,以便随时检查旧版本代码,并且便于组织代码审查。

  • 在发布之前,您应当在一个专门的、隔离的测试环境中测试代码,该环境应当与生产环境相似,并且对您的数据给予最大程度的保护。

  • 您应当拥有一个可靠、可重现且可回滚的发布流程。如果您有一个脚本化的构建过程来生成准备部署的资源,您应当定期运行它,并在持续集成环境中进行单元测试,以便及早发现开发生命周期中的潜在问题。

  • 在发布之后,您应当使用渗透测试来检测网站漏洞,防止黑客利用这些漏洞。您还应当使用监控、日志记录和错误报告来检测和诊断运行中的站点问题。

  • 您应当跟进您使用的任何第三方代码的安全建议,因为您可能需要在常规发布周期之外部署补丁。

在下一章中,您将(终于!)开始研究特定的软件漏洞以及如何防范这些漏洞。您将从一个网站面临的最大威胁开始:恶意输入,旨在将代码注入到您的网络服务器中。

第二部分

威胁

第六章:注入攻击

image

现在你已经掌握了互联网的基本工作原理,接下来我们将重点讨论特定的漏洞和黑客利用这些漏洞的方式。本章介绍的是注入攻击,即攻击者将外部代码注入应用程序,试图控制应用程序或读取敏感数据。

记住,互联网是一个客户端-服务器架构的例子,这意味着一个网页服务器同时处理来自多个客户端的连接。大多数客户端是网页浏览器,负责在用户浏览网站时生成 HTTP 请求并发送给网页服务器。网页服务器则返回包含 HTML 的 HTTP 响应,这些 HTML 构成了网站页面的内容。

由于网页服务器控制着网站的内容,服务器端代码自然期望发生特定类型的用户交互,因此期望浏览器生成特定类型的 HTTP 请求。例如,服务器期望每次用户点击链接时看到一个GET请求指向一个新 URL,或者在用户输入登录凭证并点击提交时看到一个POST请求。

然而,浏览器完全有可能生成意外的 HTTP 请求发送给服务器。此外,网页服务器也乐于接受来自任何类型客户端的 HTTP 请求,而不仅仅是浏览器。配备 HTTP 客户端库的程序员可以编写脚本,向互联网上的任意 URL 发送请求。我们在第一章中回顾的黑客工具就是这样做的。

服务器端代码无法可靠地判断是脚本还是浏览器生成了 HTTP 请求,因为无论客户端如何,HTTP 请求的内容都是无法区分的。服务器能做的最好的事情就是检查User-Agent头部,它应该描述生成请求的代理类型,但脚本和黑客工具通常会伪造这个头部的内容,使其与浏览器发送的请求相匹配。

了解了这些,黑客攻击网站时通常会在 HTTP 请求中传递恶意代码,以欺骗服务器执行这些代码。这就是对网站进行注入攻击的基础。

注入攻击在互联网上极为常见,如果成功,可能会造成毁灭性的影响。作为一名网页开发者,你需要了解它们可能发生的所有方式以及如何防范它们。在编写网站代码时,考虑 HTTP 请求中可能会出现的内容,而不仅仅是你期望出现的内容非常重要。本章将探讨四种类型的注入攻击:SQL 注入攻击、命令注入攻击、远程代码执行攻击和利用文件上传漏洞的攻击。

SQL 注入

SQL 注入 攻击针对使用底层 SQL 数据库的网站,这些网站以不安全的方式构建数据查询。SQL 注入攻击对网站构成了最大的风险之一,因为 SQL 数据库非常普遍。这一点在 2008 年得到了证明,当时黑客从 Heartland Payment Systems 偷取了 1.3 亿张信用卡号码。Heartland Payment Systems 是一家存储信用卡信息并为商户处理支付的支付处理公司。黑客通过 SQL 注入攻击访问了处理支付数据的 Web 服务器,这对依赖信息安全保障进行业务的公司来说是一次灾难。

让我们首先回顾一下 SQL 数据库是如何工作的,这样我们就能深入理解 SQL 注入是如何发生的,以及如何阻止它。

什么是 SQL?

结构化查询语言,或称SQL,用于从关系型数据库中提取数据和数据结构。关系型数据库将数据存储在表格中;表中的每一行是一个数据项(例如,用户或正在销售的产品)。SQL 语法允许像 Web 服务器这样的应用程序通过 INSERT 语句将行添加到数据库中,通过 SELECT 语句读取行,通过 UPDATE 语句更新行,通过 DELETE 语句删除行。

考虑一下当你在网站上注册时,Web 服务器在后台可能执行的 SQL 语句,如清单 6-1 所示。

❶ INSERT INTO users (email, encrypted_password)
   VALUES ('billy@gmail.com', '$10$WMT9Y')
❷ SELECT * FROM users WHERE email = 'billy@gmail.com'
   AND encrypted_password = '$10$WMT9Y'
❸ UPDATE USERS users encrypted_password ='3D$MW$10Z'
   WHERE email='billy@gmail.com'
❹ DELETE FROM users WHERE email = 'billy@gmail.com'

清单 6-1:用户与网站交互时 Web 服务器可能执行的典型 SQL 语句

SQL 数据库通常在 users 表中存储关于网站用户的信息。当用户首次注册并选择用户名和密码时,Web 服务器会在数据库上执行 INSERT 语句,以在 users 表中创建一行 ❶。下次用户登录网站时,Web 服务器会执行 SELECT 语句,尝试在 users 表中查找对应的行 ❷。如果用户更改密码,Web 服务器会执行 UPDATE 语句,更新 users 表中相应的行 ❸。最后,如果用户关闭账户,网站可能会执行 DELETE 语句,将其行从 users 表中删除 ❹。

对于每一次交互,Web 服务器负责提取 HTTP 请求中的一部分(例如,登录表单中输入的用户名和密码),并构建一个 SQL 语句来对数据库执行操作。该语句的实际执行通过数据库驱动程序完成,这是一个专门用于与数据库通信的代码库。

SQL 注入攻击的结构

SQL 注入攻击发生在 Web 服务器以不安全的方式构建它传递给数据库驱动程序的 SQL 语句时。这允许攻击者通过 HTTP 请求传递参数,从而导致驱动程序执行开发者未打算进行的操作。

让我们看看一个不安全构造的 SQL 语句,它会在用户尝试登录网站时从数据库读取用户数据,如 列表 6-2 中的 Java 代码所示。

Connection connection = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
Statement statement = connection.createStatement();
String sql = "SELECT * FROM users WHERE email='" + email +
             "' AND encrypted_password='" + password + "'";
statement.executeQuery(sql);

列表 6-2:在登录尝试期间从数据库读取用户数据的不安全方法

这个 SQL 语句的构造不安全!这个片段使用从 HTTP 请求中获取的 emailpassword 参数,并将它们直接插入到 SQL 语句中。因为这些参数没有检查 SQL 控制字符(例如 '),这些字符会改变 SQL 语句的含义,黑客可以构造输入绕过网站的身份验证系统。

列表 6-3 显示了一个示例。在这个示例中,攻击者将用户 email 参数传递为 billy@gmail.com'--,这会提前终止 SQL 语句,并导致密码检查逻辑不被执行:

statement.executeQuery(
  "SELECT * FROM users WHERE email='billy@gmail.com'❶--' AND encrypted_password='Z$DSA92H0'❷");

列表 6-3:使用 SQL 注入绕过身份验证

数据库驱动程序只执行 SQL 语句❶,并忽略之后的所有内容❷。在这种 SQL 注入攻击中,单引号字符 (') 提前关闭了电子邮件参数,而 SQL 注释语法 (--) 让数据库驱动程序忽略执行密码检查的语句的结尾。这个 SQL 语句允许攻击者以 任何 用户的身份登录,而无需知道他们的密码!攻击者只需要在登录表单中的用户电子邮件地址添加 '-- 字符。

这是一个相对简单的 SQL 注入攻击示例。更高级的攻击可能会导致数据库驱动程序在数据库上执行额外的命令。列表 6-4 显示了一个 SQL 注入攻击,它执行 DROP 命令,完全删除 users 表,从而破坏数据库。

statement.executeQuery("SELECT * FROM users WHERE email='billy@gmail.com';❶
DROP TABLE users;❷--' AND encrypted_password='Z$DSA92H0'");

列表 6-4:SQL 注入攻击正在进行中

在这种情况下,攻击者将电子邮件参数传递为 billy@gmail.com'; DROP TABLE users;--。分号字符(;)终止了第一个 SQL 语句❶,然后攻击者插入了一个附加的破坏性语句❷。数据库驱动程序会执行这两个语句,导致你的数据库处于损坏状态!

如果你的网站容易受到 SQL 注入攻击,攻击者通常可以对你的数据库执行任意的 SQL 语句,从而绕过身份验证;随意读取、下载和删除数据;甚至将恶意的 JavaScript 注入到呈现给用户的页面中。为了扫描网站的 SQL 注入漏洞,可以使用像 Metasploit 这样的黑客工具来爬取网站并测试 HTTP 参数中的潜在漏洞。如果你的网站易受 SQL 注入攻击,可以确定最终会有人利用这一漏洞。

缓解措施 1:使用参数化语句

为了防止 SQL 注入攻击,你的代码需要使用绑定参数构建 SQL 字符串。绑定参数 是占位符字符,数据库驱动程序将安全地将其替换为一些提供的输入——如 列表 6-1 中显示的电子邮件或密码值。包含绑定参数的 SQL 语句称为 参数化语句

SQL 注入攻击利用在 SQL 语句中具有特殊意义的“控制字符”来“跳出”上下文并改变 SQL 语句的整体语义。当你使用绑定参数时,这些控制字符会被“转义字符”前缀标记,告诉数据库不要将后续字符视为控制字符。这种转义控制字符的方式有效地化解了潜在的注入攻击。

使用绑定参数安全构造的 SQL 语句应该像 列表 6-5 中所示。

   Connection connection = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
   Statement statement = connection.createStatement();
❶ String sql = "SELECT * FROM users WHERE email = ? and encrypted_password = ?";
❷ statement.executeQuery(sql, email, password);

列表 6-5:使用绑定参数防止 SQL 注入

这段代码使用 ? 作为绑定参数 ❶ 以参数化形式构造 SQL 查询。然后,代码 绑定 每个参数的输入值到语句 ❷,请求数据库驱动程序将参数值插入 SQL 语句,同时安全地处理任何控制字符。如果攻击者尝试使用 列表 6-4 中概述的方法,通过传入用户名 billy@email.com'-- 来攻击这段代码,你安全构造的 SQL 语句将化解攻击,如 列表 6-6 所示。

statement.executeQuery(
  "SELECT * FROM users WHERE email = ? AND encrypted_password = ?",
  "billy@email.com'--,",
  "Z$DSA92H0");

列表 6-6:SQL 注入攻击已被化解。

因为数据库驱动程序确保不会过早终止 SQL 语句,所以这个 SELECT 语句将安全地返回 没有 用户,攻击应该会失败。参数化语句确保数据库驱动程序将所有控制字符(如 '--;)当作 SQL 语句的 输入,而不是 SQL 语句的一部分。如果你不确定你的网站是否使用了参数化语句,赶紧去检查一下!SQL 注入可能是你的网站面临的最大风险。

类似的注入攻击可能发生在每当 Web 服务器通过在后台的本地语言中构建语句与独立后台通信时。这包括像 MongoDB 和 Apache Cassandra 这样的 NoSQL 数据库,像 Redis 和 Memcached 这样的分布式缓存,以及实现轻量级目录访问协议(LDAP)的目录。与这些平台通信的库有自己实现的绑定参数,因此要确保理解它们的工作原理,并在代码中使用它们。

缓解措施 2:使用对象关系映射

许多 Web 服务器库和框架将 SQL 语句的显式构建封装起来,并允许你通过使用对象关系映射来访问数据对象。对象关系映射(ORM) 库将数据库表中的行映射到内存中的代码对象,这意味着开发者通常不需要编写自己的 SQL 语句来读取和更新数据库。这种架构在大多数情况下能防止 SQL 注入攻击,但如果使用自定义 SQL 语句,它仍然可能会受到攻击,因此了解 ORM 如何在后台工作非常重要。

人们最熟悉的 ORM 可能是 Ruby on Rails 的 ActiveRecord 框架。列表 6-7 显示了一行简单的 Rails 代码,它以安全的方式查找用户。

User.find_by(email: "billy@gmail.com")

列表 6-7:使用受保护的方式通过电子邮件查找用户的 Ruby on Rails 代码

因为 ORM 在后台使用绑定参数,它们在大多数情况下能防止注入攻击。然而,大多数 ORM 也有后门,允许开发者在需要时编写原始 SQL。如果你使用这些类型的功能,需要小心构造 SQL 语句。例如,列表 6-8 显示了易受注入攻击的 Rails 代码。

def find_user(email, password)
  User.where("email = '" + email + "' and encrypted_password = '" + password + "'")
end

列表 6-8:易受注入攻击的 Ruby on Rails 代码

由于这段代码将部分 SQL 语句作为原始字符串传递,攻击者可以传递特殊字符来操控 Rails 生成的 SQL 语句。如果攻击者能将 password 变量设置为 ' OR 1=1,他们就可以运行一条 SQL 语句,禁用密码检查,如 列表 6-9 所示。

SELECT * FROM users WHERE email='billy@gmail.com' AND encrypted_password ='' OR 1=1

列表 6-9:1=1 语句,显然为真,禁用密码检查。

这条 SQL 语句的最后一部分禁用了密码检查,允许攻击者以该用户身份登录。你可以通过使用绑定参数,安全地调用 Rails 中的 where 函数,如 列表 6-10 所示。

def find_user(email, encrypted_password)
  User.where(["email = ? and encrypted_password = ?", email, encrypted_password])
end

列表 6-10:安全使用 where 函数

在这种情况下,ActiveRecord 框架会安全地处理攻击者在 emailpassword 参数中添加的任何 SQL 控制字符。

奖励缓解:使用深度防御

一般来说,你应该始终通过冗余机制来保护你的网站。逐行检查代码中的漏洞并不足够,你需要在每个层级上考虑并执行安全性,使得某一层级的失败可以通过其他策略得到缓解。这种方法被称为 深度防御

想一想你如何保护你的家。最重要的防御措施是给所有门窗安装锁,但拥有入侵警报、监控摄像头、家庭保险,也许还有一只脾气暴躁的大狗,可以帮助你应对各种可能的情况。

在防止 SQL 注入方面,深度防御意味着使用绑定参数,但同时也采取额外措施,以减少攻击者仍然能够成功执行注入攻击的风险。接下来,我们来看一些缓解注入攻击风险的其他方法。

最小权限原则

缓解注入攻击的另一种方法是遵循最小权限原则,要求每个进程和应用程序只能在执行其允许的功能时拥有必要的权限,不能更多。这意味着,如果攻击者将代码注入到你的 Web 服务器并攻击特定的软件组件,他们所能造成的危害仅限于该组件所允许的操作。

如果你的 Web 服务器与数据库交互,请确保它用来登录数据库的帐户对数据的权限是有限的。大多数网站只需要运行属于 SQL 子集的 SQL 语句,即数据操作语言(DML),这包括我们之前讨论过的 SELECTINSERTUPDATEDELETE 语句。

SQL 语言的一个子集称为数据定义语言(DDL),它使用CREATEDROPMODIFY语句来创建、删除和修改数据库中的表结构。Web 服务器通常不需要权限来执行 DDL 语句,因此在运行时不要授予它们 DDL 权限集!将 Web 服务器的权限缩小到最小的 DML 权限集,可以减少攻击者在发现代码漏洞时所能造成的危害。

盲注与非盲注 SQL 注入

黑客区分盲注和非盲注 SQL 注入攻击。如果你的网站错误信息泄露了敏感信息给客户端,例如唯一约束被违反:此电子邮件地址在用户表中已存在,这就是一种非盲注 SQL 攻击。在这种情况下,攻击者会立刻收到反馈,知道他们在尝试攻击时的结果。

如果你将错误信息发送给客户端时更加通用,比如无法找到此用户名和密码发生了意外错误,这就是一种盲注 SQL 攻击。此场景意味着攻击者实际上是在“黑暗”中操作,信息较少,难以攻击。易受非盲注攻击的站点更容易被入侵,因此要避免在错误信息中泄露信息。

命令注入

另一种注入攻击类型是命令注入,攻击者可以利用它来攻击那些向底层操作系统发出不安全命令行调用的网站。如果你的 Web 应用程序进行命令行调用,确保构造命令字符串时采取安全措施。否则,攻击者可以构造 HTTP 请求,执行任意的操作系统命令,并接管你的应用程序。

对于许多编程语言来说,构建命令字符串以调用操作系统实际上是相当不寻常的。例如,Java 在虚拟机中运行,因此,尽管你可以通过使用java.lang.Runtime类调用操作系统,Java 应用程序通常设计为可在不同操作系统之间移植,因此依赖特定操作系统功能的可用性会违背其哲学。

命令行调用在解释型语言中更为常见。PHP 的设计遵循 Unix 哲学——程序应该专注于做一件事,并通过文本流相互通信——因此,PHP 应用程序通常通过命令行调用其他程序。同样,Python 和 Ruby 因其在脚本任务中的流行,简化了在操作系统级别执行命令的过程。

命令注入攻击的结构

如果你的网站使用命令行调用,请确保攻击者无法通过诱使 Web 服务器注入额外命令到执行调用中。例如,假设你有一个简单的网站,它执行nslookup来解析域名和 IP 地址。PHP 代码从 HTTP 请求中获取域名或 IP 地址,并构建如清单 6-11 所示的操作系统调用。

<?php
    if (isset($_GET['domain'])) {
        echo '<pre>';
        $domain = $_GET['domain']❶;
        $lookup = system("nslookup {$domain❷}");
        echo($lookup);
        echo '</pre>';
    }
?>

清单 6-11:接收 HTTP 请求并构建操作系统调用的 PHP 代码

domain参数在❶处从 HTTP 请求中提取。因为代码在构建命令字符串时没有转义domain参数❷,攻击者可以构造一个恶意 URL,并在末尾附加额外的命令,如图 6-1 所示。

image

图 6-1:使用 URL 注入恶意命令

在这里,攻击者发送一个值为google.com && echo "HAXXED"的 domain 参数,浏览器会对空格和非字母数字字符进行 URL 编码。Unix 中的&&语法用于连接多个命令。由于我们的 PHP 代码没有去除这样的控制字符,攻击者精心构造 HTTP 请求以附加额外的命令。在这种情况下,将执行两个独立的命令:预期的nslookup命令查询google.com,然后是注入的命令echo "HAXXED"

在这种情况下,注入的命令是一个无害的echo命令,它仅仅在 HTTP 响应中打印出"HAXXED"。然而,攻击者可以利用这个漏洞在你的服务器上注入并执行他们选择的任何命令。只需稍加努力,他们就可以浏览文件系统、读取敏感信息,并危及整个应用程序。如果你的 Web 服务器允许命令行访问,攻击者将完全掌握系统的控制权,除非你采取有意的步骤来减少这种影响。

缓解措施:转义控制字符

与 SQL 注入类似,通过适当转义来自 HTTP 请求的输入可以防御命令注入。这意味着用安全的替代方案替换敏感的控制字符(例如我们示例中的&字符)。如何实现这取决于您使用的操作系统和编程语言。为了使列表 6-11 中的 PHP 代码更安全,我们只需使用escapeshellarg,如列表 6-12 所示。

<?php
    if (isset($_GET['domain'])) {
        echo '<pre>';
        $domain = escapeshellarg❶($_GET['domain']);
        $lookup = system("nslookup {$domain}");
        echo($lookup);
        echo '</pre>';
    }
?>

列表 6-12:PHP 代码转义 HTTP 请求中的输入

调用escapeshellarg❶确保攻击者无法通过domain参数注入额外的命令。

Python 和 Ruby 也可以防止潜在的命令注入攻击。

在 Python 中,应使用数组而不是字符串调用call()函数,以防止攻击者在末尾附加额外的命令,如列表 6-13 所示。

    from subprocess import call
    call(["nslookup", domain])

列表 6-13:Python subprocess 模块中的 call 函数

在 Ruby 中,system()函数执行命令行调用。请提供一个参数数组而不是字符串,以确保攻击者无法偷偷添加额外的命令,如列表 6-14 所示。

    system("nslookup", domain)

列表 6-14:Ruby 中 system()函数

与 SQL 注入类似,遵循最小权限原则也有助于限制成功命令注入攻击的影响。您的 Web 服务器进程应仅以所需的权限运行。例如,您应限制 Web 服务器进程可以读取和写入的目录。在 Linux 上,您可以使用chroot命令防止进程探索指定的根目录之外的内容。您还应通过配置防火墙和网络访问控制列表来限制 Web 服务器的网络访问。这些步骤将大大增加黑客利用命令注入漏洞的难度,因为即使他们可以执行命令,他们除了读取 Web 服务器运行目录中的文件外,什么都做不了。

远程代码执行

到目前为止,您已经看到在 Web 代码构建对数据库的调用(如 SQL 注入)或运行在操作系统上的调用(如命令注入)时可能存在的漏洞。在其他情况下,攻击者可能会注入恶意代码,以在 Web 服务器本身的语言中执行,这种策略称为远程代码执行。与我们之前讨论过的注入攻击相比,网站上的远程代码执行攻击虽然较少见,但同样危险。

远程代码执行攻击的解剖

攻击者可以通过发现特定类型 Web 服务器中的漏洞,进而创建利用脚本,以针对运行该 Web 服务器技术的网站。利用脚本会在 HTTP 请求的主体中加入恶意代码,并以某种方式对其进行编码,以便服务器在处理请求时读取并执行该代码。执行远程代码的攻击技巧差异很大。安全研究人员会分析常见 Web 服务器的代码库,寻找允许恶意代码注入的漏洞。

2013 年初,研究人员发现了 Ruby on Rails 中的一个漏洞,允许攻击者将自己的 Ruby 代码注入到服务器进程中。由于 Rails 框架会根据请求的Content-Type头自动解析请求,安全研究人员注意到,如果他们创建一个包含嵌入式 YAML 对象的 XML 请求(YAML 是一种常用于 Rails 社区存储配置数据的标记语言),他们可以欺骗解析过程执行任意代码。

缓解措施:禁用反序列化期间的代码执行

远程代码执行漏洞通常发生在 Web 服务器软件使用不安全的序列化时。序列化是将内存中的数据结构转换为二进制数据流的过程,通常用于将数据结构通过网络传递。反序列化是指在另一端发生的相反过程,即将二进制数据转换回数据结构。

序列化库存在于每个主要的编程语言中,并且被广泛使用。一些序列化库,例如 Rails 使用的 YAML 解析器,允许数据结构在重新初始化到内存时执行代码。如果你信任序列化数据的来源,这是一个有用的功能,但如果不信任,这可能会非常危险,因为它可能允许任意代码执行。

如果 Web 服务器使用反序列化来处理来自 HTTP 请求的数据,则需要通过禁用任何代码执行功能来解除任何序列化库的影响;否则,攻击者可能能够找到一种方法,将代码直接注入到 Web 服务器进程中。我们通常可以通过相关的配置设置禁用代码执行,从而让 Web 服务器软件在反序列化数据时不执行代码。

作为使用 Web 服务器构建网站的开发人员,而非编写 Web 服务器代码的开发人员,防止远程代码执行的关键通常是保持对安全公告的关注。你不太可能自己编写序列化库,因此要注意你的代码库中使用了哪些第三方序列化库。确保关闭代码中的主动代码执行功能,并密切关注 Web 服务器供应商发布的漏洞公告。

文件上传漏洞

本章我们将讨论的最后一种注入攻击,利用的是文件上传功能中的漏洞。网站使用文件上传功能有多种目的:允许用户为他们的个人资料或帖子添加图片、为消息添加附件、提交文件、与其他用户共享文档等等。浏览器通过内置的文件上传控件和 JavaScript API,使上传文件变得非常容易,用户可以将文件拖到网页上,并异步发送到服务器。

然而,浏览器在检查文件内容时并不特别谨慎。攻击者可以轻松地通过将恶意代码注入到上传的文件中,滥用文件上传功能。Web 服务器通常将上传的文件视为大型二进制数据块,因此攻击者可以非常轻松地上传恶意载荷,而不被 Web 服务器检测到。即使你的网站有 JavaScript 代码在上传之前检查文件内容,攻击者仍然可以编写脚本,直接将文件数据发送到服务器端的端点,绕过你在客户端设置的任何安全措施。

让我们看看攻击者通常是如何利用文件上传功能的,以便识别我们需要修补的各种安全漏洞。

文件上传攻击的结构

作为文件上传漏洞的一个示例,让我们看看攻击者如何可能滥用你网站的个人头像上传功能。攻击者首先编写一个小型web shell,这是一个简单的可执行脚本,它会从 HTTP 请求中获取一个参数,在命令行中执行,并输出结果。Web shell 是黑客用来尝试攻破 Web 服务器的常见工具。清单 6-15 展示了一个用 PHP 编写的 web shell 示例。

<?php
  if(isset($_REQUEST['cmd'])) {
    $cmd = ($_REQUEST['cmd']);
    system($cmd);
  } else {
    echo "What is your bidding?";
  }
?>

清单 6-15:用 PHP 语言编写的 web shell

攻击者将这个脚本保存为 hack.php 并作为他们的个人资料“图片”上传到你的网站。操作系统通常将 PHP 文件视为可执行文件,这对于让此攻击得以成功至关重要。显然,一个以 .php 结尾的文件并不是一个有效的图片文件,但攻击者可以相当轻松地禁用上传过程中任何 JavaScript 文件类型检查。

一旦攻击者上传了他们的“图片”文件,他们的个人主页将显示一个缺失的图片图标,因为他们的个人头像已经损坏,实际上并不是一张图片。然而,此时他们已经达到了真正的目的:将 web shell 文件偷偷上传到你的服务器上,这意味着他们的恶意代码已经部署到你的网站,等待以某种方式执行。

由于 Web Shell 可通过公共 URL 访问,攻击者可能已创建了一个用于执行恶意代码的后门。如果你的服务器操作系统安装了 PHP 运行时,并且文件在上传过程中以可执行权限写入磁盘,攻击者可以通过调用与其个人资料图片对应的 URL,简单地传递命令来运行 Web Shell。

要执行命令注入攻击,黑客可以将一个 cmd 参数传递给 Web Shell,以在你的服务器上执行任意操作系统命令,如图 6-2 所示。

image

图 6-2:如果你的文件上传功能存在漏洞,黑客可能利用 Web Shell 访问你的数据库凭据。

在这种情况下,攻击者可以探索你的文件系统。攻击者利用你的文件上传功能,获得与命令注入攻击相同的操作系统访问权限。

缓解措施

你可以使用几种缓解措施来保护自己免受文件上传代码漏洞的影响。最重要的缓解措施是确保任何上传的文件无法作为代码执行。遵循深度防御原则,你还应该分析上传的文件,拒绝任何看起来有损坏或恶意的文件。

缓解措施 1:将文件托管在安全的系统上

确保文件上传功能安全的首要方法是确保你的 Web 服务器将上传的文件视为惰性对象,而不是可执行对象。你可以通过将上传的文件托管在内容分发网络(CDN)中,如 Cloudflare 或 Akamai,来实现这一点,正如第四章中所描述的,这样可以将安全负担转移给第三方,由他们安全地存储你的文件。

CDN 也有其他与安全无关的好处。CDN 可以将文件极其快速地传送到浏览器,并且可以在上传时将其通过处理管道处理。许多 CDN 提供复杂的 JavaScript 上传小工具,你只需添加几行代码,就能提供像图片裁剪这样的额外功能。

如果因为某些原因 CDN 不是一个选项,你可以通过将上传的文件存储在云存储(例如 Amazon 简单存储服务或 S3)或专用的内容管理系统中来获得许多相同的好处。这两种方法都提供了安全存储,能够在文件上传时消除所有 Web Shell。(尽管,如果你托管自己的内容管理系统,你必须确保正确配置它。)

缓解措施 2:确保上传的文件无法执行

如果无法使用 CDN 或内容管理系统,你需要采取与 CDN 或内容管理系统在后台执行相同的步骤来保护你的文件。这意味着确保所有文件都写入磁盘时没有可执行权限,将上传的文件分隔到特定的目录或分区(这样它们就不会与代码混合),并且加固你的服务器,只安装最基本的必需软件。(如果你不使用 PHP 引擎,请卸载它!)在上传文件时重命名文件也是个好主意,这样你就不会将带有危险扩展名的文件写入磁盘。

实现这些目标的方法取决于你所使用的托管技术、操作系统以及编程语言。例如,如果你在 Linux 上运行一个 Python Web 服务器,你可以通过使用 os 模块,在创建文件时设置文件权限,如 列表 6-16 所示。

import os
file_descriptor = os.open("/path/to/file", os.O_WRONLY | os.O_CREAT, 0o600)
with os.fdopen(open(file_descriptor, "wb")) as file_handle:
  file_handle.write(...)

列表 6-16:在 Linux 上使用 Python 写入具有读写(但不可执行)权限的文件

从操作系统中移除不需要的软件总是个好主意,因为这会减少黑客使用的工具。互联网安全中心(CIS)提供了预加固的操作系统镜像,作为良好的起点。这些镜像可以作为 Docker 镜像或在 Amazon Web Services Marketplace 中提供的 Amazon 机器镜像(AMI)使用。

缓解措施 3:验证上传文件的内容

如果你上传的是已知文件类型,考虑在代码中添加一些文件类型检查。确保上传的 HTTP 请求中的 Content-Type 头与预期的文件类型相符,但要注意攻击者很容易伪造该头部。

在文件上传后验证文件类型是可能的,特别是对于图像文件,因此在服务器端代码中实现此功能是个好主意,如 列表 6-17 所示。不过,你的实际效果可能会有所不同;过去,一些聪明的黑客通过设计有效的有效载荷来渗透各种系统,这些有效载荷对多种文件格式都是有效的。

>>> import imghdr
>>> imghdr.what('/tmp/what_is_this.dat')
'gif'

列表 6-17:在 Python 中读取文件头部以验证文件格式

缓解措施 4:运行防病毒软件

最后,如果你在容易感染病毒的服务器平台上运行(你好,Microsoft Windows!),确保你正在运行最新的防病毒软件。文件上传功能是病毒有效载荷的一个敞开的大门。

总结

在本章中,你学习了各种注入攻击,其中攻击者构造恶意 HTTP 请求以控制后端系统。

SQL 注入攻击利用了 Web 代码在与 SQL 数据库通信时没有安全构建 SQL 字符串的漏洞。你可以通过在与数据库驱动程序通信时使用绑定参数来减轻 SQL 注入攻击。

命令注入攻击利用了那些不安全地调用操作系统函数的代码。你可以通过正确使用绑定来同样防止命令注入。

远程代码执行漏洞允许黑客在网页服务器进程内执行漏洞利用代码,通常源自不安全的序列化库。确保密切关注你所使用的序列化库和网页服务器软件的任何安全公告。

文件上传功能如果在上传文件时赋予文件可执行权限,往往会导致命令注入攻击。确保将上传的文件写入第三方系统或具有适当权限的磁盘,并尽可能在上传时验证文件类型。

通过遵循最小权限原则,你可以降低所有类型的注入攻击的风险:进程和软件组件应当仅使用执行其分配任务所需的权限,而不是更多。这种方法能够减少攻击者注入有害代码时造成的损害。遵循最小权限原则的例子包括限制网页服务器进程的文件和网络访问权限,以及在有限权限的账户下连接到数据库。

在下一章中,你将了解黑客如何利用 JavaScript 漏洞攻击你的网页。

第七章:跨站脚本攻击

image

在上一章中,你了解了攻击者如何将代码注入到 web 服务器中以危害网站。如果你的 web 服务器是安全的,黑客的下一个注入目标通常是 web 浏览器。浏览器会顺从地执行网页中出现的任何 JavaScript 代码,因此,如果攻击者能找到一种方法将恶意 JavaScript 注入到用户浏览器中,而该用户正在访问你的网站,那么这个用户将会面临麻烦。我们称这种代码注入为跨站脚本(XSS)攻击

JavaScript 可以读取或修改网页的任何部分,因此攻击者可以利用跨站脚本漏洞做很多事情。他们可以窃取登录凭据或用户输入的其他敏感信息,如信用卡号。如果 JavaScript 能读取 HTTP 会话信息,他们可以完全劫持用户的会话,从而远程以该用户身份登录。(你将在第十章中了解更多关于会话劫持的内容。)

跨站脚本攻击是一种非常常见的攻击类型,其带来的危害显而易见。本章介绍了三种最常见的跨站脚本攻击类型,并解释了如何防范这些攻击。

存储型跨站脚本攻击

网站通常会使用存储在数据库中的信息生成和渲染 HTML。零售网站会将产品信息存储在数据库中,社交媒体网站会存储用户对话。网站会根据用户访问的 URL 从数据库中提取内容,并将其插入页面中生成最终的 HTML。

来自数据库的任何页面内容都可能成为攻击者的攻击载体。攻击者会尝试将 JavaScript 代码注入到数据库中,以便 web 服务器在渲染 HTML 时将 JavaScript 输出到页面。我们称这种攻击为存储型跨站脚本攻击:JavaScript 被写入数据库,但在无防备的受害者查看网站的特定页面时,在浏览器中执行。

恶意 JavaScript 可以通过使用第六章中描述的 SQL 注入方法植入数据库,但攻击者更常见的做法是通过合法途径插入恶意代码。例如,如果一个网站允许用户发布评论,网站会将评论文本存储在数据库中,并将其显示给查看相同评论线程的其他用户。在这种情况下,黑客执行跨站脚本攻击的一个简单方法是将包含 <script> 标签的评论写入数据库。如果该网站未能安全构造 HTML,那么每当页面渲染给其他用户时,<script> 标签就会被输出,恶意 JavaScript 就会在受害者的浏览器中执行。

让我们来看一个具体的例子。假设你经营一个受欢迎的烘焙网站,https://breddit.com。你的网站鼓励用户参与有关面包相关话题的讨论线程。在使用在线论坛进行讨论时,大多数内容是由用户自己贡献的。当用户添加帖子时,你的网站会将其记录到数据库并展示给其他参与同一线程的用户。这是攻击者通过评论注入 JavaScript 的一个完美机会,如 图 7-1 所示。

image

图 7-1:攻击者通过评论注入 JavaScript。

如果你的网站在渲染 HTML 时没有对注入的脚本进行转义(如我们在接下来的章节中将讨论的那样),下一个查看该线程的用户将会看到攻击者的 <script> 标签被写入到他们的浏览器并执行,如 图 7-2 所示。

image

图 7-2:攻击者的 <script> 标签被写入受害者的浏览器并执行。

一个恶意的 alert() 弹窗更多的是一种困扰而非真正的威胁,但攻击者通常会从这种方式开始,检查是否可能进行跨站脚本攻击。如果攻击者能够调用 alert() 函数,他们可以升级为更危险的攻击,比如窃取其他用户的会话,或者将受害者重定向到恶意网站。烘焙社区将再也无法安心地在线活动!

评论线程并不是唯一可能展示此类漏洞的地方。任何由用户控制的内容都是潜在的攻击途径,你需要对此进行加固。例如,攻击者曾通过将恶意脚本标签注入用户名、个人主页和在线评论中来进行跨站脚本攻击。让我们看看你应该实施的一些简单防护措施。

缓解措施 1:转义 HTML 字符

为了防止存储型跨站脚本攻击,你需要对所有来自数据存储的动态内容进行转义,这样浏览器就能知道将其视为 HTML 标签的内容,而不是原始的 HTML。浏览器中的转义意味着将 HTML 中的控制字符替换为它们相应的实体编码,如 表 7-1 所示。

表 7-1: HTML 控制字符的实体编码

字符 实体编码
" &quot;
& &amp;
' &apos;
< &lt;
> &gt;

在 HTML 中具有特殊含义的字符,如表示标签开始和结束的<>字符,都有对应的安全实体编码。当浏览器遇到实体编码时,会将其识别为转义字符,并将其呈现为适当的字符,但最重要的是,它们不会被视为 HTML 标签。列表 7-1 展示了一个安全网站如何写出攻击者在图 7-1 中输入的评论。加粗文本表示可以用来构建 HTML 标签的字符。

<div class="comment">
  &lt;script&gt;alert(&quot;HAXXED&quot;)&lt;/script&gt;
</div>

列表 7-1:此 XSS 攻击尝试已被化解。

转义字符到未转义字符的转换发生在浏览器构建页面 DOM 之后,因此浏览器不会执行<script>标签。以这种方式转义 HTML 控制字符可以有效防止大多数跨站脚本攻击。

由于跨站脚本攻击是一种常见的漏洞,现代 web 框架通常默认会转义动态内容。特别是模板,通常会在没有要求的情况下转义插值值。插值变量在嵌入式 Ruby (ERB) 模板中的语法如下所示:列表 7-2。

<div class="comment">
  <%= comment %>
</div>

列表 7-2:在嵌入式 Ruby 模板中隐式转义动态内容

当动态内容被评估时,ERB 模板引擎会通过<%= comment %>语法自动转义敏感字符。

为了写出原始、未转义的 HTML(从而容易受到 XSS 攻击),ERB 模板需要明确调用raw函数,如列表 7-3 所示。

<div class="comment">
  <%= raw comment %>
</div>

列表 7-3:在嵌入式 Ruby 模板中允许原始 HTML 注入的语法

所有安全的模板语言都遵循相同的设计原则:模板引擎会隐式转义动态内容,除非开发者明确选择构建原始 HTML。确保你理解在模板中转义是如何工作的,并在代码审查时检查动态内容是否安全转义!特别是,如果你有帮助函数或方法构建原始 HTML 以注入模板中,要检查攻击者是否能够滥用它们的输入进行跨站脚本攻击。

缓解措施 2:实施内容安全策略

现代浏览器允许网站设置内容安全策略,你可以利用它来锁定网站上的 JavaScript 执行。跨站脚本攻击依赖于攻击者能够在受害者的网页上运行恶意脚本,通常是通过在页面的<html>标签内某处注入<script>标签,这也被称为内联 JavaScript。图 7-2 中展示的黑客攻击示例就是使用内联 JavaScript,作为评论文本书写。

通过在你的 HTTP 响应头中设置内容安全策略,你可以告诉浏览器永远不执行内联 JavaScript。浏览器只有在 JavaScript 通过 <script> 标签的 src 属性导入时才会执行页面上的 JavaScript。一个典型的内容安全策略头部如下所示:Listing 7-4。该策略指定脚本可以从同一域('self')或apis.google.com域导入,但不应执行内联 JavaScript。

Content-Security-Policy: script-src 'self' https://apis.google.com

Listing 7-4: 在 HTTP 响应头中设置的内容安全策略

你还可以在 HTML 页面的 <head> 元素中的 <meta> 标签里设置你网站的内容安全策略,如 Listing 7-5 所示。

<meta http-equiv="Content-Security-Policy" content="script-src 'self' https://apis.google.com">

Listing 7-5: 在 HTML 文档的 元素中设置的等效内容安全策略

通过将浏览器加载脚本的域名列入白名单,你隐含地声明不允许使用内联 JavaScript。在这个示例的内容安全策略中,浏览器将仅从apis.google.com和网站的域名加载 JavaScript——例如,* breddit.com*。要允许内联 JavaScript,策略中必须包含unsafe-inline关键词。

阻止内联 JavaScript 执行是一个很好的安全措施,但这意味着你需要将你网站当前实现的任何内联 JavaScript 移动到单独的导入文件中。换句话说,页面上的 <script> 标签必须通过 src 属性引用一个单独的 JavaScript 文件,而不是将 JavaScript 直接写在开始和结束标签之间。

将 JavaScript 分离到外部文件中是 Web 开发中推荐的方法,因为这样可以使代码库更加有序。内联脚本标签在现代 Web 开发中被认为是不好的实践,因此禁止内联 JavaScript 实际上迫使你的开发团队养成良好的习惯。然而,内联脚本标签在旧的遗留网站中仍然很常见。事实上,可能需要一些时间来重构你的模板,以移除所有内联 JavaScript 标签。

为了帮助重构,考虑使用内容安全策略违规报告。如果你在内容安全策略头中添加report-uri指令,如 Listing 7-6 所示,浏览器会通知你任何策略违规,而不是阻止 JavaScript 执行。

Content-Security-Policy-Report-Only: script-src 'self'; report-uri https://example.com/csr-reports

Listing 7-6: 一个内容安全策略,它指示浏览器将任何内容安全违规报告到 https://example.com/csr-reports

如果你将所有这些违规报告收集到日志文件中,开发团队应该能够看到他们需要重写的所有页面,以符合提议的内容安全策略所施加的限制。

除了转义 HTML 外,你还应设置内容安全策略,因为这将有效地保护你的用户!攻击者很难同时找到未转义的内容将恶意脚本偷偷传送到你的白名单域名中。我们称这种针对同一漏洞使用多层防御的做法为深度防御,正如你在第六章中学到的那样;这将是本书的一个主题。

反射型跨站脚本攻击

数据库中的恶意 JavaScript 并不是跨站脚本攻击的唯一途径。如果你的网站会获取 HTTP 请求的一部分并将其显示回渲染的网页中,那么你的渲染代码需要防范通过 HTTP 请求注入恶意 JavaScript 的攻击。我们将这种类型的攻击称为反射型跨站脚本攻击。

几乎所有网站都会在渲染的 HTML 中显示 HTTP 请求的某一部分。以谷歌搜索页面为例:如果你搜索cats,谷歌会将搜索词作为 URL 中的一部分传递:www.google.com/search?q=cats。搜索词cats会显示在搜索结果上方的搜索框中。

如果谷歌是一个安全性较差的公司,那么就有可能将 URL 中的cats参数替换为恶意 JavaScript,并让该 JavaScript 代码在任何人打开该 URL 时执行。攻击者可以通过电子邮件将该 URL 作为链接发送给受害者,或者通过将其添加到评论中来欺骗用户访问该 URL。这就是反射型跨站脚本攻击的本质:攻击者在 HTML 请求中发送恶意代码,然后服务器将其反射回来。

幸运的是,谷歌聘用了不止几位安全专家,因此如果你尝试将<script>标签插入其搜索结果中,服务器是不会执行该 JavaScript 的。过去,黑客确实发现了谷歌应用管理界面中存在反射型跨站脚本漏洞,地址为admin.google.com,这说明即使是大公司也会被发现漏洞。如果你想有机会保护你的用户安全,你必须防范这种攻击途径。

缓解措施:对 HTTP 请求中的动态内容进行转义

你可以通过与缓解存储型跨站脚本漏洞相同的方式来缓解反射型跨站脚本漏洞:通过转义网站将动态内容插入 HTML 页面中的控制字符。无论动态内容来自后端数据库还是 HTTP 请求,你都需要以相同的方式进行转义。

幸运的是,模板语言通常会对所有插值变量进行转义,无论模板是从数据库加载它们还是从 HTTP 请求中提取它们。然而,你的开发团队仍然需要在审查代码时意识到通过 HTTP 请求进行注入的风险。代码审查通常会忽略反射型跨站脚本漏洞,因为开发人员过于专注于寻找存储型跨站脚本漏洞。

反射型跨站脚本攻击的常见目标是搜索页面和错误页面,因为它们通常会将查询字符串的部分内容显示回用户。确保你的团队理解这些风险,并知道如何在审查代码更改时识别漏洞。存储型跨站脚本攻击往往更具危害性,因为单个恶意 JavaScript 注入到数据库表中后可以一再攻击你的用户。但是反射型攻击更常见,因为它们更容易实现。

在我们结束本章之前,让我们再看看一种跨站脚本攻击类型。

基于 DOM 的跨站脚本攻击

防御大多数跨站脚本攻击意味着检查和保护服务器端代码;然而,客户端代码的丰富框架越来越流行,导致了基于 DOM 的跨站脚本攻击的兴起,在这种攻击中,攻击者通过 URI 片段将恶意 JavaScript 偷偷塞入用户的网页中。

要理解这些攻击,首先需要了解 URI 片段的工作原理。让我们从回顾一下URL(统一资源定位符)开始,浏览器地址栏中显示的地址是如何构成的。一个典型的 URL 如图 7-3 所示。

image

图 7-3:典型 URL 的各个部分

URI 片段是 URL 中#符号后的可选部分。浏览器使用URI 片段进行页面内导航——如果页面上的 HTML 标签具有与 URI 片段匹配的id属性,浏览器在打开页面后会滚动到该标签。例如,如果你在浏览器中加载 URL en.wikipedia.org/wiki/Cat#Grooming,浏览器会打开网页并滚动到 Wikipedia 猫咪页面的美容部分。之所以这样,是因为该部分的标题标签看起来像清单 7-7。

<h3 id="Grooming">Grooming</h3>

清单 7-7:与 URI 片段#Grooming 对应的 HTML 标签

利用这一有用的浏览器内建行为,Wikipedia 允许用户直接链接到页面中的某个部分,这样你和你的室友终于可以解决那个关于猫咪美容的争论了。

单页面应用通常也使用 URI 片段以直观的方式记录和重新加载状态。这些类型的应用程序通常使用像 Angular、Vue.js 和 React 这样的 JavaScript 框架编写,实际上是基于 JavaScript 的网页,旨在避免浏览器重新加载网页时出现的渲染闪烁

避免这种渲染闪烁的一个潜在方法是将整个应用设计为在一个永远不变的静态 URL 下加载,因为浏览器地址栏中 URL 的变化通常会导致网页重新加载。然而,如果用户刷新一个不变的 URL,浏览器会将网页重置为初始状态,丢失用户之前的操作信息。

许多单页面应用通过使用 URI 片段在浏览器刷新时保持状态来克服这一问题。你通常会看到网页实现无限滚动:用户向下滚动页面时,图片列表会动态加载。URI 片段会更新,指示用户滚动的进度。即便浏览器刷新,JavaScript 代码也可以解释 URI 片段的内容,并在页面刷新时加载相关数量的图片。

从设计上讲,浏览器在渲染页面时不会将 URI 片段发送到服务器。当浏览器接收到带有 URI 片段的 URL 时,它会记录下该片段,将其从 URL 中剥离,并将剥离后的 URL 发送到 Web 服务器。页面上执行的任何 JavaScript 都可以读取 URI 片段,且浏览器会将完整的 URL 写入浏览器历史记录或书签中,如果用户将页面加入书签的话。

不幸的是,这意味着 URI 片段无法被任何服务器端代码使用——因此保护服务器端代码不能缓解基于 DOM 的 XSS 攻击。客户端 JavaScript 代码在解释和使用 URI 片段时,必须小心如何处理这些片段的内容。如果内容没有转义并直接写入网页的 DOM,攻击者可以通过这个渠道偷偷注入恶意 JavaScript。攻击者可以构造一个带有恶意 JavaScript 的 URL,利用 URI 片段中的内容,然后欺骗用户访问该 URL 发起跨站脚本攻击。

基于 DOM 的跨站脚本攻击是一种相对较新的攻击方式,但它特别危险,因为代码注入完全发生在客户端,且通过检查 Web 服务器日志无法检测到!这意味着在进行代码审查时,你需要特别警觉这种漏洞,并知道如何缓解它。

缓解措施:转义来自 URI 片段的动态内容

在浏览器中执行的任何 JavaScript 代码,如果使用 URI 片段构造 HTML,容易受到基于 DOM 的跨站脚本攻击。这意味着,在用客户端代码将 URI 片段中的内容插入 HTML 时,需要特别注意转义这些内容,就像你在服务器端代码中处理时一样。

现代 JavaScript 模板框架的作者充分意识到 URI 片段所带来的风险,并且不鼓励在代码中构造原始 HTML。例如,在 React 框架中,编写未经转义的 HTML 语法需要开发者调用 dangerouslySetInnerHTML 函数,如在第 7-8 列表中所示。

function writeSomeHTML () {
  return {__html: 'First &middot; Second'};
}
function MyComponent() {
  return <div dangerouslySetInnerHTML={writeSomeHTML()} />;
}

列出 7-8:在 React 框架中危险地设置来自文本的原始 HTML

如果你的客户端 JavaScript 代码比较复杂,可以考虑切换到一个现代的 JavaScript 框架。这将使代码库更加易于管理,安全性问题也会更加明显。而且,一如既往,务必设置适当的内容安全策略。

总结

在本章中,你学习了跨站脚本攻击(XSS),攻击者通过在用户浏览页面时将 JavaScript 注入到你站点的页面中来实施攻击。攻击者通常将恶意 JavaScript 注入到来自数据库、HTTP 请求或 URI 片段的动态内容中。你可以通过转义动态内容中的 HTML 控制字符,并设置内容安全策略来防止执行内联 JavaScript,从而抵御跨站脚本攻击。

在下一章中,你将了解攻击者如何利用跨站请求伪造(CSRF)攻击你的站点用户。

第八章:跨站请求伪造攻击

image

在上一章中,你看到攻击者如何通过跨站脚本攻击将 JavaScript 注入用户的网页浏览器,通常通过评论区、搜索结果或 URL 等页面元素。现在,你将看到攻击者如何利用恶意链接来入侵用户。

没有任何网站是孤立的。由于你的网站有一个公共 URL,其他站点会频繁地链接到它,作为站点所有者,你通常应当鼓励这种行为。更多指向你网站的外部链接意味着更多的流量和更好的搜索引擎排名。

然而,并不是所有链接到你网站的行为都是出于好意。攻击者可以诱使用户点击一个恶意链接,这个链接会触发不希望发生的或意外的副作用。这就是所谓的 跨站请求伪造 (CSRF 或 XSRF)。安全研究人员有时将 CSRF 发音为“海浪”。

CSRF 是一种非常常见的漏洞,几乎所有主流网站在某一时刻都曾暴露过这种问题。攻击者利用 CSRF 窃取 Gmail 联系人列表、在亚马逊上触发一键购买操作、甚至更改路由器配置。本章将探讨 CSRF 攻击通常是如何运作的,并展示一些能防范此类攻击的编码实践。

CSRF 攻击的结构

攻击者通常通过利用那些实现 GET 请求并且能够改变网站服务器状态的网站发起 CSRF 攻击。GET 请求在受害者点击链接时触发,允许攻击者将误导性链接嵌入目标站点,从而执行意想不到的操作。GET 请求是唯一一种将请求的所有内容都包含在 URL 中的 HTTP 请求,因此它们在 CSRF 攻击中具有独特的脆弱性。

在 Twitter 的早期版本中,你可以通过 GET 请求创建推文,而不是现在网站使用的 POST 请求。这一疏忽使得 Twitter 易受 CSRF 攻击:攻击者可以创建 URL 链接,当点击这些链接时,会在用户的时间线上发布内容。列表 8-1 展示了其中一个这样的 URL 链接。

https://twitter.com/share/update?status=in%20ur%20twitter%20CSRF-ing%20ur%20tweets

列表 8-1:一个链接,在某一时刻,点击后会通过 Twitter 进行 CSRF 攻击,将文本 发送到受害者的时间线

一名机智的黑客利用这个漏洞在 Twitter 上创建了一个病毒式的 蠕虫。由于他们可以通过一个 GET 请求发送推文,他们构造了一个恶意链接,点击后会发布一条包含猥亵信息的推文 并且 带有相同的恶意链接。当推文的读者点击第一个受害者推文中的链接时,他们也会被欺骗并发送相同内容的推文。

黑客欺骗了一些受害者点击了恶意链接,这些受害者在时间线上发出了意外的推文。随着越来越多的用户阅读这些原始推文并出于好奇点击了其中的嵌入链接,他们也发出了相同的内容。很快,成千上万的 Twitter 用户被诱骗发表了他们想要骚扰山羊的愿望(最初推文的内容)。第一个 Twitter 蠕虫诞生了,Twitter 开发团队忙于修补这个安全漏洞,以防事态失控。

缓解措施 1:遵循 REST 原则

为了保护你的用户免受 CSRF 攻击,确保你的GET请求不会改变服务器的状态。你的网站应该仅使用GET请求来获取网页或其他资源。你应该通过PUTPOSTDELETE请求执行那些改变服务器状态的操作——例如,用户登录或登出、重置密码、发布帖子或关闭账户。这种设计理念,被称为表现层状态转移(REST),除了防范 CSRF 攻击外,还有许多其他好处。

REST 原则要求你根据操作的意图,将网站操作映射到适当的 HTTP 方法。你应该使用GET请求获取数据或页面,使用PUT请求在服务器上创建新对象(例如评论、上传或消息),使用POST请求修改服务器上的对象,使用DELETE请求删除对象。

并不是所有的操作都有明显对应的 HTTP 方法。例如,当用户登录时,是否认为用户是在创建一个新会话还是修改他们的状态,这个问题是一个哲学讨论。然而,在防止 CSRF 攻击方面,关键是避免将会改变服务器状态的操作分配给GET请求。

保护你的GET请求并不意味着其他类型的请求就没有漏洞,正如我们在第二个缓解措施中看到的那样。

缓解措施 2:实现反 CSRF Cookies

关闭你的GET请求可以防止大多数 CSRF 攻击,但你仍然需要防范使用其他 HTTP 方法的请求。使用这些方法的攻击比基于GET的 CSRF 攻击要少得多,且需要更多的工作,但如果攻击者认为收益足够,他们可能会尝试这些攻击。

例如,黑客可以通过让受害者提交一个托管在第三方网站上的恶意表单或脚本来欺骗用户发起一个POST请求。如果你的站点在响应POST请求时执行敏感操作,那么你需要使用反 CSRF cookies 来确保这些请求仅在你的站点内发起。敏感操作应该仅通过你自己的网站登录表单和 JavaScript 触发,而不是通过可能欺骗用户执行意外操作的恶意页面。

反-CSRF cookie 是一个随机化的字符串 token,Web 服务器将其写入名为 cookie 参数的字段。回想一下,cookies 是通过 HTTP 头在浏览器和 Web 服务器之间传递的小块文本。如果 Web 服务器返回一个包含头部值的 HTTP 响应,例如 Set-Cookie: _xsrf=5978e29d4ef434a1,则浏览器将在下一个 HTTP 请求中以 Cookie: _xsrf=5978e29d4ef434a1 的形式返回相同的信息。

安全的网站使用反-CSRF cookies 来验证 POST 请求是否来源于托管在相同 Web 域上的页面。网站上的 HTML 页面将相同的字符串 token 作为 <input type="hidden" name="_xsrf" value="5978e29d4ef434a1"> 元素添加到任何用于生成 POST 请求的 HTML 表单中。当用户提交表单到服务器时,如果返回的 cookie 中的 _xsrf 值与请求体中的 _xsrf 值不匹配,服务器会完全拒绝该请求。通过这种方式,服务器验证并确保请求来自站点内部,而不是来自恶意的第三方网站;只有当网页从相同的域加载时,浏览器才会发送所需的 cookie。

大多数现代 Web 服务器都支持反-CSRF cookies。请确保查阅你所选 Web 服务器的安全文档,了解它们如何实现这些 cookies,因为不同的 Web 服务器语法稍有不同。清单 8-2 显示了一个包含反-CSRF 保护的 Tornado Web 服务器模板文件。

<form action="/new_message" method="post">
❶ {% module xsrf_form_html() %}
   <input type="text" name="message"/>
   <input type="submit" value="Post"/>
</form>

清单 8-2:一个包含反-CSRF 保护的 Tornado Web 服务器 Python 模板文件

在这个例子中,xsrf_form_html() 函数 ❶ 生成一个随机化的 token,并将其作为 <input type="hidden" name="_xsrf" value="5978e29d4ef434a1"> 元素写入 HTML 表单中。然后,Tornado Web 服务器将在 HTTP 响应头中写出相同的 token,格式为 Set-Cookie: _xsrf=5978e29d4ef434a1。当用户提交表单时,Web 服务器会验证表单中的 token 和返回的 Cookie 头中的 token 是否匹配。浏览器的安全模型将根据 同源策略 返回 cookies,因此这些 cookie 的值只能由 Web 服务器设置。因此,服务器可以确认 POST 请求是从主机网站发出的。

你应该使用反-CSRF cookies 来验证通过 JavaScript 发出的 HTTP 请求,这样你也可以保护 PUTDELETE 请求。JavaScript 需要从 HTML 中提取反-CSRF token,并将其传递回服务器以进行 HTTP 请求。

在实现反-CSRF cookies 后,你的网站应该更加安全。现在你需要关闭最后一个漏洞,确保攻击者无法窃取你的反-CSRF token 并将其嵌入恶意代码中。

你必须实现的最终防护措施是,在设置 cookie 时指定SameSite属性。默认情况下,当浏览器向你的网站发送请求时,它会将网站上最后设置的 cookie 附加到请求中,无论请求的来源是什么。这意味着恶意的跨站请求仍然会携带你之前设置的任何安全 cookie。单靠这一点并不能完全破坏防 CSRF 措施,但如果攻击者从你的 HTML 表单中窃取了安全令牌,并将其安装到自己的恶意表单中,他们仍然可以发起 CSRF 攻击。

在设置 cookie 时指定SameSite属性,告诉浏览器当请求来自外部域(如攻击者设立的恶意网站)时,应该剥离 cookie。通过在示例 8-3 中设置SameSite=Strict语法,可以确保浏览器仅在来自你自己网站的请求中发送 cookie。

Set-Cookie: _xsrf=5978e29d4ef434a1; SameSite=Strict;

示例 8-3:将 SameSite 属性设置为我们的防 CSRF cookie,确保该 cookie 仅附加到来自我们网站的请求。

在所有的 cookie 上设置SameSite属性是个好主意,不仅仅是那些用于 CSRF 保护的 cookie。然而有一个警告:如果你使用 cookie 进行会话管理,将SameSite属性设置到你的会话 cookie 上,会使得所有来自其他网站的请求无法携带该 cookie。这意味着任何指向你网站的外部链接都会迫使用户重新登录。

对于已经在你网站上打开会话的用户来说,这种行为可能会让人有些恼火。试想,如果每次有人分享视频,你都需要重新登录 Facebook,那该有多烦人?为了防止这种情况,示例 8-4 展示了SameSite属性的一个更实用的值,Lax,它只允许来自其他网站的GET请求发送 cookie。

Set-Cookie: session_id=82938d911e13f3; SameSite=Lax;

示例 8-4:在 HTTP cookie 上设置 SameSite 属性可以使 cookie 仅在 GET 请求时生效。

这使得你的网站可以无缝链接,但剥夺了攻击者伪造恶意操作(如POST请求)的能力。只要你的GET请求没有副作用,这个设置就同样安全。

奖金缓解:要求重新认证敏感操作

你可能会注意到,当你执行敏感操作时,一些网站会强制要求你重新确认登录信息,例如更改密码或发起支付。这被称为重新认证,它是防止 CSRF 攻击的一种常见方式,因为它明确地提示用户你即将执行一些重要且可能危险的操作。

重新认证还有一个积极的副作用,即保护用户免受在共享或被盗设备上不小心保持登录状态的风险。如果你的网站处理金融交易或机密数据,你应该强烈考虑在用户执行敏感操作时强制要求他们重新输入凭据。

总结

攻击者可以通过其他网站的网络请求诱使用户执行不希望的操作。解决这种跨站请求伪造(CSRF)攻击的方法有三种。

首先,确保你的GET请求没有副作用,这样当用户点击恶意链接时,服务器状态不会发生变化。其次,使用反 CSRF cookies 来保护其他类型的请求。第三,设置这些 cookies 时,使用SameSite属性,以便从由其他网站生成的请求中剥离 cookies。

对于你网站上的一些非常敏感的操作,要求用户在执行这些操作时重新认证自己是一个好主意。这为防御 CSRF 攻击增加了一层额外的保护,并且如果用户不小心在共享或被盗的设备上保持登录状态,也能保护他们。

在下一章中,你将学习黑客如何在认证过程中利用漏洞。

第九章:身份验证的风险

image

大多数网站都提供某种形式的登录功能。这是一种身份验证形式,即当用户返回网站时识别其身份的过程。验证用户身份使他们能够在一个在线社区中拥有身份,贡献内容、发送消息、进行购买等。

如今,互联网用户习惯于使用用户名和密码注册网站,并在下次使用时重新登录。尤其是浏览器和插件有助于缓存或选择密码,而第三方身份验证服务也变得无处不在。

然而,这也有一个缺点。获取用户账户的访问权限对黑客来说是一个诱人的前景。在互联网时代,黑客通过暗网出售被盗凭证、劫持社交媒体账户传播诱饵链接,以及进行金融欺诈从未如此容易。

在本章中,你将探讨黑客在登录和身份验证过程中如何破坏用户账户的几种方式。(下一章将讨论用户登录并建立会话后面临的漏洞。)在这里,你将首先看到网站实现身份验证的最常见方式,并了解攻击者如何通过暴力破解攻击来利用这些方式。然后,你将学习如何通过第三方身份验证、单点登录以及保护你自己身份验证系统的方式来防止这些攻击。

实现身份验证

身份验证是超文本传输协议(HTTP)的一部分。为了提出身份验证挑战,Web 服务器需要在 HTTP 响应中返回 401 状态码,并添加 WWW-Authenticate 头部,描述首选的身份验证方法。(常见的两种身份验证方法是基本身份验证和摘要身份验证。)为了满足这一要求,用户代理——通常是 Web 浏览器——需要向用户请求用户名和密码,从而创建登录功能。

基本身份验证方案中,浏览器将用户提供的用户名和密码与冒号(:)字符连接起来,生成字符串 username:password。然后,它使用 Base64 算法对该字符串进行编码,并在 HTTP 请求的 Authorization 头部将其发送回服务器。

摘要身份验证方案稍微复杂一些,要求浏览器生成一个哈希值,该哈希值由用户名、密码和 URL 组成。哈希是单向加密算法的输出,它使得为一组输入数据生成唯一的“指纹”变得容易,但如果只有算法的输出,则很难猜测输入值。在本章后面,我们将更深入地讨论哈希,当我们讲解如何安全存储密码时。

HTTP 原生身份验证

尽管身份验证是超文本传输协议的一部分,但流行的网站很少使用基本认证或摘要认证——主要是由于可用性考虑。原生的浏览器身份验证提示并不是一个美观的设计。它看起来类似于 JavaScript 警告对话框,抢占浏览器的焦点,打断用户使用网站的体验,如图 9-1 所示。

因为浏览器将身份验证提示实现为 HTML 之外的内容,我们无法将本地身份验证提示样式化以匹配网站。作为一个不在网页中显示的本地浏览器窗口,浏览器也无法自动填充用户的凭据。最后,由于 HTTP 身份验证未指定忘记密码时的重置方法,我们必须在登录提示之外单独实现一个重置功能,这会导致用户体验的混乱。

image

图 9-1:原生 Google Chrome 登录提示粗鲁地打断了你的浏览会话。

非本地身份验证

由于这种不友好的设计,内置的 HTTP 身份验证方法通常仅用于那些用户体验不太重要的应用程序。现代网站通常会使用 HTML 自定义登录表单,就像在清单 9-1 中展示的那样。


<form action="/login" method="post">
❶ <input type="email" name="username" placeholder="Type your email">
❷ <input type="password" name="password" placeholder="Type your password">
   <input type="submit" name="login" value="Log in">
</form>

清单 9-1:HTML 中的典型登录表单

一个典型的登录表单包含一个 <input type="text"> 元素 ❶,要求用户提供用户名,还有一个 <input type="password"> 元素 ❷,该元素用 字符替换输入的字符以隐藏密码。用户提交表单时,提供的用户名和密码作为 POST 请求发送到服务器。如果登录失败,因为用户无法通过身份验证,服务器会在 HTTP 响应中返回 401 状态码。如果登录成功,服务器会将用户重定向到其主页。

暴力破解攻击

攻击者经常试图通过猜测密码在身份验证环节入侵你的网站。电影中通常描绘黑客使用目标的个人信息来猜测密码。虽然这可能是高曝光目标的一个问题,但黑客通常更成功于使用暴力破解攻击,它通过脚本尝试成千上万的常用密码来攻击登录页面。因为之前的数据泄露已经泄漏了包括清单 9-2 中的成千上万的常用密码,攻击者很容易确定应该首先尝试哪些密码。

1\. 123456
2\. password
3\. 12345678
4\. qwerty
5\. 12345
6\. 123456789
7\. letmein
8\. 1234567
9\. football
10\. iloveyou

清单 9-2:安全研究人员每年发布最常用密码的列表;这些密码几乎每年变化不大。(这个列表由互联网安全公司 SplashData 提供。)

让我们来看看一些你可以实施并保护身份验证免受这种类型威胁的方法。

缓解措施 1:使用第三方身份验证

最安全的认证系统是你不需要自己编写的系统。与其自己实现认证系统,不如考虑使用像 Facebook Login 这样的第三方服务,让用户可以用他们的社交媒体凭据登录你的网站。这对他们来说很方便,也减轻了你存储用户密码的负担。

大型科技公司提供了其他类似的认证服务。它们大多基于开放认证(OAuth)OpenID标准——这些是将认证委托给第三方的常用协议。你可以随时混合使用不同的认证系统。它们通常易于集成,因此选择一个或多个与用户群体匹配的系统。如果你提供与电子邮件相关的服务,可以集成 Google OAuth,要求用户授权访问其 Gmail 账户。如果你提供技术服务,可以使用像 GitHub OAuth 这样的系统。Twitter、Microsoft、LinkedIn、Reddit 和 Tumblr 等都提供认证选项,其他数百个网站也是如此。

缓解措施 2:与单点登录集成

如果你与 OAuth 或 OpenID 身份提供者集成,用户通常会使用他们的个人电子邮件地址作为用户名。然而,如果你的网站目标用户群是企业用户,可以考虑与单点登录(SSO)身份提供者(如 Okta、OneLogin 或 Centrify)集成,这些身份提供者集中管理企业系统中的认证,使员工可以无缝地使用他们的企业邮箱登录第三方应用。公司管理员可以控制哪些员工可以访问哪些网站,用户凭据也会安全地存储在公司的服务器上。

要与单点登录提供者集成,你通常需要使用安全断言标记语言(SAML),这是一种比 OAuth 或 OpenID 更古老(且不太友好)的标准,尽管大多数编程语言都有成熟的 SAML 库可供使用。

缓解措施 3:保护你自己的认证系统

尽管第三方认证通常比你自己的系统更安全,但仅依赖第三方认证可能会在某种程度上限制你的用户群,因为并不是每个人都有社交媒体账户或 Gmail 账户。对于其他用户,你需要创建一种方式,让他们注册并手动选择用户名和密码。这意味着需要在你的网站上创建独立的页面,让用户能够注册、登录和退出;同时编写代码来存储和更新凭据,并在用户重新输入时检查凭据是否正确。很可能,你还需要为用户提供更改密码的机制。

这需要实现很多功能!在开始编写代码之前,你需要做一些设计决策。我们来看看你需要做对的关键事情,以便拥有一个安全的认证系统。

要求用户名、电子邮件地址或两者

用户在注册时需要选择一个用户名和密码。大多数网站还会要求用户在注册时提交一个有效的电子邮件地址,这样他们就可以在用户忘记凭证时发送密码重置邮件。

对于许多网站来说,用户的电子邮件地址就是他们的用户名。因为每个电子邮件地址必须对一个账户唯一,所以通常选择单独的用户名是多余的。例外情况是当用户在网站上有可见存在时;例如,当用户有公开个人资料,或在评论区与其他用户互动时。这类网站要求用户选择一个单独的显示名称。将电子邮件地址作为显示名称是不好的做法,因为这会引发骚扰和垃圾邮件。

验证电子邮件地址

如果你打算从你的网站发送电子邮件——例如,允许用户重置密码——你需要验证每个用户的电子邮件地址是否对应一个有效的电子邮件账户。网站生成的电子邮件被称为事务性电子邮件,因为网站是响应用户的操作而发送这些邮件。向未经验证的地址发送事务性电子邮件会迅速使你被电子邮件服务提供商列入黑名单,因为他们不愿意为垃圾邮件提供支持。

首先,验证用户的电子邮件地址是否在表面上有效。这意味着验证电子邮件仅包含有效字符:字母、数字或任何特殊字符(!#$%&'*+-/=?^_`{|}~;.)。

地址必须包含一个@符号,并且右边必须有一个有效的互联网域名。通常,但不总是,域名应对应一个网站,例如@gmail.com地址对应* www.gmail.com。至少,互联网的域名系统(DNS)*,我们在第二章中讨论过的,必须包含该域的邮件交换(MX)记录,以告诉软件邮件应该路由到哪里。在验证过程中,您可以查找 MX 记录,正如在清单 9-3 中所示。

import dns.resolver
def email_domain_is_valid(domain):
  for _ in dns.resolver.query(domain, 'MX'):
    return True
  return False

清单 9-3:通过使用 dnsresolver 库验证一个域名是否能够接收电子邮件

然而,验证一个地址是否对应一个有效的电子邮件账户的唯一 100%可靠方法是发送一封电子邮件并检查是否收到。这意味着你需要向每个用户发送一封包含电子邮件验证链接的邮件,该链接指向你的网站并包含一个验证令牌——一个大而随机生成的字符串,你会将其存储在数据库中与用户的电子邮件地址关联。当用户点击链接以验证他们的电子邮件地址的所有权时,你可以检查验证令牌是否与你发送的相符,从而确认他们确实能够访问该电子邮件账户。

许多网站要求用户在完成注册过程之前验证他们的电子邮件。其他网站则允许用户在未验证状态下使用网站的部分功能,以减少注册过程的繁琐。你永远不能假设用户已经验证过电子邮件账户,直到你验证他们之前。直到那时,不要发送任何其他类型的交易邮件或将用户添加到邮件列表!

禁止使用一次性电子邮件账户

一些用户不愿意使用常用的电子邮件地址进行注册,他们会使用像 10 Minute Mail 或 Mailinator 这样的服务生成临时电子邮件账户,或使用图 9-2 所示的服务。这些服务生成一个一次性电子邮件账户,在接收几封邮件后即会关闭。如果用户使用这种类型的服务,通常意味着他们担心被加入邮件列表(考虑到电子邮件营销人员的无休止骚扰,这种担心是很合理的)。

你可能需要禁止用户使用一次性电子邮件地址注册,例如,当部分用户生成临时账户以骚扰他人时。此时,你可以使用维护良好的一次性电子邮件服务提供商黑名单,在注册过程中检测、拒绝并禁止一次性电子邮件域名。

image

图 9-2:想要一个临时的电子邮件地址吗?来这里获取: www.sharklasers.com/(是的,这是一个真实的网站。嘭嘭嘭。)

保护密码重置

拥有每个用户的已验证电子邮件地址,能帮助你处理用户忘记密码的(不可避免的)情况。只需向他们发送一封包含密码重置链接的邮件,链接内带有新的验证令牌。当健忘的用户打开邮件并点击链接时,你可以验证收到的令牌,并允许用户为其账户选择一个新密码。

密码重置链接应该是短期有效的,在用户使用后应立即过期。一个好的经验法则是,在 30 分钟后使重置令牌过期,以防止攻击者滥用过时的重置链接。如果攻击者入侵了用户的电子邮件账户,你不能让他们搜索包含重置链接的邮件,并使用这些链接以受害者身份访问你的网站。

要求复杂密码

复杂密码通常更难猜测,因此你应该要求用户在选择密码时满足一定的密码复杂度标准,以保障他们的安全。复杂密码包含数字和符号以及字母,大小写字母混合,且密码长度较长而非较短。至少,你应该强制要求密码长度不低于八个字符,但密码越长越好。(研究表明,密码长度比混合使用不常见字符更为重要。)

然而,用户通常很难记住复杂的密码,所以如果你强制要求过于严格的密码复杂性要求,用户通常会在其他网站上重新使用他们以前输入过的密码。一些安全的网站会防止用户重复使用他们以前用过的密码,迫使他们每次都选择新的、独特的密码,从而让他们远离懒惰的习惯。不幸的是,大多数用户通常通过在常用密码的末尾添加一个数字来循环使用密码,这样并不会显著降低密码的猜测难度。

最终,每个用户对其在线安全负责,因此通常更好的是引导用户选择强密码,而不是强迫他们通过繁琐的步骤。有些 JavaScript 库,比如password-strength-calculator库,可以在用户输入密码时对其复杂性进行评分,并指出常见的密码,你可以在注册和密码重置页面上使用这些库,促使用户选择更安全的密码。

安全存储密码

在用户选择密码后,你需要以某种形式将其记录在数据库中与用户名关联,以便在用户再次登录时重新验证其凭据。不要直接存储密码原文——我们称之为明文存储,这是一个巨大的安全隐患。如果攻击者访问了存储明文密码的数据库,他们可以危及每个用户账户,并且还会影响这些用户在其他网站上使用相同凭据的账户。幸运的是,有一种方法可以安全地存储密码,使其在数据库中不可读,但可以在稍后的时候验证用户是否正确地重新输入密码。

哈希密码

密码应该在存储到数据库之前,使用加密哈希算法进行处理。这将把原始输入字符串转换为一个固定长度的比特字符串,使得反向过程在计算上不可行。然后,你应该将该算法的输出值——哈希值——与每个用户名一起存储。

哈希算法是一种单向数学函数。猜测生成给定哈希输出(或简称哈希)的输入字符串的唯一实际方法是逐一尝试每一个可能的输入字符串。通过存储用户密码的哈希值,当用户重新输入密码时,你可以重新计算哈希值,并比较新旧哈希值以确认他们是否输入了正确的密码。

存在许多加密哈希算法,每种算法都有不同的实现方式和强度。一个好的哈希算法应该计算快速,但又不能过于快速。否则,随着计算速度的提升,通过暴力破解枚举所有可能的输入来尝试破解密码变得可行。因此,一个不错的算法是bcrypt,如示例 9-4 所示,它允许随着时间推移增加额外的迭代次数,使得哈希函数随着计算能力变得更强且更加耗时。

import bcrypt
password = "super secret password"

# Hash a password for the first time, with a randomly-generated salt
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=14❶))

# Check that an unhashed password matches one that has previously been hashed
if bcrypt.checkpw(password, hashed):
    print("It matches!")
else:
    print("It does not match :(")

示例 9-4:使用 bcrypt 算法在 Python 中对密码进行哈希并进行测试

❶处的rounds参数可以增加,从而使密码哈希变得更强。存储哈希密码而不是明文密码要安全得多。包括你在内的任何访问数据库的人都无法直接解密密码,但你的网站仍然可以判断用户是否正确重新输入了密码。这为你减轻了安全负担——即使攻击者入侵了你的数据库,他们也无法对哈希密码做什么。

哈希加盐

对密码进行哈希处理可以使你的网站更加安全,但用户在选择密码时通常缺乏想象力。在破解密码列表——对泄露的密码哈希进行逆向工程——时,黑客经常使用彩虹表,这是一种包含了常用密码的列表,这些密码已经通过已知的哈希算法处理过。将哈希与预先计算的值进行匹配,可以为攻击者带来很好的回报,使他们能够破解许多哈希值的密码,甚至是大多数。

为了防范彩虹表攻击,你需要加盐你的密码哈希,这意味着向哈希算法中加入随机元素,使得输入的密码不会单独决定生成的哈希值。你可以将盐值输入存储在配置文件中,或者更好地,为每个用户生成一个单独的盐值,并将其与密码哈希一起存储。这使得彩虹表攻击变得不可行,因为攻击者必须为你使用的每个盐值重新生成整个彩虹表,而这在计算上是不可承受的,因而需要很长时间。

要求多因素认证

无论你如何安全地存储密码,基于密码的认证系统始终容易受到暴力破解密码攻击。为了真正保护你的网页,考虑通过要求多因素认证(MFA)来增加一层安全防护,这要求回访用户通过以下三类信息中的至少两项来确认身份:他们知道的东西、他们拥有的东西和他们本身的特征。多因素认证的一个例子是银行自动取款机(ATM),它要求账户持有人的 PIN 码(他们知道的东西)和银行卡(他们拥有的东西)。另一个例子是使用生物识别技术来识别个人的设备,例如智能手机上的指纹扫描(他们本身的特征)。

对于网站,多因素认证通常意味着要求输入用户名和密码(用户知道的东西),并确认用户的智能手机上安装了认证器应用(用户拥有的东西)。每个用户在注册时需要将认证器应用与网站同步(通常通过拍摄屏幕上的二维码)。之后,应用会生成一个六位数的随机数字,用户需要在登录时提供该数字,才能成功登录,就像在图 9-3 中所示。

image

图 9-3:你的用户会喜欢输入六位数的数字。

这迫使攻击者既要知道受害者的凭证要获得受害者的智能手机才能破坏他们的账户,这种组合是非常不可能发生的。鉴于智能手机的普及,多因素认证的支持逐渐成为常态。如果你的网站涉及任何类型的财务处理,绝对应该实施多因素认证。幸运的是,许多代码库使得集成它变得相对简单。

实现和保护注销功能

如果你在网站上验证用户身份,别忘了添加一个功能,让他们也能退出网站。这看起来可能有些过时,因为用户似乎总是保持社交媒体的永久登录状态,但对于在共享设备上登录的用户来说,拥有注销功能是一个关键的安全考虑。许多家庭共享一台笔记本电脑或 iPad,公司也常常重复使用计算机和便携设备,所以一定要确保让用户能够注销!

你的注销功能应该清除浏览器中的会话 cookie,且如果你在服务器端存储了会话标识符,也应使其失效。这可以防止攻击者在事后截获会话 cookie,并尝试利用被窃取的 cookie 重新建立会话。清除会话 cookie 就像通过 HTTP 响应发送一个带有空值的 Set-Cookie 头部来清空会话参数一样简单。

防止用户枚举

如果攻击者无法枚举用户(即通过测试用户名列表中的每个用户名来查看其是否存在于你的网站上),那么你可以降低攻击者危及你的认证系统的风险。攻击者经常使用从先前泄露的密码中获得的凭据,尝试验证这些用户名是否存在于目标网站上。在缩小列表范围后,他们会尝试对匹配的用户名进行密码猜测。

防止潜在的枚举漏洞

登录页面通常允许攻击者确定用户名是否已在网站上注册。如果页面对错误密码的错误信息与对未知用户的错误信息不同,攻击者便能从响应中推测出某些用户名是否对应你网站上存在的帐户。为了避免泄露这类信息,重要的是保持错误信息的通用性。例如,无论用户名是否未被识别,或者密码是否错误,都可以使用错误信息用户名或密码错误

攻击者还可能通过时延攻击来枚举用户,方法是通过测量 HTTP 响应时间。哈希密码是一个耗时操作;尽管通常不到一秒钟,但依然是一个可测量的时间。如果你的网站只在用户输入有效用户名时计算密码哈希,攻击者可以通过测量略微更慢的响应时间来推断哪些帐户在网站上存在。确保你的网站即使在无效用户名的情况下也进行密码哈希计算。

你应该防止密码重置页面透露某个用户名是否存在。如果攻击者点击“忘记密码”链接并输入电子邮件地址请求密码重置链接,页面上的响应信息不应透露是否发送了重置邮件。这可以防止攻击者得知该电子邮件地址是否与网站上的帐户相关联。保持消息中立,例如:请检查您的收件箱

实现验证码

你还可以通过实现验证码(完全自动化公共图灵测试区分计算机与人类)来化解用户枚举攻击,验证码要求网页用户完成一些对人类来说简单、对计算机来说棘手的图像识别任务。图 9-4 中所示的验证码,使得攻击者无法通过黑客脚本滥用网页。

验证码并不完美。攻击者可以通过使用复杂的机器学习技术或支付人工用户代替他们完成任务来绕过验证码。然而,验证码通常足够可靠,能有效威慑大多数黑客攻击,而且你可以轻松地将其添加到网站上。例如,谷歌实现了一个名为 reCAPTCHA 的验证码小部件,你可以通过几行代码将其安装到你的网站上。

image

图 9-4:某些任务对计算机来说实在是太难完成了。

总结

黑客经常试图攻击你的认证系统,企图窃取用户的凭证。为了保护你的网站,你可以使用第三方认证系统,如 Facebook 登录或单点登录身份提供者。

如果你正在实现自己的认证系统,你需要让用户在注册时选择用户名和密码。你还应该为每个用户存储并验证电子邮件地址。除非你需要用户有可见的显示名称,否则使用电子邮件作为用户名是合理的。

验证电子邮件地址的唯一可靠方法是发送一封包含唯一临时验证令牌的链接的电子邮件,用户点击后你的网站可以检查此令牌。对于忘记密码的用户,密码重置机制应采用相同的方式。密码重置邮件和初始验证邮件应在一段时间后超时,且在第一次使用后失效。

在存储密码之前,你应该使用加密哈希算法处理密码。你还应该为密码哈希加盐,以防止彩虹表攻击。

如果你的网站托管敏感数据,考虑添加多因素认证。确保在你的网站上某处包含注销功能。保持登录失败消息的通用性,避免黑客枚举你网站上的用户名。

在下一章中,你将调查用户在登录后如何可能因为会话被攻击者窃取而导致账户被入侵。

第十章:会话劫持

image

当一个网站成功验证用户身份时,浏览器和服务器会打开一个会话。会话是一个 HTTP 会话,其中浏览器发送一系列与用户操作对应的 HTTP 请求,web 服务器识别它们来自同一个已验证的用户,而无需用户在每个请求中重新登录。

如果黑客能够访问或伪造浏览器发送的会话信息,他们就能够访问你网站上任何用户的账户。幸运的是,现代的网络服务器包含了安全的会话管理代码,这使得攻击者几乎不可能操控或伪造会话。然而,即便服务器的会话管理功能没有漏洞,黑客仍然可以在会话进行中窃取其他用户的有效会话;这被称为会话劫持

会话劫持漏洞通常比前一章讨论的身份验证漏洞更具风险,因为它们允许攻击者访问任何用户的账户。这是一个极具诱惑力的前景,黑客们已经找到了许多劫持会话的方法。

在本章中,你将首先了解网站如何实现会话管理。然后,你将了解黑客劫持会话的三种方式:窃取 Cookie、会话固定和利用弱会话 ID。

会话的工作原理

要理解攻击者如何劫持会话,首先你需要了解用户与 web 服务器打开会话时发生了什么。

当用户在 HTTP 下进行身份验证时,web 服务器会在登录过程中为其分配一个会话标识符。会话标识符(会话 ID)—通常是一个大而随机生成的数字—是浏览器在每次随后的 HTTP 请求中需要传输的最小信息,以便服务器能够继续与已验证的用户进行 HTTP 会话。web 服务器识别随每个请求提供的会话 ID,将其映射到相应的用户,并代表该用户执行操作。

请注意,会话 ID 必须是临时分配的值,并且与用户名不同。如果浏览器使用的会话 ID 仅仅是用户名,黑客就可以假装成任何他们想要的用户。设计上,只有极少数可能的会话 ID 应该与服务器上有效的会话相对应。(如果不是这样,网络服务器就表现出一个弱会话漏洞,我们将在本章后面讨论这个问题。)

除了用户名外,web 服务器通常会将其他会话状态与会话 ID 一起存储,包含有关用户最近活动的相关信息。例如,会话状态可能包含用户访问过的页面列表,或当前放入购物车的商品。

现在我们了解了当用户和 web 服务器打开会话时发生了什么,让我们来看看网站是如何实现这些会话的。通常有两种常见的实现方式,通常被描述为服务器端会话和客户端会话。让我们回顾一下这些方法是如何工作的,这样你就能看到漏洞发生的地方。

服务器端会话

在传统的会话管理模型中,web 服务器将会话状态保存在内存中,且 web 服务器和浏览器来回传递会话标识符。这被称为服务器端会话。Listing 10-1 展示了 Ruby on Rails 实现的服务器端会话。

# Get a session from the cache.
def find_session(env, sid)
  unless sid && (session = @cache.read(cache_key(sid))❸)
 sid, session = generate_sid❶, {}
  end
  [sid, session]
end

# Set a session in the cache.
def write_session(env, sid, session, options)
  key = cache_key(sid)
  if session
  ❷ @cache.write(key, session, expires_in: options[:expire_after])
  else
    @cache.delete(key)
  end
  sid
end

Listing 10-1: Ruby on Rails 使用会话 ID (sid) 实现服务器端会话。

会话对象在 ❶ 创建,写入服务器内存 ❷,然后从内存重新加载 ❸。

从历史上看,web 服务器曾尝试过多种方式传递会话 ID:要么在 URL 中,要么作为 HTTP 头部,要么在 HTTP 请求的主体中。到目前为止,web 开发社区普遍认同的最常见(且可靠)机制是将会话 ID 作为会话 cookie 发送。当使用会话 cookies时,web 服务器会在 HTTP 响应的 Set-Cookie 头部返回会话 ID,浏览器则使用 Cookie 头部将相同的信息附加到随后的 HTTP 请求中。

自从 1995 年 Netscape 首次引入 cookies 以来,cookies 就一直是超文本传输协议的一部分。与 HTTP 原生身份验证不同,它们几乎被所有网站使用。(由于欧盟的立法,你应该对此非常了解:根据欧盟法律,网站必须通知你它们正在使用 cookies。)

服务器端会话已被广泛实现,通常非常安全。然而,它们确实有扩展性限制,因为 web 服务器必须将会话状态保存在内存中。

这意味着,在身份验证时,只有一个 web 服务器会知道已经建立的会话。如果随后的相同用户的 web 请求被定向到不同的 web 服务器,新的 web 服务器需要能够识别回访的用户,因此 web 服务器需要有一种共享会话信息的方式。

通常,这需要将会话状态写入共享缓存或数据库中,每次请求时,且每个 web 服务器在收到新的 HTTP 请求时都需要读取缓存的会话状态。这两者都是时间和资源消耗型的操作,可能会限制拥有大量用户群体的网站的响应能力,因为每增加一个用户,就会给会话存储带来显著的负担。

客户端会话

由于服务器端会话被证明难以扩展到大型网站,Web 服务器开发者发明了客户端会话。实现客户端会话的 Web 服务器会将所有会话状态放入 cookie,而不是仅仅在 Set-Cookie 头中传递会话 ID。服务器在设置会话状态到 HTTP 头之前,会将会话状态序列化为文本。通常,Web 服务器会将会话状态编码为 JavaScript 对象表示法(JSON),并在返回服务器时进行反序列化。列表 10-2 展示了 Ruby on Rails 实现客户端会话的一个示例。

def set_cookie(request, session_id, cookie)
  cookie_jar(request)[@key] = cookie
end

def get_cookie(req)
  cookie_jar(req)[@key]
end

def cookie_jar(request)
  request.cookie_jar.signed_or_encrypted
end

列表 10-2:Ruby on Rails 代码将会话数据存储为客户端 cookie

通过使用客户端会话,网站的 Web 服务器不再需要共享状态。每个 Web 服务器都有重新建立会话所需的所有信息,只要接收到 HTTP 请求。这在你试图扩展到成千上万的并发用户时是一个巨大的优势!

然而,客户端会话确实会带来一个明显的安全问题。对于客户端会话的简单实现,恶意用户可以轻松地篡改会话 cookie 的内容,甚至完全伪造它们。这意味着 Web 服务器必须以一种防止干扰的方式来编码会话状态。

一种保护客户端会话 cookie 的常见方式是,在发送给客户端之前加密序列化后的 cookie。然后,当浏览器返回该 cookie 时,Web 服务器解密它。这种方法使得会话状态在客户端完全不可见。任何试图篡改或伪造 cookie 的行为都会破坏已编码的会话,并使 cookie 无法读取。服务器将简单地注销恶意用户,并将其重定向到错误页面。

另一种稍微轻量级的保护会话 cookie 的方法是,在发送 cookie 时为其添加数字签名。数字签名充当某些输入数据的唯一“指纹”——在这种情况下,就是序列化的会话状态——任何人只要拥有最初用于生成签名的签名密钥,就可以轻松地重新计算它。对 cookie 进行数字签名可以让 Web 服务器检测到尝试篡改会话状态的行为,因为如果有篡改,服务器会计算出不同的签名值,并拒绝该会话。

相比加密 cookie,签名 cookie 仍然允许一个好奇的用户在浏览器调试器中读取会话数据。如果你在存储关于用户的数据——比如跟踪信息——并且不希望他们看到这些数据时,请牢记这一点!

攻击者如何劫持会话

现在我们已经讨论了会话以及网站如何实现它们,接下来看看攻击者如何劫持会话。攻击者通常通过三种主要方法来劫持会话:cookie 偷窃、会话固定攻击以及利用弱会话 ID。

随着 cookies 的广泛使用,攻击者通常通过窃取经过身份验证的用户的 Cookie 头的值来实现会话劫持。攻击者通常通过三种技术之一窃取 cookies:在用户与网站交互时注入恶意 JavaScript(跨站脚本攻击)、嗅探网络流量以拦截 HTTP 头部(中间人攻击),或在用户已认证后触发意外的 HTTP 请求(跨站请求伪造)。

幸运的是,现代浏览器实现了简单的安全措施,可以保护您的会话 cookies 免受这三种技术的攻击。您只需通过向服务器返回的 Set-Cookie 响应头添加关键字,就可以启用这些安全措施,如清单 10-3 所示。

Set-Cookie: session_id=278283910977381992837; HttpOnly; Secure; SameSite=Lax

清单 10-3:通过关键字指令组合保护的会话 cookie 出现在 HTTP 响应中,防止会话劫持

让我们回顾一下三种 cookie 被盗的技术,以及可以缓解它们的关键字。

跨站脚本攻击

攻击者通常使用跨站脚本攻击(我们在第七章中详细讨论过)来窃取会话 cookie。攻击者会尝试利用注入到用户浏览器中的 JavaScript 来读取用户的 cookies,并将它们发送到攻击者控制的外部 Web 服务器。攻击者随后会在 Web 服务器的日志文件中收集这些 cookies,然后将 cookie 值复制粘贴到浏览器会话中——更可能的是,将它们添加到脚本中——以在被劫持用户的会话下执行操作。

为了通过跨站脚本攻击来防止会话劫持,请在 Set-Cookie 响应头中将所有 cookies 标记为 HttpOnly。这会告诉浏览器不要将 cookies 提供给 JavaScript 代码。在 Set-Cookie 响应头中附加 HttpOnly 关键字,如清单 10-4 所示。

 Set-Cookie: session_id=278283910977381992837; HttpOnly

清单 10-4:将您的 cookies 标记为 HttpOnly,以防止 JavaScript 访问它们。

允许客户端 JavaScript 访问 cookies 的理由非常少,因此这种方法几乎没有什么缺点。

中间人攻击

攻击者还可以通过使用中间人攻击来窃取 cookies:攻击者找到一种方法,坐在浏览器和 Web 服务器之间,读取来回传输的网络流量。为了防止通过中间人攻击窃取 cookies,您的网站应该使用 HTTPS。您将在第十三章中学习如何启用 HTTPS。

在您启用 Web 服务器上的 HTTPS 后,您应该将 cookies 标记为 Secure,如清单 10-5 所示,这样浏览器就会知道永远不会通过 HTTP 发送未加密的 cookies。

Set-Cookie: session_id=278283910977381992837; Secure

清单 10-5:将 cookies 标记为 secure 意味着将 Secure 关键字添加到 Set-Cookie 响应头中。

大多数 web 服务器被配置为响应 HTTP 和 HTTPS,但会将 HTTP URL 重定向到 HTTPS 对应的 URL。将 cookies 标记为 Secure 会在重定向发生之前,防止浏览器传输 cookie 数据。

跨站请求伪造

攻击者劫持会话的最终方式是通过 跨站请求伪造(在第八章中详细介绍)。使用 CSRF 的攻击者不需要访问用户的会话 cookie。相反,他们只需要诱使受害者点击指向你网站的链接。如果用户已经在你的网站上保持会话,浏览器会随着链接触发的 HTTP 请求一起发送会话 cookie,这可能导致用户无意中执行某些敏感操作(例如点赞攻击者试图推广的项目)。

为了防止 CSRF 攻击,你可以在 cookies 中标记 SameSite 属性,这会指示浏览器仅在由 的网站生成的 HTTP 请求中发送会话 cookies。浏览器会从其他 HTTP 请求中剥离会话 cookies,比如那些通过点击电子邮件中的链接触发的请求。

SameSite 属性有两个设置:StrictLax。如示例 10-6 所示,Strict 设置的优点是从所有由外部网站触发的 HTTP 请求中剥离 cookies。

Set-Cookie: session_id=278283910977381992837; SameSite=Strict

示例 10-6:Strict 设置将剥离从外部网站生成的请求中的 cookies。

Strict 设置如果用户通过社交媒体分享你的内容,可能会让人感到烦恼,因为该设置会强制任何点击链接的人重新登录才能查看内容。为了解决这个问题,你可以通过使用 SameSite=Lax 设置,配置浏览器仅在 GET 请求中允许 cookies,如示例 10-7 所示。

Set-Cookie: session_id=278283910977381992837; SameSite=Lax

示例 10-7:Lax 设置可以在社交媒体上无痛地分享链接,同时仍然防止通过 CSRF 劫持会话的攻击。

这个 SameSite=Lax 设置指示浏览器将 cookies 附加到传入的 GET 请求中,同时从其他类型的请求中剥离 cookies。因为网站通常通过 POSTPUTDELETE 请求执行敏感操作(例如写内容或发送消息),所以攻击者无法诱使受害者执行这些类型的敏感操作。

会话固定

在互联网的早期历史中,许多浏览器没有实现 cookies,因此 web 服务器找到了其他传递会话 ID 的方法。最流行的方法是 URL 重写——将会话 ID 附加到用户访问的每个 URL 后面。直到今天,Java Servlet 规范仍描述了当 cookies 不可用时,开发人员如何将会话 ID 添加到 URL 的末尾。示例 10-8 展示了一个包含会话 ID 的 URL 重写示例。

http://www.example.com/catalog/index.html;jsessionid=1234

示例 10-8:一个传递会话 ID 1234 的 URL 示例

现在所有浏览器都支持 cookie,因此 URL 重写已经成为一种过时的做法。然而,遗留的网页堆栈可能仍然配置为接受这种方式的会话 ID,这就引入了几个重大的安全问题。

首先,将会话 ID 写入 URL 会导致它们在日志文件中泄漏。攻击者如果访问到你的日志文件,只需将这些类型的 URL 放入浏览器,就可以劫持用户的会话。

第二个问题是一个名为会话固定(session fixation)的漏洞。当存在会话固定漏洞的网页服务器在 URL 中遇到一个未知的会话 ID 时,它们会要求用户进行身份验证,然后在提供的会话 ID 下建立一个会话。

这使得黑客能够预先固定会话 ID,向受害者发送诱人的链接(通常是在未经请求的电子邮件或站点评论区的垃圾邮件中),链接中包含了固定的会话 ID。任何点击该链接的用户都会被劫持会话,因为攻击者只需在自己的浏览器中使用相同的 URL,在预先固定的会话 ID 下进行操作。点击链接并登录的动作会将虚假的会话 ID 转换为一个真实的会话 ID——一个黑客已经知道的 ID。

如果你的网页服务器支持将 URL 重写作为会话跟踪的方式,你应该通过相关的配置选项禁用它。它没有任何实际用途,并且使你暴露在会话固定攻击下。列表 10-9 展示了如何通过编辑web.xml配置文件来禁用 Apache Tomcat 7.0 版本中的 URL 重写。

<session-config>
     <tracking-mode>COOKIE</tracking-mode>
</session-config>

列表 10-9:指定使用 COOKIE 模式的会话跟踪,在 Apache Tomcat 7.0 中将禁用 URL 重写。

利用弱会话 ID

正如我们之前讨论的,如果攻击者获得了会话 ID,他们可以劫持用户的会话。攻击者可以通过窃取会话 cookie 或通过预先为支持 URL 重写的服务器固定会话来实现这一点。然而,更为粗暴的方法是直接猜测会话 ID。由于会话 ID 通常只是数字,如果这些数字足够小或可预测,攻击者可以编写脚本来枚举可能的会话 ID,并与网页服务器进行测试,直到找到有效的会话。

生成真正的随机数在软件中是很困难的。大多数随机数生成算法使用环境因素(例如系统的时钟时间)作为种子来生成随机数。如果攻击者能够确定足够的种子值(或者将其减少到合理的潜在值范围),他们可以枚举可能的有效会话 ID 并与服务器进行测试。

早期版本的标准 Apache Tomcat 服务器被发现容易受到这种类型的攻击。安全研究人员发现,随机会话 ID 生成算法的种子是系统时间和内存中的对象的哈希码。研究人员能够利用这些种子来缩小潜在的输入值范围,从而可靠地猜测出会话 ID。

查阅你的 web 服务器文档,确保它使用无法猜测的大型会话 ID,这些 ID 应由强大的随机数生成算法生成。由于安全研究人员常常在攻击者利用之前发现弱的会话 ID 算法,因此你还需要关注安全公告,及时了解何时需要修补 web 堆栈中的漏洞。

总结

当一个网站成功验证用户身份时,浏览器和服务器之间会建立一个会话。会话状态可以存储在服务器端,或者以加密或数字签名的 cookie 形式存储在客户端。

黑客会尝试窃取你的会话 cookie,因此你应该确保它们得到保护。为了防止通过跨站脚本攻击(XSS)进行会话劫持,确保你的 cookie 对 JavaScript 代码不可访问。为了防止通过中间人攻击(MITM)进行会话劫持,确保你的 cookie 仅通过 HTTPS 连接传递。为了防止通过跨站请求伪造(CSRF)进行会话劫持,确保在跨站请求中去除 cookie。你可以通过在 HTTP 响应中的 Set-Cookie 头部使用 HttpOnlySecureOnlySameSite 这几个关键词来添加这些保护。

较旧的 web 服务器可能容易受到会话固定攻击,因此一定要禁用通过 URL 重写传递会话 ID 的方式。有时,web 服务器会使用可猜测的会话 ID,因此要时刻关注软件堆栈的安全公告,并根据需要进行修补。

在下一章中,你将学习如何正确实现访问控制,以便防止恶意用户访问你的内容或执行他们不应执行的操作。

第十一章:权限

image

网站上的用户通常会拥有不同级别的权限。例如,在内容管理系统中,一些用户是管理员,拥有编辑站点内容的权限,而大多数用户只能查看和互动内容。社交媒体网站的权限更加复杂:用户可以选择只与朋友分享某些内容,或将个人资料设置为私密。对于网页邮件网站,每个用户应只能访问自己的电子邮件!重要的是,您需要在站点上正确且统一地执行这些权限,否则用户将失去对您的信任。

Facebook 在 2018 年 9 月遭遇了一次灾难性的用户权限失败,当时黑客利用其视频上传工具中的漏洞生成了访问令牌。该站点最多有 5000 万个用户帐户受到影响。黑客窃取了用户的私人资料,如用户名、电子邮件和电话号码。Facebook 修复了该漏洞,发布了安全公告,并通过媒体进行道歉。然而,这一事件发生在包含许多关于 Facebook 商业行为不利报道的一年末,公司股价也受到了重创。

Facebook 的黑客攻击是一个特权升级的例子,其中恶意用户篡夺了其他用户的权限。确保您的站点安全,并为每个用户正确应用权限的过程称为实施访问控制。本章涵盖了这两个概念,并介绍了一种黑客常用的、利用访问控制不足的方法,称为目录遍历

特权升级

安全专家将特权升级攻击分为两类:垂直升级和水平升级。

垂直升级中,攻击者获得比自己权限更广泛的帐户访问权限。如果攻击者能够在您的服务器上部署web shell(一个可执行脚本,它获取 HTTP 请求的元素并在命令行上运行),他们的首要目标之一将是升级到root 权限,以便在服务器上执行任何他们想做的操作。通常,发送到 web shell 的命令将在与 web 服务器运行相同的操作系统帐户下执行,该帐户通常具有有限的网络和磁盘访问权限。黑客已经找到了许多在操作系统上执行垂直升级攻击的方法,试图获得 root 权限——这使他们可以通过 web shell 感染整个服务器。

横向升级攻击中,攻击者访问另一个与自己权限相似的账户。在前几章中,我们讨论了执行这种类型攻击的常见方法:猜测密码、劫持会话或恶意构造 HTTP 请求数据。2018 年 9 月的 Facebook 黑客事件就是一个横向升级的例子,由于 API 在没有正确验证用户权限的情况下发放了访问令牌。

为了保护你的站点免受升级攻击,你需要为所有敏感资源安全地实施访问控制。让我们来讨论一下如何做到这一点。

访问控制

你的访问控制策略应涵盖三个关键方面:

身份验证 在用户返回站点时正确识别用户

授权 在用户确认身份后,决定哪些操作是他们应该或不应该执行的

权限检查 在用户尝试执行某个操作时,评估其授权情况

第九章和第十章详细讨论了身份验证;你了解了如何通过保护登录功能和会话管理来可靠地判断是哪个用户发出了 HTTP 请求。然而,在此之后,你仍然需要确定每个用户可以执行哪些操作,而这就是一个更开放性的问题。

一个好的访问控制策略包括三个阶段:设计授权模型、实现访问控制和测试访问控制。完成这些后,你还可以添加审计追踪,确保没有遗漏常见的疏忽。让我们详细了解每个阶段。

设计授权模型

在软件应用中,有几种常见的方式来建模授权规则。当你设计授权模型时,重要的是记录你将如何将所选模型应用到你的用户。没有一套公认的规则,难以定义“正确”的实现方式。

鉴于此,让我们看看一些常见的授权规则建模方式。

访问控制列表

访问控制列表(ACLs) 是一种简单的授权建模方式,它将一个权限列表附加到系统中的每个对象上,指定每个用户或账户可以在该对象上执行的操作。基于 ACL 模型的典型示例是 Linux 文件系统,它可以单独授予每个用户对每个文件和目录的读、写或执行权限。大多数 SQL 数据库也实现了基于 ACL 的授权——你用来连接数据库的账户决定了你可以读取或更新哪些表,或者是否可以更改表结构。

白名单和黑名单

一种更简单的授权建模方式是使用白名单或黑名单。白名单描述了可以访问特定资源的用户或账户,并禁止所有其他用户。黑名单明确描述了被禁止访问某个资源的用户或账户,这意味着该资源应该对任何其他用户或账户开放。垃圾邮件过滤器通常使用白名单和黑名单来区分电子邮件地址,决定电子邮件应用是否应该直接将其发送到垃圾邮件文件夹,或永远不予处理。

基于角色的访问控制

最全面的授权模型可能是基于角色的访问控制(RBAC),它授予用户角色,或者将用户添加到已授予特定角色的中。系统中的策略定义了每个角色如何与特定的对象——计算系统中的资源——进行交互。

一个简单的 RBAC 系统可能通过将某些用户添加到管理员组中来指定其为管理员,这样就会赋予他们管理员角色。然后,一个策略会允许具有管理员角色的用户或组编辑你网站上的特定内容。

亚马逊网络服务的身份与访问管理(IAM)系统是一个综合角色基础系统的例子,微软的 Active Directory 也是如此。基于角色的访问控制非常强大,但常常容易变得复杂。策略可能互相矛盾,导致开发者需要解决的冲突,而且用户可能属于多个重叠关注点的组。在这种情况下,有时很难看清为什么系统会做出某些访问控制决策,或者为什么在特定情况下优先考虑某些规则。

基于所有权的访问控制

在社交媒体时代,围绕所有权组织访问控制规则变得越来越普遍,其中每个用户对他们上传的照片或创建的帖子拥有完全控制权。社交媒体用户本质上是自己内容的管理员:他们可以创建、上传、删除并控制自己帖子、评论、照片和故事的可见性。他们可以在照片等内容中标记其他用户,尽管这些其他用户可能需要在标签公开之前批准这些标签。在社交媒体网站上,每种类型的内容都有隐含的隐私级别:互相评论通常是在公开场合进行的,而直接消息则是私密的(尽管你应该尝试向我祖母解释这一点)。

实现访问控制

选择了授权模型并定义了站点的访问规则后,你需要在代码中实现它们。你应当尝试将访问控制决策集中化到代码库中,这样在代码审查时就能更轻松地根据设计文档验证它们。你不一定需要让所有访问决策都流经同一代码路径,但有一个标准的访问控制决策评估方法是很重要的。

实现授权规则有很多方法:使用函数或方法装饰器(给函数打上特定权限级别的标签)、URL 检查(例如,将敏感路径前缀设置为/admin),或在代码中插入内联断言。有些实现会将访问控制决策委托给专门的权限组件或内部 API。示例 11-1 展示了如何在 Python 函数中添加权限检查。

   from django.contrib.auth.decorators import login_required, permission_required

❶ @login_required
❷ @permission_required('content.can_publish')
   def publish_post(request):
       # Publish a post to the front page.

示例 11-1:在 Python 中使用 django 网络服务器检查权限

在允许用户发布帖子之前,网络服务器要求用户已登录 ❶ 且具有发布内容的权限 ❷。

示例 11-2 展示了如何在 Ruby 中使用 pundit 库内联检查权限。

def publish
  @post = Post.find(params[:id])
❶ authorize @post, :update?
  @post.publish!
  redirect_to @post
end

示例 11-2:使用 Ruby 中的 pundit 库检查权限

方法调用 ❶ 会询问库当前登录的用户是否有权限更新由 @post 对象描述的社交媒体帖子。

无论你使用什么方法实现权限检查,务必确保基于经过适当审查的身份数据做出访问控制决策。不要依赖 HTTP 请求中除了会话 cookie 之外的任何内容来推断哪个用户正在访问某个资源以及他们具有什么权限!恶意用户可以篡改请求中的其他内容,从而进行特权升级攻击。

测试访问控制

彻底测试访问控制系统非常重要。确保你的测试程序确实尝试找到访问控制方案中的漏洞;如果你像攻击者一样对待它,当你首次遇到真实攻击时,你会更好地准备。

编写单元测试,声明谁可以访问特定资源,更重要的是,谁不应该能够访问这些资源。养成在向站点添加新功能时编写描述访问控制规则的单元测试的习惯。如果你的站点有管理界面,这一点尤其重要,因为它们是攻击者在黑客攻击网站时常利用的后门。示例 11-3 展示了一个简单的 Ruby 单元测试,声明用户必须登录才能执行敏感操作。

class PostsTest < ApplicationSystemTestCase
  test "users should be redirected to the login page if they are not logged in" do
     visit publish_post_url
     assert_response :redirect
     assert_selector "h1", text: "Login"
  end
end

示例 11-3:一个 Ruby 单元测试,检查未授权用户在尝试发布帖子时是否会被重定向到登录页面

最后,如果你有时间和预算,考虑聘请一个外部团队进行渗透测试。该团队可以探测是否存在缺失或错误的访问控制规则,攻击者可能会利用这些漏洞。

添加审计轨迹

因为你的代码将在用户访问资源时识别用户并测试他们的授权级别,所以你应该添加审计轨迹以帮助故障排除和取证分析。审计轨迹 是在用户执行操作时记录的日志文件或数据库条目。简单地在用户浏览你的网站时添加日志语句(例如:14:32:06 2019-02-05: 用户 example@gmail.com 登录)可以帮助你诊断运行时发生的问题,并在遭受攻击时提供关键证据。

避免常见疏忽

你在许多网站上看到的一个常见疏忽是,网站没有对不打算被发现的资源进行访问控制。人们容易认为你网站上没有被其他地方链接到的页面会对黑客隐藏,因为这些页面在黑客爬取你的网站时不会被突出显示。但事实并非如此。

黑客工具可以快速枚举具有不透明 ID 的私人 URL,例如 http://example.com/item?id=423242,而且更容易访问结构可以猜测的私人 URL,例如 http://example.com/profiles/user/bob。依赖攻击者无法猜测 URL 的方式被称为 安全通过模糊,这种方式被视为一种风险。

保护敏感资源对于那些设计为 禁止 资源的站点尤其重要,这些资源必须在特定时间点才可访问。财务报告网站通常在这种限制下运作。上市公司必须在事先约定的报告渠道内,向所有投资者同时发布季度或半年财务报告。

一些网站会提前上传这些报告(例如,上传到格式为 /reports// 的 URL),有些欺诈性投资者已经知道会提前查看这些 URL,以便在市场其他人查看之前就能访问到报告。金融监管机构曾因访问逻辑错误而对公司处以巨额罚款!确保你的访问控制规则考虑到任何时间要求。

网站上的每个敏感资源都需要访问控制。如果你的网站允许用户下载文件,黑客可能会尝试访问他们不应被允许下载的文件,使用一种被称为目录遍历的黑客方法。让我们来看一下如何操作。

目录遍历

如果你的网站的 URL 中包含描述文件路径的参数,攻击者可能会利用目录遍历绕过你的访问控制规则。在目录遍历攻击中,攻击者操控 URL 参数,以访问你本不希望被访问的敏感文件。目录遍历攻击通常涉及将 URL 参数替换为相对文件路径,该路径使用../语法“爬出”当前目录。让我们分解一下这一过程是如何运作的。

文件路径和相对文件路径

在大多数文件系统中,每个文件的位置可以通过文件路径来描述。例如,在 Linux 上,文件路径/tmp/logs/web.log通过列举包含文件的目录(在此案例中,是顶级tmp目录中的logs目录),并由路径分隔符字符(/)连接。

相对文件路径是一个以点(.)字符开头的文件路径,表示它位于当前目录;相对路径./web.log表示文件web.log位于当前目录。什么被视为“当前”目录取决于路径评估的上下文。例如,在命令行提示符下,当前目录是用户最近导航到的目录。

相对路径同样使用..语法来引用包含当前目录的目录。使用..语法两次,表示引用当前目录的父目录的父目录。例如,文件系统将路径../../etc/passwd解释为要求向上移动两个目录,找到一个名为etc的目录,然后返回该目录中的passwd文件。使用相对路径类似于描述亲戚:你的叔叔是你祖父母的儿子,因此要找到他,就得在你的家谱中回溯两代,然后寻找一位男性后代。

如果你的服务器端代码允许攻击者传递并评估相对文件路径来替代文件名,他们可以探查你的文件系统,寻找看起来有趣的文件,从而破坏访问控制。相对路径语法让攻击者可以读取网页服务器主目录之外的文件,进而探查常存储密码或配置文件的目录,并读取其中的数据。让我们来看一个这样的攻击示例。

目录遍历攻击的结构

假设你有一个网站,托管着存储在服务器文件系统中的餐厅菜单 PDF 文件。你的站点邀请用户通过点击一个引用文件名的链接来下载每个 PDF,如图 11-1 所示。

image

图 11-1:一个允许下载文件的网站

如果文件名参数没有被安全地解释,攻击者可以在 URL 中用相对路径替代菜单文件名,从而访问服务器上的用户账户信息,如图 11-2 所示。

image

图 11-2:使用目录遍历攻击访问包含账户信息的 Unix 文件

在这个例子中,黑客将menu参数中的菜单名称替换为相对路径(../../../../etc/passwd),以便下载一个敏感文件。读取passwd文件会告诉攻击者在底层 Linux 操作系统中存在哪些用户账户,揭示出敏感的系统信息,帮助攻击者入侵服务器。你当然不希望攻击者能够读取这种信息!让我们来看看如何化解目录遍历攻击。

缓解措施 1:信任你的 Web 服务器

为了保护自己免受目录遍历攻击,首先要了解你的 Web 服务器如何解析静态内容 URL。几乎所有网站都会以某种方式将 URL 转换为文件路径——通常在服务器响应静态内容请求时,如 JavaScript 文件、图片或样式表。如果你发现自己需要提供一些更为特殊类型的静态文件(例如,餐厅菜单),尝试使用 Web 服务器的内建 URL 解析逻辑,而不是自己编写。你的 Web 服务器的静态托管功能通常已经经过严格测试,并能有效防范目录遍历攻击。

缓解措施 2:使用托管服务

如果你提供的文件不是你代码库的一部分,可能是因为用户或网站管理员上传了它们,你应该强烈考虑将这些文件托管在内容分发网络、云存储或内容管理系统中。这些软件类型不仅可以缓解文件上传漏洞,如第六章所讨论的那样,还能通过允许你使用安全的 URL 或不透明的文件标识符来引用文件,从而化解目录遍历攻击。在这些替代方案中,CDN 通常允许的权限较粗粒度(例如,如果某些文件只需对特定用户可用),但通常也是最容易集成的。

缓解措施 3:使用间接文件引用

如果你编写自己的代码从本地磁盘提供文件,那么化解目录遍历攻击最安全的方法是通过间接性:你为每个文件分配一个不透明 ID,该 ID 对应一个文件路径,然后让所有 URL 通过该 ID 引用每个文件。这需要你保持某种注册表,将每个文件 ID 与路径配对,比如保存在数据库中。

缓解措施 4:清理文件引用

最后,如果您确实在 URL 中使用直接的文件引用——也许是因为您继承了遗留代码库,且没有足够的时间或资源来重构文件存储方式——那么您需要确保对站点代码进行安全处理,以确保无法传递任意路径来替代文件名。最安全的方法是简单地禁止任何包含路径分隔符字符的文件引用,包括编码过的分隔符字符。(请注意,基于 Windows 和 Unix 的操作系统使用不同的路径分隔符:分别是 ** 和 /。)

另一种方法是使用正则表达式(regex)验证文件名,以过滤掉任何看起来像路径语法的内容。所有现代 Web 编程语言都包含某种类型的正则表达式实现,因此可以轻松地将传入的文件名参数与“安全”的表达式进行匹配。但要小心这种技术:黑客不断研究新的、晦涩的路径编码方式,因为目录遍历攻击非常普遍。如果可能,尝试使用第三方库来清理文件名。清单 11-4 展示了 Ruby Sinatra gem 中清理路径参数的逻辑。

def cleanup(path)
   parts     = []
❶ unescaped = path.gsub(/%2e/i, dot).gsub(/%2f/i, slash).gsub(/%5c/i, backslash)
   unescaped = unescaped.gsub(backslash, slash)

❷ unescaped.split(slash).each do |part|
     next if part.empty? or part == dot
     part == '..' ? parts.pop : parts << part
   end

❸ cleaned = slash + parts.join(slash)
   cleaned << slash if parts.any? and unescaped =~ %r{/\.{0,2}$}
   cleaned
end

清单 11-4:在 Sinatra Ruby gem 中清理路径参数的逻辑

首先,代码标准化它识别出的任何模糊字符编码 ❶。然后,它将路径拆分为单独的组件 ❷。最后,它使用标准分隔符 ❸ 重新构建路径,确保开头字符为斜杠。

清单 11-4 中所示的复杂性是必要的,因为在目录遍历攻击中,相对路径可以以多种方式进行编码。清单 11-5 展示了在不同操作系统上,父目录语法的八种编码方式。

../
..\
..\/
%2e%2e%2f
%252e%252e%252f
%c0%ae%c0%ae%c0%af
%uff0e%uff0e%u2215
%uff0e%uff0e%u2216

清单 11-5:相对路径在不同操作系统中可以通过多种方式进行编码。Gulp。

总结

您网站的用户通常会有不同的权限级别,因此需要实现访问控制规则,在用户尝试访问资源时进行评估。访问控制规则需要清晰记录、全面实现,并进行严格测试。开发时间表应包括足够的缓冲时间,以便团队评估所有新代码更改的安全影响。

通过文件名引用的静态资源容易受到目录遍历攻击,这是克服访问控制规则的一种常见方法。可以通过使用您的 Web 服务器现有的静态文件提供方法,或从安全的第三方系统提供静态文件,或者通过间接方式引用静态文件来防止目录遍历攻击。如果您必须使用文件名,请确保清理用于构建文件路径的任何 HTTP 参数。

在下一章中,您将了解一些可能暴露您网站使用的技术栈的方法,这会给黑客提供攻击的思路。

第十二章:信息泄露

image

黑客经常利用公开的安全漏洞,尤其是 零日漏洞——那些在过去 24 小时内被公开的安全缺陷。当某人发布了一个软件组件的零日漏洞后,黑客会立即扫描运行该漏洞软件的 web 服务器,以便利用这一安全漏洞。为了保护自己免受此类威胁,你应该确保 web 服务器不会泄露关于你所运行的软件栈的信息。如果你无意中暴露了服务器技术,你就是在给自己做靶子。

本章将介绍 web 服务器泄露关于你技术选择的一些常见方式,并提供如何缓解这些风险的方法。

缓解措施 1:禁用明显的服务器响应头

确保禁用 web 服务器配置中任何会暴露服务器技术、语言和版本的 HTTP 响应头。默认情况下,web 服务器通常会在每个响应中返回一个 Server 响应头,描述服务器端正在运行的软件。这对 web 服务器供应商来说是很好的广告,但浏览器并不会使用它。它只是告诉攻击者可以探测哪些漏洞。确保你的 web 服务器配置禁用此 Server 响应头。(或者,如果你想捉弄一下,可以让它报告错误的 web 服务器技术!)

缓解措施 2:使用干净的 URL

在设计网站时,避免 URL 中出现明显的文件后缀,比如 .php.asp.jsp。实现 干净的 URL ——不会泄露实现细节的 URL。带有文件扩展名的 URL 在旧版 web 服务器中比较常见,它们会明确引用模板文件名。确保避免使用这些扩展名。

web 服务器用来存储会话状态的 cookie 名称通常会泄露你的服务器端技术。例如,Java web 服务器通常将会话 ID 存储在一个名为 JSESSIONID 的 cookie 中。攻击者可以检查这些类型的会话 cookie 名称来识别服务器,如 列表 12-1 所示。

❶ if response.get_cookies.match(/JSESSIONID=(.*);(.*)/i)
   jsessionid = $1
   post_data  = "j_username=#{username}&j_password=#{password}"

   response = send_request_cgi({
                'uri'          => '/admin/j_security_check',
                'method'       => 'POST',
                'content-type' => 'application/x-www-form-urlencoded',
                'cookie'       => "JSESSIONID=#{jsessionid}",
                'data'         => post_data,
              })

列表 12-1:黑客工具 Metasploit 尝试检测并入侵 Apache Tomcat 服务器

请注意,Metasploit 代码检查了会话 cookie 的名称 ❶。

确保你的 web 服务器在 cookies 中不返回任何关于你技术栈的线索。修改配置,使用通用的会话 cookie 名称(例如,session)。

缓解措施 4:禁用客户端错误报告

大多数 web 服务器支持客户端错误报告,这允许服务器在错误页面的 HTML 中打印堆栈跟踪和路由信息。客户端错误报告在调试测试环境中的错误时非常有用。然而,堆栈跟踪和错误日志也会告诉攻击者你正在使用哪些模块或库,帮助他们挑选出可能的安全漏洞作为攻击目标。发生在数据访问层的错误甚至可能暴露数据库结构的细节,这会带来严重的安全隐患!

必须在生产环境中禁用客户端错误报告。你应该保持用户看到的错误页面完全通用。最多,用户应该知道发生了一个意外错误,并且有人正在处理这个问题。详细的错误报告应保存在生产日志和错误报告工具中,这些工具只有管理员可以访问。

查阅你 web 服务器的文档,了解如何禁用客户端错误报告。列表 12-2 展示了如何在 Rails 配置文件中禁用此功能。

  # Full error reports are disabled.
  config.consider_all_requests_local = false

列表 12-2:确保你的生产配置文件(通常存储在 config/environments/production.rb 中,适用于 Ruby on Rails)禁用客户端错误报告。

缓解措施 5:压缩或混淆你的 JavaScript 文件

许多 web 开发者在部署 JavaScript 代码之前使用压缩工具对其进行预处理,这种工具可以将 JavaScript 代码转换为功能等效但高度压缩的 JavaScript 文件。压缩工具会删除所有多余的字符(如空格),并将一些代码语句替换为更短的语义相同的语句。相关的工具是混淆器,它将方法和函数名替换为简短且无意义的标记,而不改变代码的行为,从而故意使代码变得不易读。流行的 UglifyJS 工具集成了这两种功能,可以通过命令行直接调用,语法为uglifyjs [输入文件],使其轻松融入到构建过程中。

开发者通常出于性能考虑,对 JavaScript 代码进行压缩或混淆,因为较小的 JavaScript 文件可以更快地加载到浏览器中。这种预处理还具有一个积极的副作用,即使攻击者更难检测出你正在使用哪些 JavaScript 库。研究人员或攻击者定期发现流行的 JavaScript 库中存在安全漏洞,这些漏洞可能允许跨站脚本攻击。让攻击者更难发现你使用的库,将为你争取更多的喘息空间,尤其是在漏洞被发现时。

缓解措施 6:清理你的客户端文件

进行代码审查并使用静态分析工具以确保敏感数据不会出现在评论中,或者死代码不会被传递到客户端,这是非常重要的。开发者容易在 HTML 文件、模板文件或 JavaScript 文件中留下过多的信息,因为我们常常忘记这些文件最终会被传送到浏览器。虽然压缩 JavaScript 代码可能会去除注释,但你需要在代码审查时在模板文件和手写的 HTML 文件中找到敏感注释并将其删除。

黑客工具使得攻击者能够轻松爬取你的网站,提取你不小心留下的任何评论——黑客常常使用这种技术扫描评论中不小心泄露的私密 IP 地址。当黑客试图攻击你的网站时,这通常是他们的第一步。

保持对安全公告的关注

即使你已经锁定了所有的安全设置,经验丰富的黑客仍然能够根据你所使用的技术做出合理的猜测。Web 服务器在响应特定边缘情况时有一些明显的行为,例如故意损坏的 HTTP 请求或带有不寻常 HTTP 动词的请求。黑客可以利用这些独特的服务器技术指纹来识别服务器端的技术栈。即使你在防止信息泄漏方面遵循了最佳实践,仍然需要保持对你使用的技术的安全公告的关注,并及时部署补丁。

总结

你应该确保你的 Web 服务器不会泄露关于你使用的软件堆栈的信息,因为黑客会利用这些信息来寻找攻击你网站的方法。确保你的配置禁用明显的头部信息,并在 HTTP 响应中使用通用的会话 cookie 名称。使用干净的 URL,避免包含文件名扩展名。压缩或混淆 JavaScript 代码,这样就更难判断你使用了哪些第三方库。在生产站点中关闭详细的客户端错误报告。确保清理你的模板文件和 HTML 中的评论,避免泄露过多信息。最后,保持对安全公告的关注,以便及时部署补丁。

在下一章,你将学习如何通过加密来保护你网站的流量。

第十三章:加密

image

加密是现代互联网的核心。没有私密、安全地交换数据包的能力,电子商务将无法存在,用户也无法安全地验证自己与互联网网站的身份。

安全超文本传输协议是目前网络上最广泛使用的加密形式。网页服务器和网页浏览器普遍支持 HTTPS,因此开发者可以将所有流量重定向到该协议,确保用户之间的通信安全。想要在自己的网站上使用 HTTPS 的网页开发者,只需要从证书授权机构获得一个证书,并与其托管服务提供商一起安装它。

尽管你可以轻松开始使用加密,但当网站与用户代理通过 HTTPS 进行交互时,发生的过程是非常复杂的。现代密码学——研究加密和解密数据的方法——依赖于由数学家和安全专业人员开发并积极研究的技术。幸运的是,互联网协议的抽象层意味着你不需要了解线性代数或数论就能使用它们的发现。但是,越是理解底层算法,你就越能预见潜在的风险。

本章首先概述了加密如何在互联网协议中使用以及支撑它的数学原理。一旦你对加密的工作原理有了充分的理解,你将回顾开发者需要采取的实际步骤,以开始使用 HTTPS。最后,你将了解黑客如何利用未加密或加密较弱的流量,以及一些攻击如何完全绕过加密。

互联网协议中的加密

回想一下,通过互联网发送的消息被分割成数据包,并通过传输控制协议(TCP)定向到最终目的地。接收方计算机将这些 TCP 数据包重新组装成原始消息。TCP 并不规定数据发送的如何被解释。为了实现这一点,两个计算机需要就如何解释发送的数据达成一致,使用如 HTTP 这样的高级协议。TCP 也不会掩盖发送的数据包内容。未加密的 TCP 对话容易受到中间人攻击,即恶意的第三方拦截并读取正在传输的数据包。

为了避免这种情况,浏览器和网页服务器之间的 HTTP 对话通过传输层安全性(TLS)来加密,这是一种提供隐私性(通过确保数据包不能被第三方解密)和数据完整性(通过确保任何试图篡改传输中的数据包都能被检测到)的方法。使用 TLS 进行的 HTTP 对话被称为HTTP 安全(HTTPS)对话。

当您的网页浏览器连接到一个 HTTPS 网站时,浏览器和 Web 服务器会协商使用哪些加密算法,这是TLS 握手的一部分——TLS 会话启动时发生的数据包交换。为了理解 TLS 握手过程中发生的事情,我们需要简要了解各种加密算法。现在是一些轻松的数学时刻!

加密算法、哈希和消息认证码

一个加密算法通过使用加密密钥(两个希望建立安全通信的方共享的秘密)对输入数据进行加扰处理——没有解密密钥(解密数据所需的相应密钥)的人无法解读加密后的输出。输入数据和密钥通常以二进制数据的形式编码,虽然为了可读性,密钥可能以文本字符串的形式表达。

存在许多加密算法,且数学家和安全研究人员不断发明新的算法。它们可以分为几类:对称和非对称加密算法(用于加密数据)、哈希函数(用于数据指纹和构建其他密码学算法)、以及消息认证码(用于确保数据完整性)。

对称加密算法

一个对称加密算法使用相同的密钥来加密和解密数据。对称加密算法通常作为分组密码工作:它们将输入数据分解成固定大小的块,这些块可以单独加密。(如果最后一个输入数据块大小不足,则会填充以填满块大小。)这使得它们适合处理数据流,包括 TCP 数据包。

对称算法的设计注重速度,但有一个主要的安全缺陷:解密密钥必须在接收方解密数据流之前提供给他们。如果解密密钥通过互联网共享,潜在的攻击者将有机会窃取该密钥,从而解密任何进一步的消息。这样不好。

非对称加密算法

为了应对解密密钥被盗的威胁,非对称加密算法应运而生。非对称算法使用不同的密钥来加密和解密数据。

非对称算法允许像 Web 服务器这样的软件公开其加密密钥,同时保留其解密密钥为秘密。任何希望向服务器发送安全消息的用户代理都可以使用服务器的加密密钥来加密这些消息,确保没有人(甚至是发送方自己!)能够解密所发送的数据,因为解密密钥是保密的。这有时被描述为公钥密码学:加密密钥(公钥)可以公开;只有解密密钥(私钥)需要保密。

非对称算法比对称算法复杂得多,因此也更慢。互联网协议中的加密使用了两种类型的算法的结合,正如你稍后在本章中将看到的那样。

哈希函数

与加密算法相关的是加密哈希函数,可以将其视为一种输出无法被解密的加密算法。哈希函数还有一些其他有趣的属性:算法的输出(哈希值)始终是固定大小的,无论输入数据的大小如何;而且,给定不同的输入值,得到相同输出值的概率极其微小。

为什么你会想要加密一些你之后无法解密的数据呢?好吧,这是生成输入数据“指纹”的一种巧妙方式。如果你需要检查两个不同的输入是否相同,但出于安全原因不想存储原始输入值,你可以验证这两个输入是否生成相同的哈希值。

这就是网站密码通常存储的方式,正如我们在第九章中看到的那样。当用户首次设置密码时,网站服务器会将密码的哈希值存储在数据库中,并故意忘记实际的密码值。当用户稍后再次输入密码时,服务器会重新计算哈希值并与存储的哈希值进行比较。如果两个哈希值不同,则表示用户输入了不同的密码,意味着该凭证应被拒绝。通过这种方式,网站可以检查密码的正确性,而无需明确知道每个用户的密码。(以明文形式存储密码是一种安全隐患:如果攻击者入侵数据库,他们将获得每个用户的密码。)

消息认证码

消息认证码(MAC)算法与加密哈希函数相似(通常是在加密哈希函数的基础上构建),它们将任意长度的输入数据映射到一个唯一且固定大小的输出。这个输出本身就是所谓的消息认证码。然而,MAC 算法比哈希函数更加专业化,因为重新计算 MAC 需要一个秘密密钥。这意味着只有拥有秘密密钥的参与方才能生成或检查消息认证码的有效性。

MAC 算法用于确保通过互联网传输的数据包不会被攻击者伪造或篡改。要使用 MAC 算法,发送方和接收方计算机会交换一个共享的秘密密钥——通常是作为 TLS 握手的一部分进行交换。(为了避免密钥被窃取,该秘密密钥会在发送前进行加密。)从此以后,发送方将为每个数据包生成一个 MAC,并将其附加到数据包上。由于接收方计算机拥有相同的密钥,它可以从消息中重新计算 MAC。如果计算得到的 MAC 与附加在数据包上的值不一致,则说明该数据包已经被篡改、损坏,或它并非由原计算机发送。因此,接收方会拒绝该数据包。

如果你已经看到这里并且仍在关注,恭喜你!密码学是一个庞大而复杂的主题,具有自己特有的术语。理解它如何融入互联网协议需要同时平衡多个概念,因此感谢你的耐心。接下来,我们来看一下我们讨论过的各种类型的加密算法是如何在 TLS 中使用的。

TLS 握手

TLS 使用一组合适的加密算法来高效、安全地传输信息。为了提高速度,大多数通过 TLS 传输的数据包将使用常见的对称加密算法,也称为 块加密算法,因为它加密的是“块”状的流信息。回想一下,对称加密算法容易受到恶意用户监听会话并窃取加密密钥的威胁。为了安全地传递块加密的加密/解密密钥,TLS 会先使用 非对称 算法对密钥进行加密,然后再将其传递给接收方。最后,通过 TLS 传输的数据包将使用消息认证码进行标记,以检测数据是否已被篡改。

在 TLS 会话开始时,浏览器和网站会进行 TLS 握手 以确定它们应如何通信。在握手的第一阶段,浏览器将列出它支持的多个密码套件。让我们深入了解这是什么意思。

密码套件

密码套件 是一组用于保护通信的算法。根据 TLS 标准,密码套件由 三个 独立的算法组成。第一个算法是 密钥交换算法,它是一种非对称加密算法。通信计算机使用该算法交换秘密密钥,用于第二个加密算法:旨在加密 TCP 数据包内容的对称块加密算法。最后,密码套件指定了一个 MAC 算法,用于验证加密消息的真实性。

让我们把这个内容更具体化。像 Google Chrome 这样的现代 web 浏览器支持 TLS 1.3,并提供了许多密码套件。在撰写本文时,其中一个密码套件的名字非常有趣,叫做 ECDHE-ECDSA-AES128-GCM-SHA256。这个特定的密码套件包括 ECDHE-RSA 作为密钥交换算法,AES-128-GCM 作为块密码,SHA-256 作为消息认证算法。

想要更多完全不必要的细节吗?好吧,ECDHE 代表 椭圆曲线 Diffie-Hellman 密钥交换(一种在不安全通道上建立共享密钥的现代方法)。RSA 代表 Rivest–Shamir–Adleman 算法(第一种实用的非对称加密算法,由三位数学家在 1970 年代发明,灵感来自他们喝了大量的逾越节酒)。AES 代表 高级加密标准(由两位比利时密码学家发明,并通过国家标准与技术研究院的三年审查过程被选中)。这个特定变体使用了 128 位密钥,采用 Galois/计数器模式,这就是名称中所指定的 GCM。最后,SHA-256 代表 安全哈希算法(一种具有 256 位字长的哈希函数)。

你看到了现代加密标准的复杂性了吗?现代浏览器和 web 服务器支持相当多的密码套件,而且随着时间的推移,TLS 标准中不断增加新的套件。随着现有算法的弱点被发现,计算能力变得更加廉价,安全研究人员会更新 TLS 标准,以保持互联网的安全。作为一名 web 开发者,了解这些算法如何工作并不是特别重要,但保持你的 web 服务器软件最新,以便能够支持最现代、最安全的算法,这一点非常重要。

会话初始化

我们接着上次的内容继续。在 TLS 握手的第二阶段,web 服务器选择它能够支持的最安全的密码套件,并指示浏览器使用这些算法进行通信。与此同时,服务器返回一个数字证书,其中包含服务器名称、将为证书的真实性担保的受信证书机构,以及在密钥交换算法中使用的 web 服务器加密密钥。(我们将在下一节中讨论什么是证书以及它们为何对安全通信至关重要。)

一旦浏览器验证了证书的真实性,两个计算机会生成一个会话密钥,该密钥将用于使用选择的块加密算法加密 TLS 会话。(请注意,这个会话密钥与前面章节讨论的 HTTP 会话标识符不同。TLS 握手发生在比 HTTP 会话更低级别的互联网协议中,而 HTTP 会话还未开始。)会话密钥是由浏览器生成的大随机数,用数字证书中附带的(公)加密密钥加密,采用密钥交换算法进行加密,并传输到服务器。

现在,TLS 会话终于可以开始了。此后所有的数据都会使用块加密算法和共享会话标识符进行加密,因此任何窃听会话的人都无法解读数据包。浏览器和服务器使用商定的加密算法和会话密钥在双向加密数据包。数据包还会进行身份验证并防篡改,使用消息认证码。

正如您所看到的,支撑互联网安全通信的是大量复杂的数学原理。幸运的是,作为一名 web 开发者,启用 HTTPS 所涉及的步骤要简单得多。现在我们已经了解了理论部分,让我们来看一下确保用户安全所需的实际步骤。

启用 HTTPS

为您的网站加密流量比理解底层加密算法要容易得多。大多数现代网页浏览器是自我更新的;每个主流浏览器的开发团队都会在支持现代 TLS 标准方面处于前沿地位。您网站服务器软件的最新版本也会支持类似的现代 TLS 算法。这意味着,作为开发者,您唯一需要做的就是获取一个数字证书并将其安装到您的网站服务器上。让我们来讨论如何做,并阐明为什么证书是必要的。

数字证书

数字证书(也称为公钥证书)是一种电子文档,用于证明公钥的所有权。数字证书在 TLS 中用于将加密密钥与互联网域名(如example.com)关联。它们由证书颁发机构(CA)颁发,证书颁发机构作为浏览器和网站之间的受信第三方,保证使用指定的加密密钥来加密发送到网站域名的数据。浏览器软件会信任几百个证书颁发机构——例如,Comodo、DigiCert,最近还有非营利组织 Let’s Encrypt。当一个受信的证书颁发机构担保某个密钥和域名时,它会确保您的浏览器正在与正确的网站进行通信,并且使用正确的加密密钥,从而阻止攻击者呈现恶意网站或证书。

你可能会问:为什么需要第三方来交换互联网中的加密密钥?毕竟,非对称加密的一个重点不是服务器自己可以自由提供公钥吗?虽然这个说法是对的,但实际上在互联网上获取加密密钥的过程依赖于互联网的域名系统(DNS),它将域名映射到 IP 地址。在某些情况下,DNS 容易受到伪造攻击,这些攻击可以将互联网流量引导到攻击者控制的 IP 地址,而不是合法的服务器。如果攻击者能够伪造互联网域名,他们可以颁发自己的加密密钥,受害者通常不会察觉。

证书授权机构的存在是为了防止加密流量被伪造。如果攻击者找到方法将流量从合法(安全)网站转移到他们控制的恶意服务器,攻击者通常不会拥有与该网站证书对应的解密密钥。这意味着他们将无法解密使用与网站数字证书附带的加密密钥加密的拦截流量。

另一方面,如果攻击者提供一个替代的数字证书,该证书对应于他们确实拥有的解密密钥,那么该证书将不会经过受信任的证书授权机构验证。任何访问伪造网站的浏览器都会向用户显示安全警告,强烈劝阻用户继续访问。

通过这种方式,证书授权机构使用户能够信任他们访问的网站。你可以通过点击浏览器栏中的挂锁图标查看网站使用的证书。那里的信息可能不特别有趣,但浏览器能够很好地警告你当证书无效时。

获取数字证书

从证书授权机构获取数字证书需要几个步骤,通过这些步骤,授权机构可以验证你对域名的所有权。你执行这些步骤的具体方式取决于你选择的证书授权机构。

第一步是生成一个密钥对,这是一个包含随机生成的公钥和私钥的小型数字文件。接下来,使用这个密钥对生成一个证书签名请求(CSR),该请求包含你网站的公钥和域名,并将请求上传到证书授权机构。在签署请求并颁发证书之前,证书授权机构将要求你证明你对 CSR 中包含的互联网域名具有控制权。一旦域名所有权得到验证,你就可以下载证书,并将其与密钥对一起安装到你的 Web 服务器上。

生成密钥对和证书签名请求

密钥对和 CSR 通常使用命令行工具openssl生成。CSR 通常包含除了域名和公钥之外的其他信息,如组织的法律名称和实际位置。这些信息会被包含在签名证书中,但除非证书颁发机构选择验证它们,否则并不是强制性的。在生成签名请求时,域名通常被称为区分名称(DN)完全合格域名(FQDN),出于历史原因。列表 13-1 展示了如何使用openssl通过命令行生成证书签名请求。

openssl req -new -key ./private.key -out ./request.csr

列表 13-1:通过命令行使用 openssl 生成证书签名请求

文件private.key应该包含一个新生成的私钥(也可以通过openssl生成)。工具openssl会要求提供一些信息,用于生成签名请求,包括域名。

域名验证

域名验证是证书颁发机构验证申请证书的人员是否确实拥有该域名控制权的过程。在申请数字证书时,您声明需要能够解密发送到特定互联网域名的流量。证书颁发机构会坚持检查您是否拥有该域名,作为尽职调查的一部分。

域名验证通常需要您临时编辑域名的 DNS 记录,从而证明您拥有 DNS 的编辑权限。域名验证可以防止 DNS 欺骗攻击:攻击者无法申请证书,除非他们也有编辑权限。

扩展验证证书

一些证书颁发机构会颁发扩展验证(EV)证书。这些证书要求证书颁发机构收集并验证申请证书的法律实体的信息。这些信息将被包含在数字证书中,并通过浏览器提供给访问网站的用户。EV 证书在大型组织中非常受欢迎,因为公司的名称通常会与浏览器 URL 栏中的挂锁图标一起显示,增强用户的信任感。

过期和撤销证书

数字证书有一个有限的有效期(通常是几年或几个月),到期后必须由证书颁发机构重新颁发。证书颁发机构还会追踪被证书持有者自愿撤销的证书。如果与你的数字证书对应的私钥被泄露,作为站点所有者,您需要申请一个新证书,并撤销之前的证书。浏览器会在用户访问带有过期或已撤销证书的网站时发出警告。

自签名证书

对于某些环境,尤其是测试环境,从证书授权机构获取证书是没有必要的或不切实际的。例如,仅在内部网络中可用的测试环境无法通过证书授权机构验证。然而,你可能仍然希望在这些环境中支持 HTTPS,因此解决方案是生成自己的证书——即自签名证书

openssl这样的命令行工具可以轻松生成自签名证书。浏览器遇到自签名证书的网站时,通常会发出严厉的安全警告(此网站的安全证书不被信任!),但仍然允许用户接受风险并继续访问。只要确保使用你测试环境的任何人都知道这个限制,并理解为何会出现此警告。

你是否需要为证书付费?

证书授权机构传统上是商业实体。即使到今天,它们中的许多仍然对每个签发的证书收取固定费用。自 2015 年以来,加利福尼亚的非营利组织 Let's Encrypt 提供了免费的证书。Let's Encrypt 的创始人包括 Mozilla 基金会(负责 Firefox 浏览器的发布)和电子前沿基金会(一个总部位于旧金山的数字权利非营利组织)。因此,除非你需要商业证书授权机构提供的扩展验证功能,否则没有太多理由为证书付费。

安装数字证书

一旦你拥有了证书和密钥对,下一步就是让你的 web 服务器切换到使用 HTTPS,并在 TLS 握手过程中提供证书。这个过程会根据你的托管服务提供商和服务器技术有所不同,尽管通常它是相对直接且文档完善的。让我们回顾一下一个典型的部署过程——这将需要一个简短的插曲。

Web 服务器与应用服务器

到目前为止,我在本书中描述了 web 服务器作为拦截和响应 HTTP 请求的机器,并讨论了它们如何针对每个请求发送静态内容或执行代码。虽然这是一个准确的描述,但它忽略了一个事实,即网站通常作为一对运行的应用程序来部署。

运行典型网站的第一个应用程序是一个web 服务器,它提供静态内容并执行低级 TCP 功能。它通常是像 Nginx 或 Apache HTTP 服务器这样的软件。Web 服务器是用 C 语言编写的,并优化以快速执行低级 TCP 功能。

配对的第二个应用是应用服务器,它位于 web 服务器的下游,并托管构成网站动态内容的代码和模板。每种编程语言都有许多应用服务器可供选择。一个典型的应用服务器可能是 Java 语言网站使用的 Tomcat 或 Jetty;Ruby on Rails 网站使用的 Puma 或 Unicorn;Python 网站使用的 Django、Flask 或 Tornado,等等。

有点让人困惑的是,web 开发人员常常随意称他们使用的应用服务器为“web 服务器”,因为那是他们大部分时间编写代码的环境。实际上,完全可以单独部署一个应用服务器,因为应用服务器可以做 web 服务器能做的一切,尽管效率较低。这在 web 开发人员在自己机器上编写和测试代码时是一个典型的设置。

配置你的 web 服务器以使用 HTTPS

数字证书和加密密钥几乎总是部署到 web 服务器上,因为它们比应用服务器更快。将 web 服务器切换为使用 HTTPS,只需更新 web 服务器的配置,使其接受标准 HTTPS 端口(443)上的流量,并告诉它在建立 TLS 会话时使用的数字证书和密钥对的位置。列表 13-2 展示了如何将证书添加到 Nginx web 服务器的配置文件中。

server {
    listen              443 ssl;
    server_name         www.example.com;
    ssl_certificate     www.example.com.crt;
    ssl_certificate_key www.example.com.key;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;
}

列表 13-2:描述配置 Nginx 时数字证书(www.example.com.crt)和加密密钥(www.example.com.key)的位置

以这种方式处理 TLS 功能的 web 服务器将解密传入的 HTTPS 请求,并将任何需要由应用服务器处理的请求以未加密的 HTTP 请求传递下去。这被称为在 web 服务器上终止 HTTPS:web 服务器与应用服务器之间的流量是不安全的(因为加密已被去除),但通常这不会构成安全风险,因为流量不会离开物理机器(或者至少仅在私有网络中传递)。

那 HTTP 呢?

配置你的 web 服务器以监听 443 端口上的 HTTPS 请求,需要对配置文件进行一些编辑。接着,你需要决定 web 服务器如何处理标准 HTTP 端口(80)上的未加密流量。通常的方法是指示 web 服务器将不安全的流量重定向到相应的安全 URL。例如:如果用户代理访问http://www.example.com/page/123,web 服务器将返回HTTP 301响应,指示用户代理改为访问https://www.example.com/page/123。浏览器会理解为这是一个指令,要求在完成 TLS 握手后,使用 443 端口发送相同的请求。列表 13-3 展示了如何将所有流量从 80 端口重定向到 Nginx web 服务器上的 443 端口的示例。

server {
    listen 80 default_server;
    server_name _;
    return 301 https://$host$request_uri;
}

列表 13-3:在 Nginx Web 服务器上将所有 HTTP 重定向到 HTTPS

HTTP 严格传输安全性

到此为止,你的网站已经设置好与浏览器的安全通信,任何使用 HTTP 的浏览器都会被重定向到 HTTPS。你还有一个最终的漏洞需要修复:你需要确保在任何初始的 HTTP 连接中,不会发送敏感数据。

当浏览器访问一个它以前访问过的网站时,浏览器会在请求的Cookie头部发送回网站之前提供的所有 cookie。如果最初的连接是通过 HTTP 进行的,那么即使随后的请求和响应升级到 HTTPS,这些 cookie 信息也会不安全地传输。

你的网站应该通过实施HTTP 严格传输安全(HSTS)策略,指示浏览器通过 HTTPS 连接发送 cookie。你可以通过在响应中设置Strict-Transport-Security头来实现这一点。现代浏览器遇到这个头部时,会记住只使用 HTTPS 连接到你的网站。即使用户明确输入一个 HTTP 地址,如http://www.example.com,浏览器也会自动切换到 HTTPS,而无需提示。这可以防止在初次连接到你的网站时 cookie 被窃取。列表 13-4 展示了在使用 Nginx 时如何添加Strict-Transport-Security头。

server {
    add_header Strict-Transport-Security "max-age=31536000" always;
}

列表 13-4:在 Nginx 中设置 HTTP 严格传输安全性

浏览器会记住在max-age指定的秒数内不通过 HTTP 发送任何 cookie,之后会检查该网站是否更改了其策略。

攻击 HTTP(和 HTTPS)

在本章的这一部分,你可能会问:如果我选择使用 HTTPS,最坏的情况会怎样?我还没有真正描述未加密的 HTTP 是如何被利用的,我们来补充这一点。互联网中的弱加密或未加密通信允许攻击者发起中间人攻击,在这种攻击中,他们篡改或窃听 HTTP 会话。让我们来看一些来自黑客、互联网服务提供商和政府的最近例子。

无线路由器

无线路由器是中间人攻击的常见目标。大多数路由器都包含一个简单的 Linux 操作系统安装,使其能够将流量路由到本地互联网服务提供商(ISP)并托管一个简单的配置界面。这是黑客的完美目标,因为 Linux 系统通常永远不会更新安全补丁——而且同一操作系统版本会安装在成千上万的家庭中。

2018 年 5 月,思科安全研究人员发现超过 50 万个 Linksys 和 Netgear 路由器感染了一种名为VPNFilter的恶意软件,该恶意软件会监听通过路由器传输的 HTTP 流量,窃取网站密码和其他敏感用户数据,攻击者被认为与俄罗斯政府有关。VPNFilter 甚至试图执行降级攻击,干扰与流行网站的初始 TLS 握手,使浏览器选择使用较弱的加密方式或根本不加密。

使用 HTTPS 的网站将不会受到这种攻击的影响,因为 HTTPS 流量除了接收方网站之外,任何人都无法解密。其他网站的流量可能已被黑客窃取并挖掘敏感数据。

Wi-Fi 热点

黑客发起中间人攻击的另一种低技术方法是简单地在公共场所设置自己的 Wi-Fi 热点。我们中的大多数人并不太关注设备连接的 Wi-Fi 热点的名称,因此攻击者很容易在咖啡馆或酒店大堂等公共场所设置一个热点,并等待不知情的用户连接。因为 TCP 流量会通过黑客的设备流向 ISP,黑客将能够将流量记录到磁盘中,并仔细分析以提取敏感信息,如信用卡号码和密码。受害者唯一能察觉到的不正常情况是,当攻击者离开物理位置并关闭热点时,受害者的设备与互联网断开连接。加密流量能够防止此类攻击,因为黑客无法读取他们捕获的任何流量。

互联网服务提供商

互联网服务提供商将个人用户和企业连接到互联网主干网,考虑到传输数据可能具有的敏感性,这是一个巨大的信任位置。你可能认为这会阻止他们监听或干扰 HTTP 请求,但像美国最大 ISP 之一的 Comcast 这样的公司并没有这么做,Comcast 多年来曾将 JavaScript 广告注入流经其服务器的 HTTP 流量中。Comcast 声称这样做是为了提供服务(许多广告告知用户每月数据计划已经使用了多少),但数字权利活动人士认为这种做法类似于邮递员把广告材料塞进封好的信件里。

使用 HTTPS 的网站免疫此类篡改,因为每个请求和响应的内容对 ISP 是不透明的。

政府机构

政府机构窃听你的互联网流量可能听起来像是阴谋论,但大量证据表明这确实发生过。美国国家安全局(NSA)成功地实施了中间人攻击进行监控。前 NSA 承包商爱德华·斯诺登泄露的一份内部报告描述了巴西国有石油公司 Petrobras 如何被监控:NSA 获取了 Google 网站的数字证书,然后托管了自己仿制的网站,在代理 Google 流量的同时窃取用户凭证。我们并不清楚这种程序的普遍性,但想一想真的让人不安。(如果有政府人员在阅读此内容:事实上,这种程序是好的,能够确保我们的安全,本书作者完全支持它。)

总结

你应该使用 HTTPS 来确保从网页浏览器到你网站的通信是私密的,且不能被篡改。HTTPS 是通过传输层安全协议(TLS)发送的 HTTP。当网页服务器和用户代理参与 TLS 握手时,会启动 TLS 会话。在 TLS 握手过程中,浏览器会提供它支持的加密套件列表。每个加密套件包含一个密钥交换算法、一个块加密算法和一个消息认证码算法。网页服务器选择一个它支持的加密套件,并返回其数字证书。

然后,浏览器使用附加在数字证书上的公钥,利用密钥交换算法加密一个(随机生成的)TLS 会话标识符,并将其发送给网页服务器。最后,当双方都拥有会话标识符时,他们将使用它作为后续消息的加密/解密密钥,使用选定的块加密算法进行加密。每个数据包的真实性将使用消息认证码算法进行验证。

数字证书由少数几个证书颁发机构(CA)颁发,这些机构在颁发证书之前需要你证明你拥有所选域名的所有权。证书颁发机构充当浏览器与网站之间的受信任第三方,防止伪造网站展示虚假的证书。

一旦你获得了网站的证书,你需要通过 HTTPS 提供内容。这意味着需要配置你的网页服务器以接受通过 443 端口的流量,告诉它在哪里找到证书和相应的解密密钥,并将 80 端口上的 HTTP 流量重定向到 443 端口上的 HTTPS 流量。最后,你应该指示浏览器在升级到 HTTPS 之前,不通过 HTTP 请求发送任何敏感数据——例如,session cookie,通过设置 HTTP 严格传输安全(HSTS)策略。

确保定期升级你的 web 服务器技术,以确保你使用的是最现代化(也因此最安全)的加密套件。加密标准不断被研究和增强,因为旧的算法会被破解或发现存在漏洞。

在我们讨论需要保持 web 服务器更新时,你应该更广泛地考虑如何测试、保护以及管理你用来提供网站服务的任何第三方应用程序。这正是你将在下一章中要做的事情!

第十四章:第三方代码

image

如今没有人会从头开始编写软件,尤其是 web 开发人员。支持你网站的大部分代码——从操作系统到 web 服务器,再到你使用的编程语言库——都是由其他人编写的。那么,你该如何管理别人代码中的漏洞呢?

黑客通常针对流行软件组件中的已知漏洞,因此确保第三方代码的安全至关重要。例如,黑客扫描互联网上的不安全 WordPress 实例比挑选一个特定的网站并尝试找出其潜在漏洞要高效得多。因此,保持最新的安全补丁非常重要,以避免被恶意扫描程序发现。

本章讨论了三种确保第三方代码安全的方法。你将了解如何跟进关于你依赖项(你使用的软件组件)的安全通告。接下来,你将深入了解如何配置这些依赖项,以确保它们不会无意中留下黑客可以利用的后门。最后,你将看到与第三方服务相关的安全风险——这些代码运行在他人的服务器上,要么被你的 web 服务器调用,要么通过 JavaScript 导入加载到你的网页中。特别是,你将关注通过广告网络部署恶意软件的普遍策略——即所谓的恶意广告——并检查如果你的网站包含广告,如何保护你的用户。

确保依赖项的安全

2014 年 4 月,OpenSSL 的作者,实施大多数 Linux 版本(以及其他操作系统)TLS 的开源 C 库,披露了 Heartbleed 漏洞的存在:利用缓冲区过度读取,攻击者可以从使用该易受攻击库的服务器中读取任意内存块,从而窃取加密密钥、用户名、密码以及其他敏感数据。互联网上最流行的两个 web 服务器——Apache 和 Nginx——都使用 OpenSSL 来保护通信,安全公司 AVG 的研究人员估计,超过五十万个网站在一夜之间暴露出漏洞。由于受影响网站数量庞大,Heartbleed 漏洞被称为有史以来最危险的漏洞。

修复了漏洞的新版本 OpenSSL 在漏洞披露的同一天发布,但未修复的 web 服务器在此后几个月仍然在互联网上很常见。这是一个运行未修补 web 服务器的危险时期:黑客有足够的时间寻找最有效的漏洞利用方法,而且随着易受攻击网站数量的减少,剩余的 web 服务器成了更可能的攻击目标。

所有网站都使用第三方代码,所有第三方库——即使是由安全专家编写的库,如 OpenSSL——也可能存在安全问题。如果你想领先于这些漏洞,你需要在漏洞公开后立即了解并及时修补软件。这涉及三个方面:准确知道你正在运行的依赖项,能够快速更新依赖项,并时刻关注依赖项的安全问题。我们将逐一讨论每个方面。

了解你正在运行的代码

确保你的依赖项的第一步是知道它们是什么。这听起来可能很显而易见,但现代软件栈复杂且多层次,使得在软件开发生命周期的开发阶段很容易添加新的库,而这些库之后可能会被遗忘。你应该使用许多工具来组织你的依赖项。

依赖管理工具

大多数编程语言都带有一个依赖管理器,允许开发团队在配置文件中指定第三方依赖项。这些软件库将在构建过程中按需下载。依赖管理器使得获取新的依赖项以及在新环境中重建软件栈变得简单——例如,当你部署到服务器时。

为了确保你知道每个依赖项的版本,你应该养成在依赖列表中为每个依赖项指定明确的版本号的习惯。在依赖管理系统中提供的包都托管在互联网的远程仓库中。当包的作者发布新版本时,它们会以新版本号被添加到仓库中。默认情况下,大多数依赖管理器在你首次在新环境中运行构建时会获取每个依赖项的最新版本。这种默认行为在初期开发时是合理的,但等到你发布代码时,依赖配置文件应当明确列出版本号。安全通告将披露哪些版本的依赖项存在漏洞,因此固定你在每个环境中运行的版本将告诉你需要修补的内容。

还要注意,你声明的依赖项可能本身也有依赖项——而你的依赖管理器将会帮助你自动获取这些库。因此,我们提到依赖树,因为每个依赖项都有其他依赖项作为分支。评估安全风险时,确保考虑整个依赖树。你的依赖管理器可以在命令行中输出整个树(包括依赖项的依赖项)。列表 14-1 展示了一个 Node.js 项目的依赖树,说明了@blueprintjs/core库将popper.js库作为子依赖项。

my_project@0.0.0 /usr/code/my_project
├─┬ @blueprintjs/core@3.10.0
│ ├─┬ @blueprintjs/icons@3.4.0
│ │ ├── classnames@2.2.6 deduped
│ │ └── tslib@1.9.3 deduped
│ ├── @types/dom4@2.0.1
│ ├── classnames@2.2.6 deduped
│ ├── dom4@2.1.4
│ ├── normalize.css@8.0.1
│ ├── popper.js@1.14.6
│ ├── react-popper@1.3.3 deduped

Listing 14-1:命令 npm list 显示 Node Package Manager 中的整个依赖树。

操作系统补丁

除了跟踪编程语言的依赖项外,你还需要跟踪操作系统级别上部署的软件包。操作系统供应商(例如,Red Hat 和 Microsoft)经常发布安全补丁,因此你应该跟踪在任何给定环境中使用的每个操作系统库的版本,并有一个及时升级服务器的策略。如果你在数据中心运行物理服务器,你的公司可能有专门的系统管理员来处理此事。如果你在云端(例如,在 Amazon EC2 上)运行软件,应该定期更新操作系统的版本作为部署的一部分。使用 Docker 进行容器化也是跟踪操作系统依赖项的一个好方法,因为 Docker 配置文件会明确列出容器实例化时要安装的软件。

完整性检查

最后一个考虑因素:你需要确保你认为自己正在运行的代码就是你实际运行的代码。依赖管理工具和补丁工具在这里会有所帮助。它们通过使用校验和—在依赖项上传到仓库时计算的数字指纹,并且在下载并使用依赖项时重新计算并验证—来确保软件组件未被篡改。你应该力求在将 JavaScript 代码和其他资源部署到浏览器时提供相同的保障。

现代浏览器允许你通过在 HTML 中的 <script><style> 标签中添加 子资源完整性检查 来实现这一点。你的构建过程应该为你打算在客户端导入的每个资源文件生成一个校验和,并将该校验和分配给每个导入标签的 integrity 属性。Listing 14-2 显示了如何使用 openssl 工具生成校验和。

cat FILENAME.js | openssl dgst -sha384 -binary | openssl base64 -A

Listing 14-2:在 Unix 中生成校验和,将 JavaScript 文件 FILENAME.js 通过管道传输到 openssl 以生成摘要并将其编码为 Base64。

浏览器将在执行导入的代码之前,将脚本与预期的校验和进行比较,并验证是否匹配。这使得黑客更难在获得服务器访问权限后用恶意代码替换 JavaScript 文件,因为他们还必须获得并修改生成 <script> 标签的代码,如 Listing 14-3 中所示。

<script src="https://example.com/example-framework.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlG"
        crossorigin="anonymous"></script>

Listing 14-3:通过计算文件的校验和并将其添加到导入脚本的 HTML 标签的完整性属性中,确保导入的 JavaScript 文件的完整性。

能够快速部署新版本

应对安全问题需要你能够迅速部署补丁,这就意味着你需要有一个有序且脚本化的发布流程。第五章已经讲解了大部分内容:你的发布流程应该是可靠的、可复现的,并且可以回滚的,发布应该与源代码控制系统中的代码分支挂钩。你的依赖管理器使用的配置文件应该保存在源代码控制中,这样你就可以跟踪每个发布中部署了哪些版本的依赖。

你经常会单独部署第三方组件的安全补丁——升级依赖版本,而不需要发布任何自己的代码变更。即使发布仅包含第三方代码变更,你仍然需要对你的网站进行回归测试:换句话说,要确保升级后的依赖不会破坏网站上的任何现有功能。如果你的单元测试覆盖面广,回归测试就会变成一种形式化的操作。你的代码库中执行的代码行越多,你需要手动测试的工作就越少。在编写良好的单元测试上投入一些时间,将使得部署安全补丁更加迅速和简便。

保持警觉,关注安全问题

通过精心管理的依赖关系和可靠的发布流程,你就能确保你使用的第三方代码是安全的。最后一步是确保在安全问题披露时能够及时了解。感谢互联网,你有很多方法来跟进这些问题。

社交媒体

安全公告会通过社交媒体和新闻网站迅速传播,例如 Twitter、Reddit 和 Hacker News (news.ycombinator.com/), 所以这些网站是获取安全新闻的好途径。大型软件漏洞会在诸如* www.reddit.com/r/programming/* 和 /r/technology 等 subreddit 中讨论,通常还会登上 Hacker News 的首页。

如果你有时间在 Twitter 上关注技术专家和软件作者,安全问题常常是当天的讨论话题。这也是跟进软件世界新发展的好方式。

邮件列表和博客

编程语言通常会有邮件列表和频道,发布重要新闻。例如,Python 软件基金会每周发布新闻通讯,并且有自己的 Slack 频道。确保订阅与你的技术栈相关的所有信息。

有大量关于信息安全的博客。可以查看 Brian Krebs (krebsonsecurity.com/) 和 Bruce Schneier (www.schneier.com/),他们对当前安全问题有深入的评论。

官方公告

注意来自托管服务商和软件供应商的安全警报。当像 Heartbleed 这样的重大安全问题发生时,托管公司会与客户合作并指导他们完成修补过程。如果你使用微软技术,微软会每周二发布新的安全补丁(补丁星期二),因此请确保订阅其新闻通讯。

软件工具

除了保持对外界动向的关注,自动化工具还可以检查你的依赖项是否存在已知的漏洞。Node.js 在这方面处于领先地位,因为Node 包管理器(NPM)现在包含了npm audit命令,可以用来将你的依赖版本与开源漏洞数据库进行交叉检查。Ruby 的类似工具是bundler-audit gem;对于 Java 和 .NET,开放 Web 应用程序安全项目(OWASP)发布了一款命令行工具,名为dependency-check。将这些工具纳入到构建流程中,每当你的代码构建时,它们会提醒你潜在的漏洞,并帮助你评估每个漏洞的风险。

你的源代码库也可以提供帮助。GitHub 会自动扫描其网站上托管的代码,并在发现脆弱的依赖时发出安全警报。

知道何时升级

需要注意的是,并非所有的安全问题都需要优先处理!不断升级你的依赖项可能非常耗时,尤其是因为许多安全问题可能已被你系统中的其他因素所缓解。大公司通常有正式的流程来审核安全警报、评估优先级并选择适当的措施。只要你的团队已经评估了相关风险,完全可以在下一个预定的发布版本中加入次要的安全升级。

配置安全

软件的安全性取决于它的配置是否安全。特别是第三方软件,若你安装了一个新的数据库并使用默认的用户账户和密码进行运行,很快就会遇到问题。黑客经常扫描互联网,寻找使用默认设置的运行软件,因为他们知道许多站点所有者在安装软件时会忽略自定义配置。

如果你在使用未加固的配置运行软件,你可能会把这一点公之于众。信息安全咨询公司 Offensive Security 维护着谷歌黑客数据库,其中列出了你可以通过简单的谷歌搜索找到的不安全软件。谷歌搜索蜘蛛会彻底地索引网页,并提供一套强大的工具,以根据这些信息优化搜索。例如,搜索index of /etc/certs会列出数百万个公开其数字证书目录的网页服务器——这是一个严重的安全漏洞!

使用安全配置部署依赖项是防止被黑客攻击的关键。安全配置要求你用强密码设置服务,安全存储配置信息,并限制攻击者在获取环境某一部分的访问权限后能够造成的损害。让我们来看一下如何实现。

禁用默认凭据

许多软件包都带有默认的登录凭据,以便第一次使用的用户能够快速启动和运行。确保在将软件部署到测试或生产环境之前禁用这些凭据。例如,如果你的数据库、Web 服务器或内容管理系统部署时有一个admin帐户,它将很快被扫描互联网上漏洞软件的机器人检测到。

禁用开放目录列表

Web 服务器通常会过度共享。例如,较旧版本的 Apache web 服务器将 URL 路径映射到文件,并且如果 URL 中省略了文件名,它会友好地列出目录中包含的文件。开放目录列表邀请黑客探索你的文件系统,使他们能够搜索敏感数据文件和安全密钥。确保在 Web 服务器配置中禁用目录列表。清单 14-4 展示了如何在 Apache web 服务器中执行此操作。

<Directory /var/www/>
   Options Indexes FollowSymLinks
   AllowOverride None
   Require all granted
</Directory>

清单 14-4:移除关键字 Indexes,以防止此 Apache 配置文件生成开放目录列表。

保护你的配置信息

你的 Web 服务器配置可能包含敏感信息,如数据库凭据和 API 密钥。许多开发团队将配置文件存储在源代码控制中,以便简化部署。然而,考虑一下如果黑客获得了对源代码控制系统的访问权限,他们可能会做什么:这种类型的敏感信息正是黑客首先会搜索的内容。数据库凭据、API 密钥、私密加密密钥、证书以及其他敏感配置细节需要外部存储,而不是存放在源代码控制中。

一种常见的方法是在操作系统级别通过环境变量记录敏感配置,并在启动时让配置代码从这些环境变量中初始化自己。这些环境变量可以通过保存在服务器上的配置文件进行初始化。

另一种方法是使用专用的配置存储。亚马逊 Web Services(AWS)允许你在其 Systems Manager 参数存储中安全地存储配置。微软的服务器通常将凭据存储在 Active Directory 中,从而允许精细的权限控制。将配置存储在数据库表中也是一个选择,但你应该考虑,如果攻击者获得了数据库的访问权限,他们可能会如何升级攻击。(你的 Web 服务器还必须在加载其余配置之前访问数据库凭据!)

保护配置文件信息的一个可靠方法是将其以加密形式存储,使用如 AES-128 之类的算法进行加密。这意味着黑客必须同时破解你的配置数据解密密钥,才能窃取你的凭据。只需记住,解密密钥应存储在与配置文件不同的位置,否则安全性将大打折扣。

加固测试环境

预生产环境通常安装与生产环境相同的软件,但安全性较差。如果你的测试环境包含敏感数据——例如,如果你曾经将生产环境的数据复制到测试环境中——你需要将测试环境配置为与生产环境一样安全。至关重要的是,生产和非生产环境不应共享凭据或 API 密钥;如果黑客成功攻破你的测试服务器,限制他们能造成的损害是非常重要的。

保护管理前端

一些软件组件带有通过互联网访问的管理工具。管理界面是黑客最喜欢的攻击目标之一。例如,你可能经常遇到恶意机器人,通过检测是否存在/wp-login.php页面来探测不安全的 WordPress 实例。

如果你不打算使用这些管理前端,请在配置中禁用它们。如果你打算使用它们,请确保删除任何默认的登录凭据,并且如果可能的话,限制可以访问它们的 IP 范围。请查阅你的软件堆栈文档,或在 Stack Overflow 上做一个快速搜索(*stackoverflow.com/)以了解如何操作。

现在你已经学会了如何保护在自己服务器上运行的第三方代码,让我们来看一下如何安全地与运行在其他人服务器上的代码进行集成。

保护你使用的服务

第三方服务在现代网站开发中被广泛使用。你可能会使用 Facebook 登录进行身份验证,使用 Google AdSense 在你的网站上投放广告,使用 Akamai 托管静态内容,使用 SendGrid 发送事务性电子邮件,使用 Stripe 处理支付。

将这些服务集成到你的网站中,通常意味着要在服务提供商处创建一个账户,获得秘密访问凭据,并修改你的网站代码以利用该服务。这里有两个安全考虑因素。首先,黑客通常会尝试窃取你的访问凭据,以便访问这些服务的账户。这将允许他们获取关于你用户的信息,或者在支付处理器的情况下,甚至发起金融交易。其次,每个第三方服务都是你网站的潜在攻击向量,因为黑客会试图破坏服务提供商,以便访问广泛的目标。

让我们从第一个考虑因素开始:学习如何安全存储你的访问凭证。

保护你的 API 密钥

许多第三方服务在你注册时会为你发放一个应用程序接口(API)密钥,你的代码在与 API 交互时必须将该密钥作为访问令牌提供。API 密钥需要安全存储。通常,这意味着将 API 密钥安全地存储在服务器的配置中,正如上一节所讨论的那样。

一些 API 发放 两个 API 密钥:一个 公钥 可以安全地传递给浏览器,用于从 JavaScript 发起 API 调用;一个 私钥 必须保存在服务器上,用于从服务器端发起更敏感操作的私有 API 调用。公钥的权限较少。审计你的代码,确保这些密钥没有被混淆!你不想不小心将具有更高权限的私钥发送给客户端。即使是像将你的配置变量命名为 SECRET_KEY 这样的简单做法,也能提醒你的开发团队注意风险。

其他服务允许你生成一个可以传递给客户端的临时访问令牌。通常,这些令牌只能使用一次,或者在有限的时间窗口内使用,以防止恶意用户滥用。这些访问令牌可以防止 重放攻击,即攻击者通过重新发送 HTTP 请求来尝试重复某个操作(例如,重复支付)。确保你的代码仅在用户已经完成身份验证后生成访问令牌,否则攻击者可能会按需生成新的访问令牌。

保护你的 Webhook

大多数 API 集成涉及从你的 Web 服务器或浏览器发起 HTTPS 调用到服务提供商的 API。当服务提供商需要发起相反方向的调用(例如,向你发送通知)时,它可能要求你实现一个 Webhook。这是你网站上的一个简单“反向 API”,当事件发生时,服务提供商会向其发送 HTTPS 请求。例如,当用户打开你发送的电子邮件或你的支付处理程序发起支付时,你可能会收到 Webhook 调用。

由于 Webhook 是公开的 URL,任何人都可以调用它,而不仅仅是服务提供商。如果服务提供商支持在 Webhook 调用中发送凭证,你应该在处理 Webhook 调用之前验证这些凭证是否正确。

如果 Webhook 调用纯粹是信息性的且不包含敏感数据,它可以在没有凭证的情况下发送。在这种情况下,攻击者可以轻松伪造这样的 Webhook 调用。准备在进行进一步处理之前,通过回调服务提供商的 API 来验证通知。

保护由第三方提供的内容

黑客最喜欢的一种手段是通过其他人的域名提供恶意内容;受害者可能会因为他们信任的网站而产生虚假的安全感。用户已经习惯于信任浏览器中的锁形图标,因此,如果黑客能够找到一种方式,在大公司的安全证书下部署恶意软件,他们就能欺骗更多受害者下载它。

许多网站使用内容分发网络(CDN)或基于云的存储(如 Amazon S3)来提供频繁访问的内容。当网页开发人员与这种类型的服务集成时,他们通常会通过更改 DNS 将流量从他们的域名引导到该服务,例如将subdomain.example.com这样的子域流量重定向到服务。这使得第三方提供的内容可以使用站点的安全证书进行加密。

黑客经常尝试子域接管,通过扫描互联网寻找指向未初始化或已停用服务 IP 地址的子域 DNS 条目。然后,他们会注册服务提供商并占用列出的其中一个 IP 地址。这将使他们能够通过使用受害者的域名创建指向其恶意内容的链接。

如果您的网站提供由 CDN 或基于云的存储托管的内容,您需要确保您的 DNS 条目指向的仅是有效的 IP 地址。在更改 DNS 之前,请确保已验证服务在您的控制下正常运行,并且如果更换服务提供商,请及时撤销 DNS 更改。

现在您知道了如何保护与服务提供商的集成,让我们来看看另一个方向的威胁。

作为攻击向量的服务

第三方服务可能成为对您的网站发起恶意攻击的一个向量这一点在您集成客户端服务时尤为重要,因为从第三方域导入的任何 JavaScript 都带有安全风险。

以 Google Analytics 为例。当您将 Google Analytics 工具添加到您的网站时,您需要注册一个 Google 帐户以获取跟踪 ID,然后在您希望跟踪用户活动的页面上导入外部 JavaScript,如示例 14-5 所示。

<script src="https://www.googletagmanager.com/gtag/js?id=GA_TRACKING_ID"></script>

示例 14-5:将 Google Analytics 添加到网页的配方

导入的代码可以读取页面 DOM 中的任何内容,包括用户输入的敏感数据。它还可能以误导的方式更改 DOM;例如,诱使用户输入他们的凭证。在添加客户端服务时,必须考虑到这些风险。恶意代码可以由第三方服务本身提供,或者由攻击者劫持该服务后提供。(如果您想知道:Google Analytics 从未被攻击者攻破。我这里只是将其作为一个示例!)

不幸的是,在考虑如何运行从第三方导入的客户端代码时,浏览器的安全模型目前并不十分复杂。浏览器中的 JavaScript 代码在沙盒中运行,这意味着它与底层操作系统隔离,无法访问磁盘上的文件,但从不同来源导入的 JavaScript 文件却都在同一个沙盒中运行。

即将发布的 Web 组件规范(* www.webcomponents.org/ ),目前由 HTML 标准委员会开发,定义了代码和页面元素的更精细的权限。尽管这些细节正在最终确定并实现,但你仍然应该在网站上实施合理的安全预防措施。让我们通过探讨如何保护你的客户端集成,来看一下通过第三方渠道进行攻击的最常见方式:恶意广告*。

警惕恶意广告

广告是现代网络的重要组成部分:互联网上的大部分内容都是通过广告收入来资助的,且公司每年在在线广告上的支出超过 1000 亿美元。广告通常是由第三方广告平台放置在网站上的。网站所有者(在在线广告界被称为发布者)会订阅广告平台,然后标出他们网站上广告应该出现的位置。广告平台将在网站加载时填充这些区域,使用直接在每个页面中导入的 JavaScript。

主要广告平台如 Google AdSense 使用分析工具来识别发布者托管的内容类型和访问网站的人的类型,从而决定投放的广告类型。发布者有时直接与广告商打交道,或者将广告位放在广告交换平台上,通过该平台,广告买家可以购买广告块。(广告买家可能会购买 1000 次广告展示,针对特定人群,例如访问运动鞋网站的 18-25 岁男性。)

作为发布者,你对你所承载的广告有一定的控制权,但通常不能事先批准每一条广告。例如,Google AdSense 允许发布者屏蔽某些广告类别或特定网站域名,或在广告开始展示给用户后拒绝特定广告。

这构成了一个安全风险,因为黑客经常利用广告平台作为攻击向量。恶意广告——恶意广告——使攻击者能够同时通过恶意软件攻击多个网站。恶意广告是互联网上日益严重的威胁,可能会让发布者和广告网络尴尬,并使用户成为受害者。

避免恶意软件传播

广告中的恶意软件通常通过漏洞利用工具包传播,这些工具包在传递实际的恶意代码(即有效载荷)之前,会判断特定的浏览器和操作系统是否存在漏洞:有效载荷可能包括重定向或锁定浏览器的脚本、通过插件漏洞传播的病毒或勒索病毒,甚至是通过浏览器挖矿加密货币的 JavaScript 代码。

漏洞利用工具包的作者与安全研究人员之间处于军备竞赛中。为了避免被检测到,漏洞利用工具包托管在动态生成的 URL 上,并通过仅偶尔触发来避免自动扫描。甚至有观察到漏洞利用工具包试图通过检测它们是否在虚拟机中运行来阻止恶意软件分析(恶意软件研究人员通常使用虚拟机来隔离有害代码进行分析)。

如果你的用户因为你网站上的广告而遭遇恶意软件攻击,你就把他们置于了危险之中。你可以通过确保只与可信的广告平台合作、在网页中使用安全的框架来展示广告,并不断留意恶意广告来保护他们。

使用信誉良好的广告平台

大多数情况下,防范恶意广告是广告平台的责任。它们与广告购买者建立了联系,只有它们能够全面监控这些广告商,从而发现恶意行为者。

谷歌(迄今为止)是广告领域最大的参与者。谷歌允许较小的出版商通过自助广告平台 AdSense 将其网站实现货币化。较大的出版商可以访问 AdX,这是一个允许出版商指定广告合作伙伴并设置自己价格的平台。两个平台都接受第三方广告网络的广告。

谷歌在防范恶意广告方面表现得非常敏锐,因为它们的收入很大一部分依赖于广告平台。为了利用这一点,你应该在选择广告平台时优先考虑 AdSense 或 AdX。

然而,谷歌出于声誉原因选择不与某些类型的网站合作。例如,如果你托管成人主题或暴力内容,你很难通过 AdSense 的审核。在这种情况下,你可能不得不与一个资源较少、对防范恶意软件的兴趣较小的较小广告平台合作。在选择平台之前,请做好研究。

使用 SafeFrame

隔离网页中的第三方内容最有效的方法是将该内容托管在<iframe>标签内。加载在 iframe(内联框架)中的 JavaScript 代码无法访问包含页面的 DOM。HTML5 通过向<iframe>标签添加sandbox属性提供了更精细的控制。此属性允许框架指定,例如,包含的内容是否可以提交POST请求或打开新窗口。

广告行业已采用一种名为SafeFrame的标准,它允许发布者指定广告必须在 iframe 中运行。SafeFrame 标准使用<iframe>标签,并添加了一个 JavaScript API,使广告商能够克服 iframe 的一些固有限制。该 API 允许广告脚本在框架可见时获得通知,并对大小变化作出响应,例如。

你的广告平台将提供一个选项,允许你展示符合 SafeFrame 标准的广告,你应该选择该选项。这将阻止任何恶意广告脚本,这些脚本试图干扰网页的渲染过程。

定制你的广告偏好

大多数广告平台允许你自定义展示给用户的广告内容类型。如果你使用 Google AdSense,确保你只展示来自 Google 认证广告网络的内容。黑客已经知晓通过购买过期域名,运营较小、已经停用的广告网络来投放恶意软件。

还要清点你展示的广告类别。你可能希望屏蔽快速致富计划和多级营销活动的广告,以及任何自称为可下载工具的广告。

审查并报告可疑广告

定期查看你的网站上展示的广告,方法是通过广告平台仪表板进行查看。(记住:广告是根据访客量身定制的,因此仅在浏览器中访问你的网站,并不能显示所有正在展示的广告。)报告并屏蔽任何可疑的广告。最好记录用户离开你网站时的外部链接,这样你就能追踪到你托管的广告是否将用户引导到可疑网站。

摘要

第三方代码中的漏洞是你网站的一大威胁。使用依赖管理工具来跟踪你使用的第三方依赖项,将你的依赖清单放在源代码控制下,并指定明确的依赖版本。确保你的构建和部署流程是脚本化的,这样当发布安全通告时,你可以轻松地升级依赖项。(这也包括操作系统的补丁。)保持关注社交媒体和新闻网站,了解何时发布安全通告。使用审计工具检测依赖树中存在漏洞的软件组件。在网页中导入 JavaScript 时,使用integrity属性,以便浏览器可以验证这些文件的完整性。

确保你没有使用不安全的配置;黑客通过简单的 Google 搜索可以发现不安全的软件组件。禁用系统的任何默认凭证,并在你的 Web 服务器配置中禁用开放目录列表。将敏感的配置信息(例如数据库访问凭证或 API 密钥)从源代码控制中移除;相反,应该将其保存在专用的配置存储中,并在启动时加载。特别要注意保护测试环境和管理前端的配置,因为它们是黑客常见的攻击目标。

小心不要将敏感的 API 密钥或访问令牌传递给客户端。确保任何 Webhook 防范伪造攻击。如果你通过自己的域名提供来自其他位置的内容—例如,通过内容分发网络或云存储托管—确保攻击者无法在这些系统上放置恶意软件,并通过你的安全证书将其提供给用户。

了解你在网站上托管的广告可能带来的恶意软件风险。使用信誉良好的广告网络,并充分利用其允许的所有基于 SafeFrame 的安全设置。定期检查你网站上发布的广告,报告任何可疑广告并将其列入黑名单。

在下一章中,你将了解与 XML 解析相关的漏洞。XML 是现代互联网中无处不在的一部分,也是黑客常用来攻击你系统的目标。

第十五章:XML 攻击

image

随着 90 年代互联网的爆炸式增长,各组织开始通过 Web 共享数据。计算机之间共享数据意味着必须达成一个共享的数据格式。在 Web 上的人类可读文档使用超文本标记语言(HTML)进行标记。机器可读的文件通常存储在一种类似的数据格式中,称为 可扩展标记语言(XML)

XML 可以被看作是 HTML 的一种更通用的实现:在这种标记方式中,标签和属性名称可以由文档作者选择,而不是像 HTML 规范中那样固定。在 列表 15-1 中,你可以看到一个 XML 文件,描述了一本书的目录,使用了 <catalog><book><author> 等标签。

<?xml version="1.0"?>
<catalog>
   <book id="7991728882998">
      <author>Sponden, Phillis</author>
      <title>The Evil Horse That Knew Karate</title>
      <genre>Young Adult Fiction</genre>
      <description>Three teenagers with very different personalities
team up to defeat a surprising villain.</description>
   </book>
   <book id="28299171927772">
      <author>Chenoworth, Dr. Sebastian</author>
      <title>Medical Encyclopedia of Elbows, 12th Edition</title>
      <genre>Medical</genre>
      <description>The world's foremost forearm expert gives detailed diagnostic
and clinical advice on maintaining everyone's favorite joint.</description>
   </book>
</catalog>

列表 15-1:描述书籍目录的 XML 文档

这种数据格式的流行,尤其是在 Web 初期,意味着 XML 解析——将 XML 文件转换为内存中的代码对象的过程——在过去几十年的每个浏览器和 Web 服务器中都有实现。不幸的是,XML 解析器是黑客攻击的常见目标。即使你的网站设计上不处理 XML,Web 服务器也可能默认解析该数据格式。本章将展示 XML 解析器如何受到攻击以及如何化解这些攻击。

XML 的应用

与 HTML 类似,XML 将数据项封装在标签之间,并允许标签嵌套在彼此之间。XML 文档的作者可以选择具有语义意义的标签名称,使得 XML 文档自描述。由于 XML 非常易读,这种数据格式被广泛采用,用于编码供其他应用程序使用的数据。

XML 的应用非常广泛。允许客户端软件通过互联网调用函数的应用程序接口(API)通常使用 XML 来接收和响应数据。网页中的 JavaScript 代码在与服务器进行异步通信时,常常使用 XML。许多类型的应用程序——包括 Web 服务器——都使用基于 XML 的配置文件。

在过去的十年中,一些应用开始使用比 XML 更适合、更简洁的数据格式。例如,JSON 是在 JavaScript 和其他脚本语言中编码数据的更自然方法。YAML 语言通过有意义的缩进,使其成为配置文件的更简单格式。尽管如此,每个 Web 服务器都以某种方式实现了 XML 解析,并且需要防范 XML 攻击。

XML 漏洞通常发生在验证过程中。让我们花一点时间讨论在解析 XML 文档的上下文中,验证的含义。

验证 XML

由于 XML 文件的作者能够选择文档中使用的标签名称,任何读取数据的应用程序都需要知道预期的标签名称及其出现顺序。XML 文档的预期结构通常通过正式语法来描述,文档可以根据该语法进行验证

语法文件规定了哪些字符序列是语言中有效的表达式。例如,编程语言的语法可能会规定,变量名只能包含字母数字字符,而某些运算符如+需要两个输入。

XML 有两种主要方式来描述 XML 文档的预期结构。文档类型定义(DTD)文件类似于巴科斯–诺尔范式(BNF)符号,经常用于描述编程语言的语法。XML Schema 定义(XSD)文件是更现代、更具表现力的替代方案,能够描述更广泛的 XML 文档;在这种情况下,语法本身是通过 XML 文件来描述的。这两种 XML 验证方法都被 XML 解析器广泛支持。然而,DTD 包含一些可能暴露解析器于攻击的特性,因此我们将重点讨论这一点。

文档类型定义

DTD 文件通过指定预期文档中标签、子标签和数据类型来描述 XML 文件的结构。列表 15-2 展示了一个 DTD 文件,描述了列表 15-1 中<catalog><book>标签的预期结构。

<!DOCTYPE catalog [
  <!ELEMENT catalog     (book+)>
  <!ELEMENT book        (author,title,genre,description)>
  <!ENTITY  author      (#PCDATA)>
  <!ENTITY  title       (#PCDATA)>
  <!ENTITY  genre       (#PCDATA)>
  <!ENTITY  description (#PCDATA)>
  <!ATTLIST book id CDATA>
]>

列表 15-2:描述列表 15-1 中 XML 格式的 DTD 文件

这个 DTD 描述了顶层的<catalog>标签预计包含零个或多个<book>标签(数量由+符号表示),并且每个<book>标签预计包含描述authortitlegenredescription的标签,以及一个id属性。标签和属性预计包含解析后的字符数据(#PCDATA)或字符数据(CDATA)——即文本而非标签。

DTD 可以包含在 XML 文档中,使文档自我验证。然而,支持这种内联 DTD 的解析器容易受到攻击——因为上传此类 XML 文档的恶意用户可以控制 DTD 的内容,而不是由解析器本身提供。黑客利用内联 DTD 使文档在解析过程中消耗的服务器内存成倍增加(XML 炸弹),并访问服务器上的其他文件(XML 外部实体攻击)。让我们看看这些攻击是如何工作的。

XML 炸弹

XML 炸弹使用内联 DTD 来爆炸 XML 解析器的内存使用量。这会通过耗尽服务器可用的所有内存,导致其崩溃,从而使 Web 服务器停机。

XML 炸弹利用了 DTD 可以指定简单的字符串替换宏,并在解析时展开,这些宏被称为内部实体声明。如果一段文本在 XML 文件中经常使用,你可以在 DTD 中将其声明为内部实体。这样,你就不必每次都输入它,只需在文档中键入实体名称作为简写。在列表 15-3 中,包含员工记录的 XML 文件通过使用内部实体声明在 DTD 中指定公司名称。

<?xml version="1.0"?>
<!DOCTYPE employees [
  <!ELEMENT employees (employee)*>
  <!ELEMENT employee (#PCDATA)>
  <!ENTITY company "Rock and Gravel Company"❶>
]>
<employees>
  <employee>
    Fred Flintstone, &company;❷
  </employee>
  <employee>
    Barney Rubble, &company;❸
  </employee>
</employees>

列表 15-3:内部实体声明

字符串 &company; ❷ ❸ 充当 Rock and Gravel Company ❶ 的占位符。当文档被解析时,解析器将所有 &company; 的实例替换为 Rock and Gravel Company,并生成最终的文档,如列表 15-4 所示。

<?xml version="1.0"?>
<employees>
  <employee>
    Fred Flintstone, Rock and Gravel Company
  </employee>
  <employee>
    Barney Rubble, Rock and Gravel Company
  </employee>
</employees>

列表 15-4:解析器处理 DTD 后的 XML 文档

内部实体声明有其用途,尽管很少使用。问题出现在内部实体声明引用其他内部实体声明时。列表 15-5 显示了一系列嵌套的实体声明,构成了一个 XML 炸弹。

<?xml version="1.0"?>
<!DOCTYPE lolz [
  <!ENTITY lol "lol">
  <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
  <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
  <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
  <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
  <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
  <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
  <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>

列表 15-5:一种 XML 炸弹,被称为 十亿笑声攻击

当此 XML 文件被解析时,&lol9; 字符串将被替换为 10 次 &lol8; 字符串。然后,每一个 &lol8; 实例将被替换为 10 次 &lol7; 字符串。XML 文件的最终形式包含一个 <lolz> 标签,其中有超过 十亿lol 字符串的出现。这个简单的 XML 文件在 DTD 完全展开后将占用超过 3GB 的内存,足以使 XML 解析器崩溃!

消耗 XML 解析器的可用内存将使你的 Web 服务器离线,这使得 XML 炸弹成为黑客发起拒绝服务攻击的有效手段。攻击者所需要做的就是找到一个接受 XML 上传的 URL,他们只需点击一下按钮就能让你的网站离线。

接受内联 DTD 的 XML 解析器也容易受到一种更隐蔽类型攻击的威胁,这种攻击以不同的方式利用实体定义。

XML 外部实体攻击

DTD 可以包含来自外部文件的内容。如果 XML 解析器配置为处理内联 DTD,攻击者可以利用这些外部实体声明来探索本地文件系统或触发来自 Web 服务器本身的网络请求。

一个典型的外部实体如列表 15-6 所示。

<?xml version="1.0" standalone="no"?>
<!DOCTYPE copyright [
  <!ELEMENT copyright (#PCDATA)>
  <!ENTITY copy PUBLIC "http://www.w3.org/xmlspec/copyright.xml"❶>
]>
<copyright>&copy;❷ </copyright>

列表 15-6:使用外部实体在 XML 文件中包含标准版权文本

根据 XML 1.0 规范,解析器应读取外部实体中指定文件的内容,并在 XML 文档中每次引用该实体时插入该数据。在这个例子中,托管在 http://www.w3.org/xmlspec/copyright.xml ❶ 上的数据将被插入到 XML 文档中,每当文本 &copy; ❷ 出现时。

外部实体声明所引用的 URL 可以使用不同的网络协议,具体取决于前缀。我们示例中的 DTD 使用 http:// 前缀,这将导致解析器发起 HTTP 请求。XML 规范还支持使用 file:// 前缀读取本地磁盘文件。因此,外部实体定义是一个安全的 灾难

黑客如何利用外部实体

当 XML 解析器抛出错误时,错误信息通常会包括正在解析的 XML 文档的内容。黑客正是利用这一点,通过外部实体声明来读取服务器上的文件。例如,一个恶意构造的 XML 文件可能会包含对 Linux 系统中类似 file://etc/passwd 文件的引用。当这个外部文件被解析器插入到 XML 文档中时,XML 就会变得不合法——因此解析失败。解析器会忠实地将文件内容包含在错误响应中,允许黑客查看引用文件中的敏感数据。通过这种技术,黑客可以读取易受攻击的 web 服务器上的敏感文件,这些文件包含密码和其他机密信息。

外部实体也可以用于发起 服务器端请求伪造(SSRF) 攻击,攻击者通过这种方式从你的服务器触发恶意 HTTP 请求。一个配置不当的 XML 解析器会在遇到带有网络协议前缀的外部实体 URL 时发起网络请求。能够将你的 web 服务器欺骗成根据攻击者选择的 URL 发起网络请求,对攻击者来说无疑是一个巨大的好处!黑客已经利用这一特性来探测内部网络、发起针对第三方的拒绝服务攻击,并伪装恶意的 URL 请求。你将在下一章中了解更多关于 SSRF 攻击的风险。

保护你的 XML 解析器

这是一个简单的修复,能够保护你的解析器免受 XML 攻击:在你的配置中禁用内联 DTD 的处理。DTD 是一种过时的技术,内联 DTD 本身就是一个糟糕的主意。事实上,许多现代 XML 解析器默认已经经过硬化,这意味着它们在开箱即用时会禁用那些可能使解析器受到攻击的功能,所以你可能已经得到保护。如果你不确定,应该检查你使用的 XML 解析技术(如果有的话)。

以下部分描述了如何在一些主要的 Web 编程语言中保护你的 XML 解析器。即使你认为你的代码并不解析 XML,你使用的第三方依赖项很可能以某种形式使用 XML。确保你分析整个依赖树,以查看当你的 web 服务器启动时,哪些库被加载到内存中。

Python

defusedxml 库明确拒绝内联 DTD,并且是 Python 标准 XML 解析库的直接替代品。使用此模块来替代 Python 的标准库。

Ruby

Ruby 中解析 XML 的事实标准是 Nokogiri 库。自版本 1.5.4 以来,该库已经加固了 XML 攻击的防护,因此请确保你的代码使用该版本或更高版本进行解析。

Node.js

Node.js 有多种解析 XML 的模块,包括 xml2jsparse-xmlnode-xml。大多数模块默认省略 DTD 的处理,因此请确保查阅你所使用的解析器的文档。

Java

Java 有多种解析 XML 的方法。符合 Java 规范的解析器通常通过 javax.xml.parsers.DocumentBuilderFactory 类启动解析。Listing 15-7 说明了如何在该类的任何实例化位置配置安全的 XML 解析,使用 XMLConstants.FEATURE_SECURE_PROCESSING 特性。

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);

Listing 15-7: 安全配置 Java XML 解析库

.NET

.NET 有多种解析 XML 的方法,所有这些方法都包含在 System.Xml 命名空间中。XmlDictionaryReaderXmlNodeReaderXmlReader 默认是安全的,System.Xml.Linq.XElementSystem.Xml.Linq.XDocument 也是如此。System.Xml.XmlDocumentSystem.Xml.XmlTextReaderSystem.Xml.XPath.XPathNavigator 从 .NET 版本 4.5.2 起已被加固。如果你使用的是早期版本的 .NET,应该切换到安全的解析器,或禁用内联 DTD 的处理。Listing 15-8 展示了如何通过设置 ProhibitDtd 属性标志来实现这一点。

XmlTextReader reader = new XmlTextReader(stream);
reader.ProhibitDtd = true;

Listing 15-8: 禁用 .NET 中的内联 DTD 处理

其他考虑事项

外部实体攻击的威胁说明了遵循 最小权限原则 的重要性,该原则指出,软件组件和进程应该仅获得执行其任务所需的最小权限。XML 解析器几乎没有正当理由进行外发网络请求:考虑将 Web 服务器的外发网络请求限制到最小。如果确实需要外发网络访问——例如,如果你的服务器代码调用了第三方 API——你应该在防火墙规则中白名单这些 API 的域名。

同样,限制 Web 服务器可以访问的磁盘目录也很重要。在 Linux 操作系统中,可以通过将 Web 服务器进程运行在 chroot 监狱中来实现,这样可以忽略运行进程尝试更改根目录的任何操作。在 Windows 操作系统中,应该手动白名单 Web 服务器可以访问的目录。

总结

可扩展标记语言(XML)是一种灵活的数据格式,广泛用于在互联网上交换机器可读的数据。如果你的 XML 解析器配置为接受并处理内联文档类型定义(DTD),它可能会受到攻击。XML 爆炸文件使用内联 DTD 来爆炸解析器的内存使用,可能导致你的 Web 服务器崩溃。XML 外部实体攻击引用本地文件或网络地址,并可以用于欺骗解析器泄露敏感信息或发起恶意网络请求。确保你使用一个加强版的 XML 解析器,该解析器禁用了内联 DTD 解析。

下一章将扩展本章提到的一个概念:黑客如何利用你 Web 服务器中的安全漏洞,对第三方发起攻击。即使你不是直接受害者,作为一个合格的互联网公民,阻止利用你的系统进行的攻击也是非常重要的。

第十六章:不要成为共犯

image

恶意行为者在互联网上有很多藏身之处。黑客常常冒充他人,并使用被攻陷的服务器来躲避检测。本章探讨了你的网站可能如何帮助攻击者逃脱恶意行为,即使你并不是他们攻击的目标。

确保你不是网络犯罪的共犯,这样你将获得良好的网络公民分数。更实际地说,如果黑客将你的系统作为攻击其他目标的跳板,你很快就会发现你的域名和 IP 地址被主要服务列入黑名单,甚至可能会被你的托管服务商断开连接。

本章涵盖了几种可能使你成为网络恶意行为共犯的漏洞。前几个漏洞被黑客用来发送有害邮件:诈骗者常常使用电子邮件地址伪造来伪装发件人,并在网站上利用开放重定向来伪装邮件中的恶意链接。

接下来,你将看到如何将你的网站嵌入到他人页面的框架中,并作为点击劫持攻击的一部分。在这种类型的攻击中,你的网站被用作诱饵和替换的手段,欺骗用户点击一些有害的内容。

在前一章中,你已经了解了黑客如何利用 XML 解析器中的漏洞来触发网络请求。如果攻击者能够精心构造恶意 HTTP 请求,触发从你的服务器发出的外部网络访问,那么你就启用了服务器端请求伪造攻击。你将学习这种攻击类型的常见发起方式以及如何防御。

最后,你将了解恶意软件被安装到你的服务器上,进而用于僵尸网络的风险。你可能在不知情的情况下托管了可以被攻击者远程控制的僵尸代码!

电子邮件欺诈

电子邮件是通过简单邮件传输协议(SMTP)发送的。SMTP 最初设计中的一个重大疏漏是没有身份验证机制:电子邮件的发件人可以在From头部附加任何他们想要的电子邮件地址,直到最近,接收方无法验证发件人是否真的是他们声称的身份。

结果,当然,我们都会收到大量垃圾邮件。专家估计,约有一半的电子邮件是垃圾邮件——每天发送近 150 亿封垃圾邮件。垃圾邮件通常包含不需要的(且常常具有误导性)营销材料,给收件人带来麻烦。

与垃圾邮件相关的是钓鱼电子邮件:发件人试图欺骗收件人泄露敏感的个人信息,如密码或信用卡详情。一种常见的伎俩是向受害者发送看似是他们使用的网站的密码重置邮件,但实际重置链接指向一个伪域名——一个看起来与真实域名相似的域名,托管着该网站的伪造版本。假网站会在攻击者的代理下收集用户的凭据,然后将用户重定向到真实网站,使受害者毫无察觉。

更恶劣的这种类型的攻击是精准钓鱼(spearphishing),其中恶意电子邮件的内容是针对小范围受众量身定制的。进行这种攻击的诈骗者通常会对受害者进行详细的调查,以便能够提及同事的名字或冒充同事。CEO 诈骗——诈骗者冒充 C 级高管,通过电子邮件向其他员工请求电汇——根据 FBI 的数据,仅 2016 年至 2019 年间,这类诈骗就让黑客获利超过 260 亿美元。这个数字仅包括那些向执法部门报告损失的受害者。

值得庆幸的是,邮件服务提供商已经开发出复杂的算法来检测垃圾邮件和钓鱼邮件。例如,Gmail 会扫描每封传入的邮件,并迅速判断其是否合法,将任何看起来可疑的邮件发送到垃圾邮件文件夹。垃圾邮件过滤器在分类邮件时会使用许多输入:邮件和主题行中的关键词、邮件域名以及邮件正文中是否包含任何可疑的外链。

您的网站和组织可能会从自定义域发送电子邮件,因此责任在于防止电子邮件被标记为垃圾邮件,并保护您的用户免受伪装成您域名的恶意电子邮件的攻击。您可以通过以下几种方式做到这一点:通过实施发件人策略框架(Sender Policy Framework,SPF)和在生成电子邮件时使用域名密钥识别邮件(DomainKeys Identified Mail,DKIM)。

实施发件人策略框架

实施发件人策略框架(SPF)意味着在 DNS 中将被授权从您的网页域名发送电子邮件的 IP 地址列入白名单。由于 SMTP 位于 TCP 之上,因此电子邮件发送方的 IP 地址无法像From头部那样被伪造。通过在您的域名记录中明确列出 IP 地址,邮件接收代理将能够验证传入的邮件是否来源于允许的来源。

列表 16-1 展示了如何在您的 DNS 记录中指定发件人策略框架。

v=spf1❶ ip4:192.0.2.0/24 ip4:198.51.100.123❷ a❸ -all❹

列表 16-1:一个 DNS 记录,用于将授权从给定域发送电子邮件的 IP 地址范围列入白名单,作为 SPF 的一部分

此记录将作为.txt记录添加到您的域名记录中。在此语法中,v=参数❶定义了使用的 SPF 版本。ip4❷和a❸标志指定了允许为给定域发送消息的系统:在此案例中,是一系列 IP 地址,以及对应域名的 IP 地址(由a标志指示)。记录末尾的-all标志❹告诉邮件提供商,如果前面的机制没有匹配,邮件应被拒绝。

实施域名密钥识别邮件(DKIM)

DomainKeys可用于为发出的邮件生成数字签名,以证明电子邮件确实是从您的域名合法发送的,并且在传输过程中没有被篡改。域名密钥识别邮件(DKIM)使用公钥加密技术,使用私钥为来自域名的发出邮件签名,并允许接收者通过使用托管在 DNS 中的公钥验证签名。只有发送者知道私钥,因此只有他们才能生成合法的签名。邮件接收代理将通过将电子邮件内容和托管在您域上的公钥结合来重新计算签名。如果重新计算的签名与附加在邮件上的签名不匹配,则邮件将被拒绝。

要实现 DKIM,您需要在您的域中添加一个域密钥,作为.txt记录。清单 16-2 显示了一个示例。

k=rsa;❶ p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDmzRmJRQxLEuyYiyMg4suA❷

清单 16-2:一个(公有)域名密钥托管在 DNS 系统中,相应的私钥需要与为该域名生成电子邮件的应用共享。

在此示例中,k表示密钥类型❶,p是用于重新计算签名的公钥❷。

保护您的电子邮件:实用步骤

您的组织可能会从多个位置生成电子邮件。作为响应用户在您网站上操作的电子邮件——即事务性电子邮件——将由您的 Web 服务器软件触发,通常通过像 SendGrid 或 Mailgun 这样的电子邮件服务生成。手动编写的电子邮件将通过 Web 邮件服务(例如 Gmail)或托管在您网络上的电子邮件服务器软件(例如 Microsoft Exchange 或 Postfix)发送。您的团队也可能使用像 Mailchimp 或 TinyLetter 这样的电子邮件营销或新闻通讯服务发送电子邮件。

查阅您的服务提供商或电子邮件服务器的文档,了解如何生成并添加实现 SPF 和 DKIM 所需的 DNS 记录。实际上,您可能已经在使用 DKIM,因为许多事务性邮件和营销服务在您注册时要求您添加相关的 DNS 记录。当您在实施 SPF 时锁定 IP 范围和域名时,请记得考虑所有从您的域名发送电子邮件的软件!

伪装恶意链接在电子邮件中

垃圾邮件算法会检查电子邮件中的恶意链接,为了支持这一点,Webmail 提供商保持更新的域名黑名单,这些域名已知是有害的。扫描指向这些域名的链接是阻止危险电子邮件的一种常见且有效的方法。

因此,骗子们不得不想出新的手段来伪装有害链接,以防止他们的电子邮件被标记并直接发送到垃圾邮件文件夹。这样做的一种方法是使用像 Bitly 这样的 URL 缩短服务,它会将 URL 编码为更短的形式,并在用户访问链接时重定向到该网址。然而,在不断升级的垃圾邮件战争中,电子邮件扫描算法现在会展开指向已知 URL 缩短服务的链接,并检查最终目的地是否有害。

黑客还发现了更微妙的方式来伪装电子邮件中的恶意链接。如果你的网站可以用来伪装指向互联网上任意 URL 的链接——如果你在网站上实现了开放重定向——你可能会像 URL 缩短服务一样帮助黑客伪装恶意链接。你不仅会让用户容易受到钓鱼攻击,而且你发送的真正电子邮件可能会被垃圾邮件检测算法列入黑名单。

开放重定向

在 HTTP 中,重定向发生在 Web 服务器响应 301(临时重定向)或 302(永久重定向)响应代码时,并提供浏览器应导航到的 URL。重定向最常见的用途之一是,如果未经身份验证的用户尝试访问网站,重定向其到登录页面。在这种情况下,网站通常会在用户完成身份验证后发出第二次重定向回到原始 URL。

为了启用第二次重定向,Web 服务器必须在用户登录时记住原始目的地。通常,这通过在登录 URL 中对最终目标 URL 进行编码作为查询参数来实现。如果黑客能够在这个查询参数中编码任意 URL——换句话说,如果第二次重定向可以将用户发送到互联网上的另一个网站——这就被称为开放重定向

防止开放重定向

大多数网站永远不需要重定向到外部 URL。如果你的网站的任何部分会在另一个 URL 中编码一个 URL 以将用户重定向到该目的地,你应该确保这些编码的 URL 是相对 URL,而不是绝对 URL:编码的链接应该指向你的网站内部,而不是外部。

相对 URL 以斜杠(/)开头,容易检查。黑客已经找到了一些方法,能够将绝对 URL 伪装成相对 URL,因此你的代码需要考虑到这一点。清单 16-3 展示了如何通过简单的模式匹配逻辑来检查 URL 是否为相对 URL。

import re
def is_relative(url):
  return re.match(r"^\/[^\/\\]"❶, url)

清单 16-3:使用 Python 中的正则表达式检查链接是否为相对链接(网站内部)

这个模式 ❶ 表示 URL 必须以斜杠开头,接下来的字符不能是另一个斜杠或反斜杠 (**)。检查第二个字符是为了防止像 www.google.com 这样的 URL,这些 URL 会被浏览器解释为绝对 URL;它们将自动以 httphttps 为前缀,具体取决于页面当前使用的协议。

防止开放重定向的另一种方法是避免在查询参数中完全编码 URLs。如果你正在为登录后重定向编码一个 URL,考虑将该 URL 存放在临时 cookie 中,而不是查询参数中。攻击者无法轻易伪造受害者浏览器中的 cookie,因此你将关闭恶意链接的门。

其他考虑事项

某些类型的网站确实需要用户发布外部链接。例如,如果你运营一个社交新闻网站,用户经常会发布指向外部 URL 的链接。如果这适用于你的站点,使用 Google Safe Browsing API 检查每个 URL 是否与有害网站的黑名单匹配。

在你确保了你的电子邮件和重定向代码安全后,确保你的网页无法被其他人的恶意网站嵌套也是非常重要的。让我们看看如何保护你的用户免受点击劫持攻击。

点击劫持

HTML 允许网页包含另一个网页,使用 <iframe> 标签。这使得来自不同网页域名的内容能够以受控的方式混合,因为在框架内运行的 JavaScript 无法访问包含页面。<iframe> 标签常用于在网页中嵌入第三方内容——OAuth 和 CAPTCHA 小部件通常使用它们来保护 cookies。

正如互联网中的任何有用的东西一样,黑客已经找到了滥用 <iframe> 标签的方法。现代 CSS 允许使用 z-index 属性将页面元素层叠在彼此之上;具有较高 z-index 的元素将遮挡具有较低 z-index 的元素,并且首先接收点击事件。页面元素还可以使用 opacity 属性使其透明。通过结合这些技术,黑客可以将一个透明的 <div> 放置在 <iframe> 元素上方,然后诱使受害者点击存储在 <div> 中的任何内容,而不是他们认为自己正在点击的底层内容。

这种点击劫持——点击劫持——已经以各种方式被使用。在某些情况下,受害者被诱导打开他们的摄像头,以便攻击者能够远程观看他们。此技术的另一个变种是 点赞劫持,即受害者被诱导在 Facebook 上未经他们同意地点赞某些内容。在黑暗网络上出售点赞用于促销目的已成为黑客的一个重要赚钱方式。

防止点击劫持

如果您运营一个网站,您应该确保您的网站不会成为点击劫持攻击的诱饵。大多数网站根本不需要被包含在 <iframe> 标签中,因此您应该直接告诉浏览器这一点。现代浏览器支持 Content-Security-Policy 头部,允许服务器的响应指定页面不应有 frame-ancestors,如在示例 16-4 中所示。

Content-Security-Policy: frame-ancestors 'none'

示例 16-4:一个告知浏览器绝不将您的网站托管在框架中的头部

实现此策略会告知浏览器绝不将您的网站放入框架中。

如果由于某种原因您的站点确实需要包含在 <iframe> 中,您应该告诉浏览器 哪些 网站允许托管这样的框架。您可以使用相同的 Content-Security-Policy 头部来指定该网站可以作为其自身框架的祖先。示例 16-5 显示了如何使用关键字 self 来允许您的站点托管指向同一站点其他部分的 iframes。

Content-Security-Policy: frame-ancestors 'self'

示例 16-5:一个允许网站托管其自身 iframes 的头部

最后,如果您确实需要第三方网站能够在框架中托管您的网站,您可以像在示例 16-6 中所示的那样,列出单独的 Web 域名。

Content-Security-Policy: frame-ancestors example.com google.com

示例 16-6:一个允许网站被 example.com google.com 通过 iframe 托管的头部

现在您已经了解了如何防范点击劫持,让我们看看攻击者如何试图从您的服务器发起恶意网络请求。

服务器端请求伪造

黑客进行恶意 HTTP 请求时,通常会试图掩盖这些请求的来源。例如,拒绝服务攻击——将在下一章讨论——当来自许多不同的 IP 地址时效果更佳。如果您的 Web 服务器发出外部 HTTP 请求,而黑客可以控制这些请求发送到哪些 URL,那么您的服务器就容易受到服务器端请求伪造(SSRF)攻击,黑客可以利用您的服务器发送恶意请求。

确实有一些合理的理由需要从您的服务器发出外部网络请求。如果您使用任何形式的第三方 API,这些通常通过 HTTPS 提供为 Web 服务。例如,您可能会使用服务器端 API 发送事务邮件、为搜索索引内容、在错误报告系统中记录意外错误或处理支付。然而,当攻击者能够操控服务器调用其选择的 URL 时,问题就出现了。

通常,SSRF 漏洞发生在 HTTP 请求的出站 URL 从发送给服务器的 HTTP 请求的某一部分不安全地构造时。黑客通过对网站进行蜘蛛抓取,访问每一页,并使用黑客工具将他们遇到的每个 HTTP 参数替换为他们控制的 URL 来检查网站是否存在 SSRF 漏洞。如果他们检测到任何 HTTP 请求发送到他们设置的陷阱 URL,他们就知道这些请求一定是从您的服务器触发的,这意味着您容易受到 SSRF 攻击。

黑客还会检查您网站是否接受 XML 内容,并使用 XML 外部实体攻击来尝试执行 SSRF。第十五章讨论了这种攻击途径。

保护免受服务器端伪造(SSRF)攻击

您可以在多个层级上保护自己免受服务器端伪造(SSRF)的攻击。第一步,也是最重要的一步,是审计您代码中所有发起外部 HTTP 请求的部分。您几乎总是能够提前知道哪些域名需要在 API 调用中被调用,因此构建 API 调用的 URL 时应该使用在配置或代码中记录的 web 域名,而不是来自客户端。确保这一点的一个方法是使用通常与大多数 API 一起免费提供的软件开发工具包(SDK)。

因为您应该遵循深度防御的做法——从多个重叠的方式保护自己免受漏洞攻击——所以在网络层面安装防护措施以防止 SSRF 攻击也是明智之举。在您的防火墙中将需要访问的单个域名列入白名单,禁止所有其他域名,是捕捉在代码审查过程中可能忽略的安全问题的一种好方法。

最后,考虑使用渗透测试来检测代码中的 SSRF 漏洞。这可以通过聘请外部团队来检查您网站的漏洞,或使用自动化在线工具来实现。实际上,您将使用黑客用来发现漏洞的相同工具,在他们自己有机会进行攻击之前进行检查。

僵尸网络

黑客总是在寻找备用的计算能力来支持他们的攻击。如果黑客成功侵入您的服务器,他们通常会安装一个僵尸——一种可以通过远程命令控制的恶意软件。大多数僵尸作为一个点对点网络的一部分运行——一个僵尸网络——它们通过加密协议相互通信。

机器人通常用于感染普通消费者设备,如笔记本电脑。然而,将机器人安装到服务器上是一笔大生意,因为服务器将为机器人提供显著更多的计算能力。骗子们会在暗网上支付高价购买可以控制僵尸网络的访问密钥。他们通常利用这些空闲的计算能力来挖掘比特币或进行点击欺诈——即人为地夸大网站的页面浏览量。僵尸网络还用于生成垃圾邮件或进行拒绝服务攻击(将在下一章讨论)。

防范恶意软件感染

显然,您要避免在服务器上安装任何机器人恶意软件。第六章讨论了可能允许黑客在您的服务器上安装机器人命令注入和文件上传漏洞。请确保按照本章的建议来修复这些漏洞。

此外,您还应该积极保护您的服务器免受感染。运行最新的杀毒软件将帮助您快速发现任何类型的恶意软件。监控您的外发网络访问将突出可疑活动:已安装的机器人将定期轮询其他 IP 寻找其他机器人。您还应该考虑在您的 web 服务器上运行完整性检查器——一种检查敏感目录中文件变化的软件。

如果您使用虚拟化服务或容器,您在这里有一个优势:系统的任何重建通常都会清除已安装的恶意软件。定期从镜像重建系统将大大帮助您防止机器人感染。

总结

通过以下措施避免成为他人互联网攻击的帮凶:

  • 通过在您的域名记录中实施 SPF 和 DKIM 头部来保护您发送的电子邮件。

  • 确保您的网站没有开放的重定向。

  • 通过设置内容安全策略,防止您的网站被托管在 <iframe> 标签中。

  • 审计您的代码,以确保服务器不能被欺骗,向攻击者选择的外部 URL 发送 HTTP 请求,并将外部网络访问列入白名单,以避免在服务器端请求伪造攻击中被利用。

  • 使用虚拟化服务器、病毒扫描程序或漏洞扫描工具来检查并移除机器人。

在下一章中,您将了解黑客可能使用的暴力破解技术,该技术可能使您的 web 服务器离线:拒绝服务攻击。

第十七章:拒绝服务攻击

image

2016 年 10 月 21 日,互联网用户醒来后发现他们许多喜爱的网站无法访问:Twitter、Spotify、Netflix、GitHub、Amazon 等许多网站似乎都无法连接。根本原因是对一个 DNS 提供商的攻击。大量的 DNS 查询请求使得受欢迎的 DNS 提供商 Dyn 瘫痪。直到大部分的一天过去——期间发生了两波巨大的 DNS 查询请求——服务才完全恢复。

这次中断的规模和影响前所未有。(唯一一次可比的事件发生在一只鲨鱼咬断了一条海底互联网电缆,导致整个越南一度无法联网。)然而,这只是拒绝服务(DoS)攻击日益常见且日益危险的最新体现。

拒绝服务攻击不同于本书中讨论的大多数漏洞类型,因为该攻击的目的不是要攻陷系统或网站:其目的仅仅是使其无法为其他用户提供服务。通常,这是通过向网站发送大量流量来实现的,直到所有服务器资源被耗尽。本章将分析一些常见的拒绝服务攻击技术,并提出防御它们的各种方法。

拒绝服务攻击类型

响应一个网络请求通常比发送一个请求需要更多的处理能力。例如,当一个 Web 服务器处理一个 HTTP 请求时,它必须解析请求、运行数据库查询、将数据写入日志,并构建返回的 HTML 内容。用户代理只需生成包含三部分信息的请求:HTTP 动词、发送的 IP 地址和 URL。黑客利用这种不对称性,通过发送大量网络请求来压垮服务器,使其无法响应合法用户的请求。

黑客们已经发现了在网络堆栈的各个层面发起拒绝服务攻击的方法,而不仅仅是通过 HTTP。鉴于他们过去的成功,未来可能会发现更多的方法。让我们来看看攻击者工具包中的一些工具。

互联网控制消息协议攻击

互联网控制消息协议(ICMP) 被服务器、路由器和命令行工具用于检查网络地址是否在线。这个协议非常简单:请求会发送到一个 IP 地址,如果响应的服务器在线,它会返回确认信息。假如你曾使用过ping工具来检查服务器是否可达,那么你就曾在背后使用了 ICMP 协议。

ICMP 是最简单的互联网协议,因此不可避免地,它是第一个被恶意使用的协议。Ping 洪水试图通过发送源源不断的 ICMP 请求来压倒服务器,且只需几行代码即可发起。稍微复杂一些的攻击是 死神 Ping 攻击,它发送损坏的 ICMP 数据包,试图使服务器崩溃。这种攻击利用了旧版软件在接收 ICMP 数据包时未正确进行边界检查的漏洞。

传输控制协议攻击

大多数基于 ICMP 的攻击可以通过现代网络接口来化解,因此攻击者已将目标转移到更高层的网络协议栈,即 TCP,后者是大多数互联网通信的基础。

TCP 会话从 TCP 客户端向服务器发送一个 SYN(同步)消息开始,服务器随后应该回复一个 SYN-ACK(同步确认)响应。然后,客户端通过向服务器发送最后一个 ACK 消息来完成握手。通过用 SYN 消息淹没服务器——即 SYN 洪水——而不完成 TCP 握手,黑客工具会使服务器留下大量“半开”连接,从而耗尽合法客户端的连接池。接着,当合法客户端尝试连接时,服务器会拒绝连接。

应用层攻击

针对 Web 服务器的应用层攻击滥用 HTTP 协议。Slowloris 攻击通过向服务器打开多个 HTTP 连接,并通过定期发送部分 HTTP 请求保持这些连接,从而耗尽服务器的连接池。R-U-Dead-Yet? (RUDY) 攻击向服务器发送永无止境的 POST 请求,并带有任意长的 Content-Length 头部值,以使服务器忙于读取无意义的数据。

黑客还发现了通过利用特定 HTTP 端点将 Web 服务器下线的方法。上传 zip bomb——扩展时会呈指数增长的损坏归档文件——到文件上传功能中,可以耗尽服务器的可用磁盘空间。任何执行反序列化——将 HTTP 请求的内容转换为内存中的代码对象——的 URL 也可能是潜在的漏洞。此类攻击的一个例子是 XML bomb,如你在第十五章中看到的那样。

反射和放大攻击

启动有效的拒绝服务攻击的一个难点是找到足够的计算能力来生成恶意流量。黑客通过使用第三方服务来生成流量,从而克服这一限制。通过向第三方发送恶意请求,并伪造返回地址为他们的目标受害者,黑客反射响应到他们的目标,可能会压垮响应流量的服务器。反射攻击还会掩盖攻击的原始来源,使得追踪变得更加困难。如果第三方服务的回复比初始请求更大或更多,较大的响应将放大攻击的威力。

迄今为止最大的一次拒绝服务攻击是通过反射实现的。2018 年,一名攻击者成功生成了每秒 1.3TB 的数据,并将其指向 GitHub 网站。黑客通过定位大量不安全的 Memcached 服务器,并向它们发送带有 GitHub 服务器 IP 地址的用户数据报协议(UDP)请求来实现这一点。每个响应大约是原始请求的 50 倍,实际上将攻击者的计算能力放大了相同的倍数。

分布式拒绝服务攻击

如果拒绝服务攻击是从单一 IP 地址发起的,那么将该 IP 的流量列入黑名单并停止攻击是相对容易的。现代的拒绝服务攻击,如 2018 年针对 GitHub 的攻击,来自多个协作源——即分布式拒绝服务(DDoS)攻击。除了利用反射,这些攻击通常是从僵尸网络发起的,僵尸网络是由恶意软件控制的各种计算机和互联网连接设备组成的网络,攻击者可以控制这些设备。如今,许多类型的设备都连接到互联网——恒温器、冰箱、汽车、门铃、发刷——而且这些设备容易出现安全漏洞,成为这些僵尸程序的藏身之地。

无意的拒绝服务攻击

并非所有的互联网流量激增都是恶意的。网站突然变得热门,短时间内吸引大量访问者,导致网站瘫痪,这是很常见的情况,因为这些网站并没有设计来应对如此高的流量。Reddit 的死亡之拥常常会使得较小的网站在其登上社交新闻网站的首页时被迫下线。

拒绝服务攻击缓解

防御一次重大拒绝服务攻击既昂贵又耗时。幸运的是,你不太可能成为像 2016 年使 Dyn 公司瘫痪那样规模的攻击的目标。此类攻击需要广泛的计划,只有少数敌人能够实施。你不太可能在你的食谱博客上看到每秒数 TB 的数据流量!

然而,较小的拒绝服务攻击结合勒索请求确实发生,因此进行一些防护措施是非常值得的。以下部分描述了一些你应考虑使用的对策:防火墙和入侵防御系统、DDoS 防护服务以及高度可扩展的网站技术。

防火墙和入侵防御系统

所有现代服务器操作系统都配备了防火墙——一种基于预定安全规则,监控和控制进出网络流量的软件。防火墙使你能够确定哪些端口应该允许传入流量,并通过访问控制规则过滤掉来自特定 IP 地址的流量。防火墙被放置在组织网络的边界,用于在流量进入内部服务器之前过滤掉不良流量。现代防火墙能够阻止大多数基于 ICMP 的攻击,并可用于将单个 IP 地址列入黑名单,这是一种有效的方式来阻止来自单一来源的流量。

应用防火墙 在网络堆栈的更高层运行,充当代理,扫描 HTTP 及其他互联网流量,在流量进入网络其他部分之前进行检查。应用防火墙扫描传入流量,检测是否存在损坏或恶意请求,并拒绝匹配恶意签名的任何内容。由于签名由供应商持续更新,这种方法能够阻止许多类型的黑客攻击(例如,SQL 注入攻击尝试),并有效减轻拒绝服务攻击。此外,除了像 ModSecurity 这样的开源实现外,还有商业应用防火墙供应商(例如,Norton 和 Barracuda Networks),其中一些提供基于硬件的解决方案。

入侵防御系统(IPS) 对保护网络采取更全面的方法:除了实施防火墙和匹配签名外,它们还会寻找网络流量中的统计异常,并扫描硬盘上的文件,查找不寻常的变化。IPS 通常需要较大的投资,但能够提供非常有效的保护。

分布式拒绝服务保护服务

在复杂的拒绝服务攻击中,网络数据包通常与常规数据包无法区分。流量是有效的;只有流量的意图和量才是恶意的。这意味着防火墙无法筛选出这些数据包。

许多公司提供分布式拒绝服务攻击的保护,通常费用较高。当你与 DDoS 解决方案供应商集成时,你将所有传入流量通过其数据中心,这里会扫描并拦截任何看起来恶意的流量。由于解决方案供应商对全球恶意互联网活动有全面的了解,并且拥有大量可用带宽,它可以使用启发式方法防止任何有害流量到达你。

DDoS 保护通常由 CDN 提供,因为它们拥有地理上分布的数据中心,并且通常已经为客户托管静态内容。如果大部分请求已经由托管在 CDN 上的内容处理,那么将剩余的流量通过其数据中心路由也不会花费太多额外的精力。

构建可扩展性

在许多情况下,遭受拒绝服务攻击的情形与网站同时有很多访客是无法区分的。通过准备应对大流量的激增,你可以有效防止许多拒绝服务攻击。构建可扩展性是一个庞大的话题——这方面已经写了许多书籍,且它是一个活跃的研究领域。你应该关注的一些最有效的方法包括:转移静态内容,缓存数据库查询,使用异步处理长时间运行的任务,以及部署多个 Web 服务器。

CDN 将提供静态内容(如图片和字体文件)的负担转移给第三方。使用 CDN 显著提高了网站的响应速度,并减少了服务器的负载。CDN 易于集成,对大多数网站来说具有成本效益,并且能显著减少 Web 服务器需要处理的网络请求量。

一旦将静态内容转移出去,数据库访问调用通常会成为下一个瓶颈。有效的缓存可以防止数据库在流量激增时过载。缓存的数据可以存储在磁盘、内存中,或者在像 Redis 或 Memcached 这样的共享内存缓存中。甚至浏览器也可以帮助缓存:在资源(例如图片)上设置Cache-Control头,告诉浏览器存储资源的本地副本,并在可配置的未来日期之前不再请求它。

将长时间运行的任务转移到作业队列中,将帮助你的 Web 服务器在流量激增时迅速响应。这是一种 Web 架构方法,它将长时间运行的任务(例如生成大文件或发送电子邮件)移到后台的工作进程中。这些工作进程与 Web 服务器分开部署,Web 服务器创建任务并将它们放入队列中。工作进程从队列中取出任务并逐个处理,任务完成后通知 Web 服务器。可以查看 Netflix 技术博客(medium.com/@NetflixTechBlog/,该博客展示了基于这种原则构建的大规模可扩展系统。

最后,你应该有一个部署策略,允许你相对快速地扩展 Web 服务器的数量,以便在繁忙时期提升计算能力。像 Amazon Web Services (AWS) 这样的基础设施即服务 (IaaS) 提供商让你可以轻松地在负载均衡器后多次部署相同的服务器镜像。像 Heroku 这样的平台使得在其 Web 仪表板上移动滑块就能做到这一点!你的托管服务提供商会有某种方法来监控流量量,像 Google Analytics 这样的工具可以用来追踪你网站上有多少会话是何时开启的。然后,当监控阈值被触发时,你只需要增加服务器数量即可。

总结

攻击者利用拒绝服务攻击通过大量流量将网站淹没,使其无法为合法用户提供服务。拒绝服务攻击可以发生在网络栈的任何层次,并且可以通过第三方服务进行反射或放大。通常,它们作为来自攻击者控制的僵尸网络的分布式攻击发起。

简单的拒绝服务攻击可以通过合理的防火墙设置来化解。应用防火墙和入侵防御系统有助于防范更为复杂的攻击。最全面(因此也是最昂贵)的保护来自分布式拒绝服务攻击解决方案提供商,它们会在恶意流量到达你的网络之前将其过滤掉。

各种类型的拒绝服务攻击——包括意外的攻击,当你突然看到大量新访客时——都可以通过构建能够良好扩展的网站来缓解。内容分发网络减轻了从你的网站提供静态内容的负担,且有效的缓存能够防止数据库成为瓶颈。将长时间运行的进程移至任务队列,将使你的 Web 服务器能够高效运行并达到满负荷。积极的流量监控以及轻松扩展 Web 服务器数量的能力,将为你在繁忙时期做好充分准备。

这本书中你将要查看的所有个体漏洞已经讲解完毕!最后一章总结了本书中涉及的主要安全原则,并回顾了各个漏洞以及如何防范它们。

第十八章:总结

image

到此为止,我们已经走到了书的结尾!我们覆盖了很多内容,但现在你应该感觉已经准备好以安全、可靠的方式构建网站。

让我们用一个简短的回顾来结束。本章介绍了 21 条网络安全法则,帮助你记住每章的关键教训。遵循这些简单步骤,你被黑客攻击的可能性将接近于零。

自动化你的发布过程

能够通过单个命令行调用构建你的代码。将代码保存在源代码控制中,并决定一个分支策略。将配置与代码分离,以便轻松构建测试环境。使用测试环境在每次发布前验证功能。自动化代码的部署到各个环境。确保你的发布过程是可靠的、可重现的和可回滚的。始终知道每个环境中运行的是哪个版本的代码,并能够以简单的方式回滚到先前的版本。

进行(彻底的)代码审查

确保每个代码更改在批准发布之前都经过至少一个非原作者团队成员的审查。确保团队成员有时间对代码更改进行批判性评估,并理解审查代码与编写代码同样重要。

测试你的代码(直到无聊)

编写单元测试以确保你代码库中关键部分的正确性,并将它们作为构建过程的一部分运行。每次修改代码时,都在持续集成服务器上运行单元测试。测量单元测试运行时执行的代码库百分比,并始终努力提高这个覆盖率数字。编写测试以在修复 bug 之前 复现软件 bug。测试直到恐惧变成无聊!

预防恶意输入

HTTP 请求的所有部分都可能被黑客篡改,所以要做好准备。通过使用参数化语句构造数据库和操作系统的查询,这样可以防止注入攻击。

中和文件上传

如果用户可以向你的网站上传文件,确保这些文件不能被执行。理想情况下,将文件上传到内容分发网络(CDN)。如果需要更细粒度的文件权限,将它们托管在内容管理系统(CMS)中。作为最后手段,将上传的文件保存在单独的磁盘分区,并确保它们不以可执行权限写入磁盘。

编写 HTML 时转义内容

攻击者将试图通过将恶意 JavaScript 注入你的网页,或通过将 JavaScript 藏在数据库或 HTTP 参数中来进行攻击。确保写入网页的任何动态内容都进行了转义——用安全的实体编码替换 HTML 控制字符。这适用于客户端和服务器端!如果可能,通过使用 Content-Security-Policy 响应头禁用内联 JavaScript 的执行。

对来自其他网站的 HTTP 请求保持怀疑

来自其他域的 HTTP 请求可能是恶意的——例如,攻击者可能已经诱使您的某个用户点击了一个伪装的链接。确保向您的网站发送的GET请求不会有副作用:它们应仅用于检索资源。确保其他类型的请求(如用于发起登录的POST请求)来自您的网站,通过在 HTML 表单和任何由 JavaScript 发起的 HTTP 请求中加入反伪造 cookie 来实现。通过向Set-CookieHTTP 响应头添加SameSite属性,剥离来自您网站域外请求的 cookie。

对密码进行哈希和加盐

如果您在数据库中存储密码,请使用强大的一次性哈希函数(如bcrypt)对其进行加密后再保存。通过添加盐值来为每个哈希增加随机性。

不要泄露用户身份

唯一应该知道用户是否注册了您网站的人是用户自己。确保登录表单和密码重置页面不会允许黑客挖掘您的网站以获取用户列表:保持错误和信息消息的通用性,无论用户名是否存在。

保护您的 cookie

如果攻击者能够窃取您的 cookie,他们就可以劫持用户身份。为您的Set-Cookie响应头添加HttpOnly关键字,以防止恶意 JavaScript 读取 cookie。添加Secure关键字,确保 cookie 只通过 HTTPS 发送。

保护敏感资源(即使您没有链接到它们)

在返回敏感资源的 HTTP 请求之前,检查用户是否有权限访问该资源——即使该资源没有出现在搜索页面或没有从其他地方链接到。

避免使用直接的文件引用

避免在 HTTP 请求中传递和评估文件路径。使用您网站服务器内建的 URL 解析来评估资源路径,或者通过不透明的标识符引用文件。

不要泄露信息

尽量减少攻击者能够了解您技术栈的信息。关闭 HTTP 响应中的任何Server头部,确保Set-Cookie头中的会话参数名称是通用的。避免在 URL 中出现明显的文件后缀。确保在生产环境中关闭详细的客户端错误报告。在构建过程中,对您使用的 JavaScript 库进行混淆处理。

正确使用加密

为您的域名购买安全证书并将其与您的私钥一起安装到 Web 服务器上。将所有流量重定向到 HTTPS,并为Set-Cookie响应头添加Secure关键字,确保 cookie 不会通过未加密的 HTTP 发送。定期更新您的 Web 服务器,以保持加密标准的最新。

保护您的依赖项(和服务)

使用包管理器在构建过程中导入第三方代码,并将每个包固定到特定的版本号。保持对所用包的安全公告的关注,并定期更新它们。安全地存储配置—不要存储在源代码控制中!对于您托管的任何广告,使用 SafeFrame 标准。

防止 XML 解析器的攻击

关闭 XML 解析器中对内联文档类型声明的处理。

安全地发送电子邮件

通过在您的域名记录中使用发件人策略框架(SPF)记录,白名单指定哪些服务器被允许从您的域发送电子邮件。允许邮件接收者验证您发送的电子邮件的发件人地址,并通过使用域名密钥识别邮件(DKIM)检测试图篡改电子邮件的行为。

检查您的重定向(如果有的话)

如果您将用户重定向到存储在 HTTP 请求中的 URL—例如,用户登录后—请确保该 URL 是您域名内的本地地址,而不是外部网站。否则,这些开放重定向将被用来伪装恶意邮件中的链接。

不要允许您的网站被框架嵌套

除非有特定需求,否则不要允许将您的网站嵌套在<iframe>中。通过将Content-Security-Policy: frame-ancestors 'none'添加到您的 HTTP 响应中,禁用框架。

锁定您的权限

遵循最小权限原则—确保每个进程和软件组件以所需的最小权限运行。考虑如果攻击者侵入您系统的任何部分,他们可能会尝试做什么,并减轻危害。确保您的 Web 服务器进程不是以根操作系统账户运行。限制 Web 服务器可以访问的磁盘目录。防止 Web 服务器发起不必要的网络请求。让您的 Web 服务器以一个具有有限权限的账户连接到数据库。

检测并为流量激增做好准备

使用实时监控来检测网站的高流量。通过使用 CDN、客户端 cookie、缓存和异步处理来构建可扩展性。能够轻松地扩展托管网站的服务器数量。如果恶意流量成为问题,可以部署防火墙或入侵防御系统,或者考虑注册分布式拒绝服务(DDoS)保护。

posted @ 2025-11-30 19:37  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报