跨站脚本攻击
注册完成,页面跟你打招呼。注册表单收下一个名字,发给服务器,服务器再把它送回来,页面就能用这个名字问候你。
这一来一回,就是整个漏洞。
来自访客的文本,一旦被页面当成标记来处理,就变危险了。
漏洞在哪
下面就是显示问候语的那个函数:
type RegisterResponse = { user: { name: string } }
function displaySuccess(response: RegisterResponse) {
successBox.innerHTML = `Welcome, ${response.user.name}`
}innerHTML 的意思是"把这个字符串当 HTML 来解析"。浏览器读完它,把它描述的元素统统建出来,再把这些元素要求的行为一一接上。
如果名字是 张伟,这没什么问题。浏览器建一个文本节点,然后继续往下走。
如果名字里带着一个标签,浏览器就会把那个标签建出来。它没有任何办法知道这段标记是从一个陌生人的键盘上敲进来的——因为等它抵达解析器时,它已经是你页面的一部分了。
这就是跨站脚本攻击,一般写成 XSS:脚本被注入到受害者信任的站点里,然后在受害者的浏览器中运行。它从 1990 年代末就存在,至今仍然层出不穷,因为促成它的条件太平常了——接收输入,放到页面上。
innerHTML 就是在要求它把字符串当作页面结构,于是它照做了。 麻烦在于,这个字符串来自一个你从没见过的人,而这一路上没有任何环节说过一句"这部分只是文本"。
攻击怎么打
名字是个文本框,所以没人会觉得那是能塞代码的地方。试着在里面输入这个:
<img src="x" onerror="alert('XSS successful')">提交表单。服务器存下这个值,再回显出来。displaySuccess 把它交给 innerHTML,浏览器建出一个真正的 <img> 元素,去加载一张名叫 x 的图片,失败,于是执行 onerror 处理函数。
全程没有一个 <script> 标签。这也是让很多人意外的地方:屏蔽 script 这个词几乎挡不住什么,因为 HTML 里有几十个属性,都会在某件平常的事发生时执行代码。
只在你自己的项目上做这些实验
这里的攻击载荷是为了让你能在自己负责的代码里认出这个漏洞。存储型的载荷不会只留在你手里:下一个打开页面的人,它就在那个人的浏览器里触发了。
所以任何有真实访客的地方都不能碰。只在你自己的项目上试,或者在你拿到书面测试许可的系统上试。
脚本运行时带着页面的源(origin),所以你自己的 JavaScript 能做的事,它都能做:
| 脚本能碰到什么 | 对访客意味着什么 |
|---|---|
| Cookie 和浏览器存储 | 他们的会话可以被复制走,在别处使用 |
| 页面本身 | 页面可以被改写成任何内容、索要任何东西 |
| 你的 API,以他们的身份 | 请求发出去时已经带着身份认证 |
| 页面跳转 | 他们可以被送到一个仿得很像的假站点 |
<script> 标签。其实不用。 一张加载失败的图片、一个鼠标划过的页面元素、一个加载完成的 SVG:每一个都能捎带一条指令。挡住一个关键词,剩下的照样能用。
怎么修
改一个属性:
function displaySuccess(response: RegisterResponse) {
successBox.textContent = `Welcome, ${response.user.name}`
}textContent 设置的是文本。不是"可能是文本的标记",就是文本。同样的载荷现在会以字面字符的形式出现在页面上:<img src="x" onerror="alert('XSS successful')">,看得见,但什么也不干。
它改变的是"让浏览器拿这个值去干什么"。值一个字都没动,之所以安全,是因为它从一开始就不会被执行。
为什么这样修有用
对任何一个值,浏览器都需要收到二者之一的指令:把它当结构,或者把它当文本。innerHTML 给的是前者,textContent 给的是后者。同一个字符串,不同的指令,不同的结局。
这里的修复在前端,因此只修好了这一个页面,别的一概不管。值在服务器上仍然是原样存着的,而存储正是它蹲守的地方:
- 一个列出近期注册用户的管理后台。
- 一封用名字称呼用户的邮件模板。
- 一份导出给别人打开的报表。
- 另一个团队调用同一个 API 的客户端。
这些都是不同的目的地,规则各不相同,而它们谁也不知道这一个页面做了什么决定。所以服务器同样不能信任这个值——这就要说到后面的 schema 校验:在入口处就拒掉一个荒唐的值,能让每个目的地要扛的东西少很多。
同样那个名字还躺在存储里,等着下一个显示它的界面,而那个界面得自己做一次决定。
动手试试
三个值,分别填进一个仍在用 innerHTML 的页面的名字栏。想一想哪些会执行,以及各自是靠什么触发的:
<div onmouseover="alert('one')">hover me</div>
<svg onload="alert('two')"></svg>
<iframe src="javascript:alert('three')"></iframe>对一下你的答案
三个都会执行,而且没有哪两个对访客的要求是一样的。
- div 在等。它渲染成一段普通文本"hover me",指针划过它时
onmouseover才触发。在有人动鼠标之前什么都不会发生——所以一个载荷在截图里可以看起来毫无威胁。 - svg 不等。元素一解析完
onload就触发,所以问候语一渲染出来它就跑了。 - iframe 要看浏览器。
javascript:这个 URL 方案在当前的浏览器里对框架导航是被拦住的,所以这个最有可能什么都不发生——这也说明一个载荷失败其实证明不了多少。换一个浏览器、换一个旧版本,或者换一个略有不同的 sink,答案就可能变了。
把 sink 换成 textContent,这三个就都回到了它们本来的样子:问候语里一段奇怪的文字。
接下来去哪
上面每一个载荷都是一小段字符串。它们造成破坏靠的是"被解释执行",不是"体量大"。
拒绝服务 正好反过来:输入永远不会被当成任何东西去解释,纯粹靠量大就能把事情搞砸。

