Skip to content
This page has been auto-translated and may contain errors.View in English

跨站脚本攻击

注册完成,页面跟你打招呼。注册表单收下一个名字,发给服务器,服务器再把它送回来,页面就能用这个名字问候你。

这一来一回,就是整个漏洞。

来自访客的文本,一旦被页面当成标记来处理,就变危险了。

漏洞在哪

下面就是显示问候语的那个函数:

Vulnerable
ts
type RegisterResponse = { user: { name: string } }

function displaySuccess(response: RegisterResponse) {
  successBox.innerHTML = `Welcome, ${response.user.name}`
}

innerHTML 的意思是"把这个字符串当 HTML 来解析"。浏览器读完它,把它描述的元素统统建出来,再把这些元素要求的行为一一接上。

如果名字是 张伟,这没什么问题。浏览器建一个文本节点,然后继续往下走。

如果名字里带着一个标签,浏览器就会把那个标签建出来。它没有任何办法知道这段标记是从一个陌生人的键盘上敲进来的——因为等它抵达解析器时,它已经是你页面的一部分了。

这就是跨站脚本攻击,一般写成 XSS:脚本被注入到受害者信任的站点里,然后在受害者的浏览器中运行。它从 1990 年代末就存在,至今仍然层出不穷,因为促成它的条件太平常了——接收输入,放到页面上。

Juno漏洞在哪 浏览器在这里并不是疏忽大意。它做的正是别人吩咐它做的事:innerHTML 就是在要求它把字符串当作页面结构,于是它照做了。

麻烦在于,这个字符串来自一个你从没见过的人,而这一路上没有任何环节说过一句"这部分只是文本"。

Juno漏洞在哪 被赋值的那个属性,就是问题的全部。审查前端代码时,去搜 innerHTMLouterHTMLinsertAdjacentHTMLdocument.write,然后逐个追问:这个值是从哪儿来的?

这些位置叫做 sink(汇聚点):值在这里不再是数据,开始被解释执行。一个值安全到什么程度,取决于它最终落进哪个 sink——所以同一个名字,放在这一行没事,放在下一行就危险。

Juno漏洞在哪 这三种形态值得分别叫出名字,因为它们要用不同的办法修。上面这个是反射型 XSS:值发到服务器,又原封不动地随响应回来。存储型 XSS 是同一个漏洞加上耐心——存一次,之后每个打开页面的人都会被喂到。

而基于 DOM 的 XSS 根本不经过服务器。DOM 是浏览器对页面的实时对象模型,这一变种是脚本用页面自己读到的值去改写它。

这种攻击载荷可以藏在 URL 井号后面的片段里,而浏览器不会把这部分发往服务端。

最后这点直接影响你该怎么找它。服务器日志里看不到 DOM XSS 的载荷,因为服务器压根没收到过。

如果你只靠翻日志来排查这类漏洞,那有三分之一的情况你从结构上就是看不见的。

攻击怎么打

名字是个文本框,所以没人会觉得那是能塞代码的地方。试着在里面输入这个:

html
<img src="x" onerror="alert('XSS successful')">

提交表单。服务器存下这个值,再回显出来。displaySuccess 把它交给 innerHTML,浏览器建出一个真正的 <img> 元素,去加载一张名叫 x 的图片,失败,于是执行 onerror 处理函数。

全程没有一个 <script> 标签。这也是让很多人意外的地方:屏蔽 script 这个词几乎挡不住什么,因为 HTML 里有几十个属性,都会在某件平常的事发生时执行代码。

只在你自己的项目上做这些实验

这里的攻击载荷是为了让你能在自己负责的代码里认出这个漏洞。存储型的载荷不会只留在你手里:下一个打开页面的人,它就在那个人的浏览器里触发了。

所以任何有真实访客的地方都不能碰。只在你自己的项目上试,或者在你拿到书面测试许可的系统上试。

脚本运行时带着页面的源(origin),所以你自己的 JavaScript 能做的事,它都能做:

脚本能碰到什么对访客意味着什么
Cookie 和浏览器存储他们的会话可以被复制走,在别处使用
页面本身页面可以被改写成任何内容、索要任何东西
你的 API,以他们的身份请求发出去时已经带着身份认证
页面跳转他们可以被送到一个仿得很像的假站点
Juno攻击怎么打 "脚本"这个词让人以为一定要有 <script> 标签。其实不用。

一张加载失败的图片、一个鼠标划过的页面元素、一个加载完成的 SVG:每一个都能捎带一条指令。挡住一个关键词,剩下的照样能用。

Juno攻击怎么打 "同源"这三个字值得多想一会儿。脚本是以你的应用的身份运行的,所以那一刻,凡是建立在"信任自己前端"之上的防护全都失效了。

一个具体后果:给会话 cookie 加上 HttpOnly,能阻止脚本读取它,但完全阻止不了脚本发请求——浏览器照样会把 cookie 带上。这是个好措施,只是覆盖面比看起来窄。

Juno攻击怎么打 黑名单注定要输,因为攻击面是整份 HTML 规范,不是一张词表。光是事件处理属性就有几十个,而且还在不断增加。

