速成课 · No. 22
几乎每一个 Web 漏洞都可以追溯到同一个错误:信任来自外部的数据。攻击者控制着请求、表单、URL、上传的文件——全都是。Web 安全这门功夫,就是把每一丝外部输入都当作有敌意的,直到被证明安全,并学会那几种经典攻击——任何忘记这条规则的人都会被它们惩罚。
只讲精髓 · 每个想法一个画面 · 掌握术语
在任何具体攻击之前,有一条单一的原则能防住其中大多数。把这一个想法内化,本课其余的部分不过是它在不同地方的应用。
一切外部输入都由攻击者控制
边境检查站把每一件到达的包裹都视为潜在危险,直到被检查过——它不会因为某样东西看起来普通就放行。
任何从外部进入你系统的东西——表单字段、URL、请求头、上传的文件、API 调用——都由发送它的人控制,而那个人可能是攻击者。他们可以发送任何东西,而不只是你的表单所设想的内容。所以最根本的规则是:在你校验之前,把一切外部输入都当作有敌意的。 本课里几乎每一个漏洞,究其根本,都是某处有人信任了不该信任的输入。
检查发生在 trust boundary 上
建筑外的围墙,只有一道有人把守的门——里面是受信任的,外面不是,凡是跨进来的一切都在那条线上接受检查。
trust boundary 是外部世界与你系统之间的那条线。向内跨越的数据必须在那条边界上接受检查,因为一旦进了里面,你的代码往往就把它当作安全的。经典的错误是在客户端(浏览器)做校验,并以为那就够了——但攻击者会彻底绕过浏览器。真正的安全检查发生在服务器上,在你真正能掌控的那条边界上。
校验输入,转义输出
门口的保安检查证件(谁进来),而翻译则确保你的话在隔壁房间里不会被误读成一条命令(东西怎么出去)。这是两件不同的工作。
两个习惯能挡住大多数攻击。校验输入:拒绝不符合你预期形状的数据——类型不对、太长、格式畸形。转义输出:当你把数据放进另一个上下文(数据库查询、HTML 页面、shell 命令)时,中和掉任何可能在那里被读作代码的东西。下面几乎每一种攻击,都是不受信任的输入溜进了某个会被解释成指令而非数据的地方。
每一丝外部输入都由攻击者控制。在服务器上、在边界处校验它,并在用到它的每一处转义它。这一条规则就能防住大多数攻击。
破坏力最大的一类攻击也最容易理解:不受信任的输入被当作了代码。教科书式的例子是 SQL injection,它展示了整个套路。
Injection:输入变成代码
一张让你填名字的表格,有人却写进一条指令——而办事员把整行念出声时,无意间执行了那条指令。
injection 发生在不受信任的输入被混进一条命令或查询、并被当作其中一部分执行的时候。系统本想把你的输入当作数据来用,但这个输入被精心构造成会被读作代码。从 SQL 到 shell 命令乃至更多场景,根上的缺陷都一样:「指令」与「用户的数据」之间的边界从未被强制执行,于是攻击者塞进了指令。
SQL injection,那个经典案例
一张图书馆索书单,有人在书名一栏里写下「……并且把每一扇门都解锁」——自动化系统老老实实地两件都办了。
在 SQL injection 中,攻击者把数据库语法打进一个普通字段。如果你的代码靠把输入拼进字符串来构造查询,他们的输入就成了查询的一部分——让他们能读取其他用户的数据、绕过登录,或删掉数据表。几十年来它一直是最常见、最严重的 Web 漏洞之一,而它完全来自用不受信任的输入做字符串拼接来构造查询。
parameterized query 才是解药
一张带着上锁、贴好标签盒子的表格:你在「name」盒子里写的任何东西都只会被当作一个名字,绝不会成为表格指令的一部分,无论你往里放什么。
解药是 parameterized query(预编译语句):你用占位符写查询,把用户输入单独传进去,于是数据库始终把它当作纯数据,绝不当作 SQL。这个输入可以是你能想象到的最恶毒的字符串,它仍然无法改变查询的结构。绝不要靠把输入拼进字符串来构造查询——每一次都用参数,SQL injection 就根本无从发生。
injection 是被当作代码执行的不受信任的输入。SQL injection 是经典案例,而 parameterized query——输入作为数据传入、绝不拼接——就是解药。
如果说 injection 是输入在服务器上变成代码,那么 cross-site scripting 就是输入在浏览器里变成代码。它把你自己的页面变成对付你用户的武器。
XSS:输入在浏览器里变成脚本
一块社区公告板,有人钉上一张被做了手脚的纸条,使得任何读它的人口袋都被悄悄掏空——危险是由那块受信任的公告板本身递送的。
cross-site scripting(XSS)是指攻击者让他们的 JavaScript 在另一个用户的浏览器里、在你的站点上运行。如果你拿到用户输入——一条评论、一个名字、一份资料——并把它直接放进页面,攻击者就能提交一个 <script> 而不是文本,它便会对每一个查看那个页面的人运行。他们的代码现在以你用户的会话身份行事:窃取 cookie、冒充用户、篡改页面。
它带着你用户的信任运行
一个穿着受信任制服的冒名顶替者:人们之所以照办,是因为他们信任的是那身制服——你站点的域名——而不是制服里的那个人。
XSS 之所以危险,是因为那段恶意脚本是以你的站点身份运行的,带着用户给予你域名的全部信任。它能读到用户看到的东西、以用户身份行事,并把用户的数据发给攻击者。浏览器无从得知这段脚本不是你写的——它来自你的页面。这就是为什么「不过是个评论框」会成为著名的临终遗言:任何用户输入能抵达页面的地方,都是一个潜在的 XSS 漏洞。
转义输出并使用内容策略
一位翻译,把每位来宾的话都在屏幕上渲染成纯文本——这样即使有人喊出一条命令,它也只会显示为无害的引用文字,而不是任何人会去执行的命令。
核心的解药是转义输出:当你把用户数据放进 HTML 时,把像 < 和 > 这样的字符转换成无害的显示文本,让它们渲染为内容,绝不作为标记执行。现代框架默认就这么做,所以你应该让它们去做。再叠加一层 Content Security Policy——一条限制哪些脚本可以运行的浏览器规则——作为第二道防线。把任何抵达页面的东西都当作不受信任的,并在它出去的路上中和掉它。
XSS 是不受信任的输入带着你站点的信任、在你用户的浏览器里作为脚本运行。转义一切抵达页面的东西,并加上一条 Content Security Policy。
下一种攻击什么都不注入——它滥用浏览器自身的习惯:给每个请求都附上你的凭据,从而骗它在你毫无此意时替你行事。
CSRF:你的浏览器被骗去行事
有人在一张表格上伪造你的签名,并从你的地址寄出——银行看到一份来自你的、有效且已签名的请求,就把它办了,浑然不知不是你写的。
cross-site request forgery(CSRF)利用了这样一个事实:浏览器会自动给任何发往某站点的请求附上你的 cookie——包括你已登录的会话。攻击者在自己那个恶意页面上放一个发往你银行的隐藏请求;当你在登录着银行的情况下访问该页面时,你的浏览器就会带着你的会话把这个请求发出去,银行便以为是你的本意。你在从未做出选择的情况下被迫行事了。
confused deputy 问题
一位受信任、掌握着钥匙的助理,被一张伪造的字条骗去打开了金库——助理本就拥有权限,却被骗着替别人动用了它。
CSRF 是一种「confused deputy(被搞糊涂的代理人)」攻击:你的浏览器握有真实的权限(你的会话),却被骗去替攻击者行使它。服务器分辨不出区别,因为这个请求看上去和一个真实请求一模一样——同样的 cookie、同样的用户。缺陷不在于凭据被窃;而在于一个有效请求是由你以外的人触发的,且没有任何东西证明了你的意图。
用 token 和 SameSite 证明意图
一家银行要求每一笔转账都附上一个一次性码,而这个码只印在你自己的对账单上——来自别处的伪造请求无法附上它,于是被拒绝。
解药是要求证明是你的页面发出了这个请求。CSRF token 是你站点嵌进它自己表单、并在提交时检查的一个秘密值;攻击者的页面无从得知它,于是伪造的请求会失败。现代的补充是 SameSite cookie 属性,它告诉浏览器:对来自其他站点的请求,不要附上你的会话 cookie。两者合在一起,确保一个敏感操作来自你的站点、带着你的意图。
CSRF 骗你的浏览器带着你的会话发出一个请求。用一个 CSRF token 和 SameSite cookie 证明意图,让伪造的跨站请求被拒绝。
有些数据如此敏感,以至于你如何存储它本身就是一个安全决定。密码是经典的例子,它揭示了人们老是混淆的两个词之间的区别。
绝不要以明文存储密码
把每个人家的钥匙列成一张清单,放在前台一个没锁的抽屉里——一次闯入,家家洞开。便利本身就是灾难。
如果你以可读文本存储密码,那么任何拿到你数据库的人——通过一次入侵、一次泄露、一个内鬼——就立刻拥有了每一个用户的密码。而且因为人们会重复使用密码,你连他们的其他账户也一并危及了。以明文存储密码,是 Web 安全里最严重、最基本的错误之一。这种数据太危险了,不能保存成你能读取的形式。
hashing 是单向的;encryption 是双向的
碎纸机对比一个上锁的盒子:盒子可以解锁回原样,但碎纸机把纸变成你永远拼不回去的碎屑——可同一张纸总是以同样的方式被绞碎。
这是必须钉牢的区分。encryption 是可逆的:有钥匙,你就能把打乱的数据还原回原样(用它处理你必须再次读取的数据,比如存起来的 API key)。hashing 是单向的:它把输入变成一个固定的指纹,无法逆推回输入,但同样的输入总是产生同样的指纹。它们解决不同的问题,把它们搞混是个经典错误。
对密码做 hashing,并加 salt
核对一枚火漆印:你不需要原信就能验证它——只要重新盖一下、比对印痕。你确认了吻合,却从未存下那个秘密本身。
你对密码做 hashing,是因为你其实从不需要把密码取回——你只需要核对它。存下那个 hash;登录时,对他们打进来的内容做 hashing 再比对。一次入侵泄露的是指纹,不是密码。再加一个 salt——每个密码一个独一无二的随机值——这样相同的密码会得到不同的 hash,预先算好的攻击表也就失效了。使用一个慢的、专为密码设计的 hash(比如 bcrypt 或 Argon2),绝不要用一个快的通用 hash,好让猜测的代价高昂。
绝不要以明文存储密码。encryption 对你必须读回的数据是可逆的;hashing 对你只需验证的数据是单向的——对密码做 hashing,加 salt,用一个慢的算法。
在具体攻击之外,还有两条原则,用来在某些东西被攻破时限制损害——因为在安全里,你假定某些东西终将被攻破。
给每个部分它所需的 least privilege
一张只能开你自己房间和健身房的酒店门卡——而不是楼里的每一扇门。万一丢了,损害被限制在它够得着的范围里。
least privilege 意味着每个用户、服务和凭据只获得其工作所需的最小访问权——一点都不多。你的 Web 应用所用的数据库账户,如果它只读写行,就不该有能力删掉数据表。这样当某样东西被攻破时——而且要假定它会——爆炸半径就很小,被那一个部分被允许做的事所限定。过宽的权限会把一次小入侵变成一次彻底的入侵。
defense in depth:一层层,而不是单独一堵墙
一座有护城河、城墙、城门和卫兵的城堡——没有哪一道屏障被指望是完美的,于是每一道都接住上一道放过去的。
defense in depth 意味着把彼此独立的防护一层层叠起来,使得任何一处失效都不致命。校验输入,并且用 parameterized query,并且以 least privilege 运行,并且转义输出。任何单一的控制都可能失效或被绕过;合在一起,它们就让一次彻底的攻破必须同时击败所有这些。安全不是一堵完美的墙——而是一层层重叠的、平平无奇的控制,每一道都假定其他几道可能失效。
别在错误和响应里漏出线索
一扇上锁的门,被晃动时居然热心地宣告「钥匙不对——真的那把是黄铜的、还有点弯」——等于把怎么进来精确地告诉了入侵者。
啰嗦的错误和过度透露的响应等于给攻击者递上一张地图。一个说「密码错误」(而不是「无此用户」)的登录,等于确认了哪些账户存在;一段堆栈跟踪暴露了你的框架、版本和查询结构。给用户看一条笼统的消息,把细节私下记进日志,绝不返回多于客户端所需的数据。安静地失败不只是整洁——它剥夺了攻击者那种让下一步变得轻松的侦察。
假定某些东西会突破进来。least privilege 限定损害,defense in depth 意味着没有哪一处失效是致命的,而安静的错误剥夺了攻击者的地图。
安全是一种贯穿于你如何构建之中的实践,而不是最后才拴上去的一个功能。习惯没几个,而且大多数不过是那条唯一的规则被有纪律地应用。
靠框架,并保持它打好补丁
你不会自己锻造锁——你装上经过验证的锁,并在有缺陷被公布时更换它们。自己造锁,正是你得到一把更差的锁的方式。
大多数经典攻击,成熟的框架和库其实早已解决:它们替你转义输出、把查询参数化、处理 CSRF token——前提是你按其设计意图使用它们,而不是绕着它们走。而既然依赖中不断有漏洞被发现,就让它们保持更新;一个没打补丁的库是最常见的入口之一。别发明你自己的加密或你自己的转义——用经过审验的工具,并让它们保持最新。
把 OWASP Top 10 当作你的地图
飞行前检查清单之所以存在,是因为同样那几种失误造成了大多数坠机——你每次都核对它们,而不是在空中重新发现它们。
你不必去想象每一种威胁。OWASP Top 10 是业界列出的最常见、最严重的 Web 漏洞清单——injection、坏掉的访问控制等等——它同时也是一份检查清单。拿你的应用对照它走一遍,你就能抓住那些造成了绝大多数真实入侵的类别。用一张已知的地图,胜过指望自己想到了一切。
- 外部输入从哪里进入,它有没有在服务器上、在边界处被校验? - 查询——是参数化的, 绝不靠拼接输入来构造? - 输出到页面——已转义,让用户输入无法作为脚本运行? - 会改变状态的操作——是否用 token 和 SameSite 防住了 CSRF? - 敏感数据——密码有没有 加 salt 做 hashing,机密绝不以明文存储? - least privilege——每个部分是否只拥有它所需的访问权?
- trust boundary / 校验输入 / 转义输出——那条唯一的规则和它的两个习惯。 - injection / SQL injection / parameterized query——被当作代码运行的输入,以及解药。 - XSS / Content Security Policy——在浏览器里被当作脚本运行的输入,以及它的防御。 - CSRF / CSRF token / SameSite——一个带着你会话的伪造请求,以及如何证明意图。 - hashing / encryption / salt——单向验证对比双向读取,以及每个密码的随机性。 - least privilege / defense in depth——限定损害,并把防护一层层叠起来。 - OWASP Top 10—— 业界那张最常见漏洞的地图。
- 你把一切外部输入都当作有敌意的,在服务器上、在边界处检查。 - 查询是 参数化的,页面输出被转义——默认如此,经由框架。 - 会改变状态的请求带着一个 CSRF token,而 cookie 是 SameSite 的。 - 密码被 做了 hashing 并加了 salt;没有任何敏感的东西以明文存储。 - 每个部分都以 least privilege 运行,依赖保持打好补丁,并且你对照 OWASP Top 10 检查。
Web 安全大半是一条规则被有纪律地应用:永远不要信任输入。在边界处校验,在出去的路上转义,安全地存储机密,并为某些东西溜进来的那一刻叠好防护。