所以即便你的转义已经做对了,内容安全策略(Content Security Policy,一个告诉浏览器允许运行哪些来源脚本的响应头)依然值得上。它是第二道防线,为将来某天漏掉一处输出点做准备。

真正管用的版本是基于 nonce 或基于 hash 的。策略里带 unsafe-inline,恰好就放行了这次攻击用的内联处理函数——这也是策略最常见的沦为装饰的方式。

不要拿它来代替修 sink。上它的理由是:你总有一天会漏掉一个 sink。

怎么修

改一个属性:

Fixed
ts
function displaySuccess(response: RegisterResponse) {
  successBox.textContent = `Welcome, ${response.user.name}`
}

textContent 设置的是文本。不是"可能是文本的标记",就是文本。同样的载荷现在会以字面字符的形式出现在页面上:<img src="x" onerror="alert('XSS successful')">,看得见,但什么也不干。

Juno怎么修 注意这个修法没做什么。它没有检查这个名字,没有从里面剔掉任何东西,也没有判断它看起来是否可疑。

它改变的是"让浏览器拿这个值去干什么"。值一个字都没动,之所以安全,是因为它从一开始就不会被执行。

Juno怎么修 默认就用 textContent,并且把每一处 innerHTML 都当成"需要给个理由"的东西。大多数 innerHTML 存在,只是因为有人想要一个换行或者一个加粗的词——用 createElement 建个小元素就能搞定,不必把门打开。

如果标记确实必须来自用户输入,比如评论里的富文本,那是消毒(sanitizer)该干的活。用一个有人维护的库,比如 DOMPurify,绝对不要用你自己手写的正则——因为你要解析的东西是 HTML,而 HTML 比它看起来古怪得多。

Juno怎么修textContent 只是某一种上下文(HTML 文本)的正确转义方式。不同上下文没有通用答案。同一个值放进属性里,需要属性编码;放进 URL 里,需要 URL 编码;放进 <script> 块里,需要 JavaScript 字符串编码;放进 CSS 里,又是另一套。

最经典的翻车方式是:值在进来的时候转义了一次,之后又被拿到一个规则完全不同的地方复用。所以转义应该放在输出的时候做,那时才知道目的地是哪里;而校验应该放在输入的时候做,那时你在决定这个值到底要不要收。

现代框架会帮你转义文本插值,这就省掉了大部分麻烦。但它们也各留了一个后门:React 的 dangerouslySetInnerHTML 和 Vue 的 v-html——名字本身就是全部的警告。做代码审查时先 grep 这两个。

为什么这样修有用

对任何一个值,浏览器都需要收到二者之一的指令:把它当结构,或者把它当文本。innerHTML 给的是前者,textContent 给的是后者。同一个字符串,不同的指令,不同的结局。

这里的修复在前端,因此只修好了这一个页面,别的一概不管。值在服务器上仍然是原样存着的,而存储正是它蹲守的地方:

  • 一个列出近期注册用户的管理后台。
  • 一封用名字称呼用户的邮件模板。
  • 一份导出给别人打开的报表。
  • 另一个团队调用同一个 API 的客户端。

这些都是不同的目的地,规则各不相同,而它们谁也不知道这一个页面做了什么决定。所以服务器同样不能信任这个值——这就要说到后面的 schema 校验:在入口处就拒掉一个荒唐的值,能让每个目的地要扛的东西少很多。

Juno为什么这样修有用 修好一个页面,并不等于这个值安全了。只是它在这里安全了。

同样那个名字还躺在存储里,等着下一个显示它的界面,而那个界面得自己做一次决定。

Juno为什么这样修有用 遇到这类问题时,别只修 bug 报告里指出的那一行。把这个字段被渲染的每一处都搜出来——存储型载荷落到哪儿就在哪儿触发,而管理后台通常是整个代码库里被审查得最少的那一面。

管理页面也是它最不该触发的地方,因为它借用的那个会话,权限最大。

Juno为什么这样修有用 这也是"在输入时消毒"作为策略一直失败的原因。它把某一个目的地的假设烧进了存储的数据里,把本来合法的值弄坏了,最后给你留下一张表,满是半编码的字符串——等第二个消费方出现时,谁也没法安全地把它们还原回去。

站得住脚的分工是:在边界做校验,因为那时你在决定要不要收下这个值;在输出时做转义,因为只有渲染方知道它要去哪里。OWASP 的防护速查表值得一直开着,而它正是按输出上下文来组织的,原因就在这儿。

动手试试

三个值,分别填进一个仍在用 innerHTML 的页面的名字栏。想一想哪些会执行,以及各自是靠什么触发的:

html
<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,这三个就都回到了它们本来的样子:问候语里一段奇怪的文字。

接下来去哪

上面每一个载荷都是一小段字符串。它们造成破坏靠的是"被解释执行",不是"体量大"。

拒绝服务 正好反过来:输入永远不会被当成任何东西去解释,纯粹靠量大就能把事情搞砸。