你不能错过的XSS指引

5,680 阅读13分钟

引子

前段时间,部门一直在招聘前端开发,本着为了让我们的网站

  • 更快

  • 更稳定

  • 更安全

    的三大原则,我也简要的问了一下候选人前端安全方面的问题,其中就有涉及到 XSS 攻击相关的知识。以下就是我们的面试过程。

    我:xxx 你了解过 XSS 攻击吗?

    候选人:XSS 知道啊,就是……,但是我感觉现在我们使用 react 等现代框架,已经不需要去做 XSS 相关的防护了,框架已经帮我们做了这方面的工作。

    我:难道使用了 react 等框架就可以确保不会发生 XSS 攻击吗?

问到这里候选人就开始支支吾吾了。各位读者,也默默地问一下自己,使用了 react 难道就真的不会发生 XSS 攻击了吗? 笔者为了让大家更好地掌握 XSS 攻击相关的知识,主要会围绕以下几点向大家阐述。

  • 什么是 XSS 攻击
  • XSS 攻击举例
  • react 框架中的 XSS 攻击
  • XSS 攻击防护
  • 结语

什么是 XSS 攻击

XSS 攻击全称跨站脚本攻击,是为不和层叠样式表(Cascading Style Sheets, CSS)的缩写混淆,故将跨站脚本攻击缩写为 XSS,XSS 是一种在 web 应用中的计算机安全漏洞,它允许恶意 web 用户将代码植入到提供给其它用户使用的页面中。

XSS 攻击分类

  • 反射型

    反射型 XSS 只是简单地把用户输入的数据 “反射” 给浏览器。也就是说,黑客往往需要诱使用户“点击”一个恶意链接,才能攻击成功。反射型 XSS 也叫“非持久型 XSS”(Non-persistent XSS)。

  • 存储型

    存储型 XSS 会把用户输入的数据“存储”在服务器端(数据库)。存储型 XSS 通常也叫做“持久型 XSS”(Persistent XSS)

  • Dom Based XSS

    Dom Based XSS 并非按照“数据是否存在服务器端”来划分,DOM Based XSS 从效果上来说也是反射型 XSS。单独划分开来,是因为 DOM Based XSS 形成原因比较特别——通过修改页面的 DOM 节点形成的 XSS。

XSS 攻击举例

既然我们已经熟悉了 XSS 攻击是什么已经 XSS 攻击的分类了,那就让我们当一回黑客吧!接下来我们将会分别模拟三种类型的攻击,建议大家先 clone 一下我的示例代码。

文章使用的代码,见仓库,目录为 security/xss

运行实例

git clone https://github.com/chenshengshui/Web-Frontend-Study-Map.git
cd security
npm install
npm run xss

反射型 XSS 举例

示例演示

打开浏览器,进入威胁网站:http://localhost:3000/non-persistent-xss.html

源码解析

目标网站把用户输入的参数直接输出到页面上:

/*
 * 目标网站
 */
const express = require('express');
const app = express();

app.use(express.static('./xss'));
app.get('/', function(req, res) {
  res.setHeader('X-XSS-Protection', 0); // 这个处理是为了关闭现代浏览器的xss防护
  res.send('早上好' + req.query['name']);
});

app.listen(3000);

威胁网站:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>

  <body>
    <a href="http://localhost:3000/?name='<script>alert(/xss/)</script>'"
      >点我进入目标网站</a
    >
  </body>
</html>

威胁网站诱导用户点击链接,但是链接包含攻击的脚本,如果我们从威胁网站进入到我们的目标网站,将会发现 alert('xss')在目标网站执行了。查看源码发现参数中的 script 标签,已经被写入页面中,而这显然不是开发者希望看到的。

存储型 XSS 举例

存储型 XSS 攻击的一个经典场景就是,黑客写了一篇包含有恶意 Javascript 代码的博客文章,文章发表后,所有访问博客文章的用户,都会在他们的浏览器中执行这段恶意的 Javascript 代码。恶意的脚本将保存到服务端,所以这种 XSS 攻击 就叫做“存储型 XSS”。

示例演示

打开浏览器,进入网站:http://localhost:3000/persistent-xss.html, 在文本框输入以下内容:

<div style="color: red">
  欢迎大家阅读我的博客,我是WaterMan,喜欢就关注一下呗!
</div>
<script>
  alert('哈哈,我窃取了你的token了,下次注意噢' + document.cookie);
</script>

点击保存后,我们的 cookie 将被窃取。

源码解析 首先看我们的前端代码,So easy,就只有一个 textarea 和一个提交按钮,点击提交按钮将会把用户输入的内容提交到后台,没毛病,我们平常就是这么开发的。

document.getElementById('submit').onclick = function() {
  var content = document.getElementById('textarea').value;
  var url = 'http://localhost:3000/postArticle';
  easyRequest()
    .post(url, {
      content: content
    })
    .then(res => {
      alert(res.message);
      window.open('http://localhost:3000/persistent-xss');
    });
};

function easyRequest() {
  return {
    post: function(url, data) {
      return fetch(url, {
        body: JSON.stringify(data),
        cache: 'no-cache',
        credentials: 'same-origin',
        headers: {
          'content-type': 'application/json'
        },
        method: 'POST',
        mode: 'cors',
        redirect: 'follow',
        referrer: 'no-referrer'
      }).then(response => response.json());
    }
  };
}

再看我们的后端代码,后端代码也及其的简单,会将我们的提交保存到文件,这里用文件来模拟一般的数据库。

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const fs = require('fs');

app.use(express.static('./xss'));
// parse application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({ extended: false }));
// parse application/json
app.use(bodyParser.json());

app.use(function(req, res, next) {
  // 模拟设置用户tookie
  res.setHeader('Content-Type', 'text/html;charset=utf-8');
  res.cookie('token', 'zkskskdngqkkkgn245tkdkgj');
  next();
});
app.get('/', function(req, res) {
  res.setHeader('X-XSS-Protection', 0);
  res.send('早上好' + req.query['name']);
});

app.get('/persistent-xss', function(req, res) {
  fs.readFile('./xss/data.txt', function(err, data) {
    if (err) {
      return res.send(err);
    }
    res.send(data);
  });
});

app.post('/postArticle', function(req, res) {
  const content = req.body.content + '\n';
  fs.writeFile('./xss/data.txt', content, { flag: 'w' }, function(err) {
    if (err) {
      return res.send({ status: 500, message: err.message });
    }
    res.send({ status: 200, message: '保存成功' });
  });
});

app.listen(3000);

这样一来,只要任意一个用户访问文章详情网站http://localhost:3000/persistent-xss,他的tooken都会被获取。

DOM Based XSS

通过修改页面的 DOM 节点形成的 XSS,称之为 DOM Based XSS。 直接看以下代码

<body>
  <div id="content"></div>
  <input type="text" id="text" value="" style="width: 500px" />
  <input type="button" id="submit" value="write" />
</body>
<script>
  document.getElementById('submit').onclick = function() {
    var str = document.getElementById('text').value;
    document.getElementById('content').innerHTML =
      "<a href='" + str + "'>testLink</a>";
  };
</script>

点击“write”按钮后,会在当前页面插入一个超链接,其地址为文本框的内容。在这里,“write”按钮的 onclick 事件触发后,会修改页面的 DOM 节点,通过 innerHTML 把一段用户数据当作 HTML 写入到页面中,这就造成了 DOM Based XSS。 构造如下数据

' onclick=alert(/xss/) // 输入后,页面代码就变成了:

<a href='' onclick=alert(/xss/)//' > testLink</a>

首先用一个单引号闭合掉 href 的第一个单引号,然后插入一个 onclik 事件,最后再用注释符注释掉第二个单引号。点击这个新生成的链接,脚本将被执行。这样一来我们就发起了一次 DOM Based XSS 攻击。 大家也可以启动项目后,打开http://localhost:3000/dom-based-xss.html网站体验一下。

react 框架中的 XSS 攻击

react 框架确实相比使用 Jquery 编写代码安全不少,因为 React 已经帮我们做了不少工作。 这里我们简要介绍一下 React 到底做了什么。

React 官网介绍,有这样一段描述:

It is safe to embed user input in JSX:

const title = response.potentiallyMaliciousInput;
// This is safe:
const element = <h1>{title}</h1>;

By default, React DOM escapes any values embedded in JSX before rendering them. Thus it ensures that you can never inject anything that’s not explicitly written in your application. Everything is converted to a string before being rendered. This helps prevent XSS (cross-site-scripting) attacks.

翻译一下就是,在 React 中直接嵌入用户输入是安全的,因为 React Dom 会在渲染之前对 JSX 中的值进行实体编码,这样就可以确保你不会注入任何威胁应用的代码。在渲染前,所有东西都会被转换为字符串,这样有助于防御 XSS 攻击。

那这个 HTML 实体编码是怎么做的呢?

& becomes &amp;
< becomes &lt;
> becomes &gt;
" becomes &quot;
' becomes &#39

当然 React 也不是一股脑的把所有都转换了,否则我们就无法正常渲染等标签了,这里后文后详细讲述 xss-fitler 的防御机制。

这里先回到 React 中的 XSS 攻击。React 中有一个 API dangerouslySetInnerHTML,专门用来渲染 HTML,我们看官网的介绍:

dangerouslySetInnerHTML is React’s replacement for using innerHTML in the browser DOM. In general, setting HTML from code is risky because it’s easy to inadvertently expose your users to a cross-site scripting (XSS) attack. So, you can set HTML directly from React, but you have to type out dangerouslySetInnerHTML and pass an object with a __html key, to remind yourself that it’s dangerous. For example:

function createMarkup() {
  return { __html: 'First &middot; Second' };
}

function MyComponent() {
  return <div dangerouslySetInnerHTML={createMarkup()} />;
}

也就是说,dangerouslySetInnerHTML 是 innerHtml 的替代品,但是它还是存在安全性问题。

运行示例

还是之前的仓库,我们进入到目录/security/react-xss

cd security/react-xss
npm install
npm start

源码分析

class Chat extends Component {
  state = {
    input: '',
    result: ''
  };

  onChange = e => {
    this.setState({
      input: e.target.value
    });
  };

  onSubmit = () => {
    this.setState({
      result: this.state.input
    });
  };

  render() {
    return (
      <div styleName="container">
        <div styleName="msg">
          <textarea onChange={this.onChange} placeholder="在此输入内容" />
          <button onClick={this.onSubmit}>提交</button>
          <div dangerouslySetInnerHTML={{ __html: this.state.result }} />
        </div>
      </div>
    );
  }
}

也就是当 react 中使用了 dangerouslySetInnerHTML 这个 api 时,是需要注意很容易引发 XSS 攻击的。

前面我们已经充分了解了 XSS 攻击的定义及分类,也认识到 React 并不是万无一失的,那我们怎么来提升我们网站的安全性呢?接下来就到了本篇文章的重中之重了,我将讲述 XSS 攻击防护的若干手段,让你的网站更安全。

XSS 攻击防护

XSS 的防御是复杂的。下面我会讲述我了解到的几条 XSS 防御的手段,如果还有其他我未提及的,欢迎读者在评论区补充,我们的目的就是让我们的网站更安全,也提升大家的能力。

接下来主要围绕以下几点来讲述 XSS 攻击防护:

  • 四两拨千金——HttpOnly
  • 输入检查
  • 输出检查
  • 正确处理富文本

四两拨千金——HttpOnly

什么是 HttpOnly?

HttpOnly 是一个设置 cookie 是否可以被 javasript 脚本读取的属性,浏览器将禁止页面的 Javascript 访问带有 HttpOnly 属性的 Cookie。

我们回到示例的代码,请看下面设置 cookie 的语法:

const express = require('express');
const app = express();
app.use(function(req, res, next) {
  res.setHeader('Content-Type', 'text/html;charset=utf-8');
  // 模拟设置用户tookie
  res.cookie('token', 'zkskskdngqkkkgn245tkdkgj');
  next();
});

这里设置的 cookie,未设置 HttpOnly 为 true,这样一来我们就直接用 document.cookie 获取 token 值了。如下图

可以在控制台的 Application 标签栏查看 cookie 的详细信息

为了防止黑客通过 Javascript 脚本获取 cookie 信息,我们可以将 HttpOnly 开启。

const express = require('express');
const app = express();
app.use(function(req, res, next) {
  res.setHeader('Content-Type', 'text/html;charset=utf-8');
  // 模拟设置用户tookie
  res.cookie('token', 'zkskskdngqkkkgn245tkdkgj', { httpOnly: true });
  next();
});

不使用 express 等框架时,原生 nodejs 可以这样设置

response.setHeader('Set-Cookie', 'token=zkskskdngqkkkgn245tkdkgj;HttpOnly');

严格来说,HttpOnly并非为了对抗XSS,HttpOnly解决的是XSS后的Cookie劫持攻击。

Cookie 设置知识延展 Cookie 设置 HttpOnly,Secure 属性可以有效防止 XSS 攻击,设置 X-Frame-Options 响应 头可避免点击劫持。

属性介绍:

  • Secure 属性 当设置为 true 时,表示创建的 Cookie 会被以安全的形式向服务器传输(ssl),即只能在 HTTPS 连接中被浏览器传递到服务端进行会话验证,如果是 HTTP 连接则不会传递该信息,所以不会被窃取到 Cookie 的具体内容。

  • HttpOnly 属性 如果在 Cookie 中设置了“HttpOnly”属性,那么通过程序(JS 脚本,Applet 等)将无法读取到 Cookie 信息,这样能有效的防止 XSS 攻击。

  • X-Frame-Options 响应头 X-Frame-Options Http 响应头表示是否允许一个页面在<frame>、<iframe>、<object>中展现的标记。通过设置 X-Frame-Options 阻止站点内的页面被其他页面嵌入从而防止点击劫持。X-Frame-Options 有三个值: OENY:该页面不允许在 frame 中展示,即便是在相同域名的页面中嵌套也不允许。 SAMEORIGIN:该页面可在相同域名页面的 frame 中展示。 ALLOW-FROM uri:该页面可在指定来源的 frame 中展示。

//设置cookie
response.setHeader(
  'Set-Cookie',
  'JSESSIONID=' + sessionid + ';Secure;HttpOnly'
); //设置Secure;HttpOnly
response.setHeader('x-frame-options', 'SAMEORIGIN'); //设置x-frame-options

输入检查

常见的Web漏洞如XSS,SQL Injection等,都要求攻击者构造一些特殊字符,这些特殊字符可能是正常用户不会用到的,所以输入检查就有存在的必要了。

输入检查,很多时候用于格式检查,简单的说就是对用户输入的信息添加一个白名单。比如在网站注册时要求填写的用户名,会被要求为字母、数字的组合。比如“waterMan1”就是一个合法的用户名,而“waterMan$^”就不被允许了。输入检查必须放在服务器端代码中实现,因为在客户端使用javascript进行输入检查,很容易被攻击者绕开。那作为一个前端的我们,是不是就可以两手插袋了呢?其实在当前nodejs大行其道的时机,我们这样认为往往显得过于单纯。我们可以在node端进行输入检查,确保我们的网站安全行。退一万步说,我们在客户端代码加入输入检查,也可以阻挡大部分误操作的正常用户,从而节约服务器资源,也可以提高黑客的攻击门槛。

那我们该怎么进行输入检查呢?

粗暴的做法

对输入检查最有效的方式,就是对一些特殊字符进行过滤或者编码,如

& becomes &amp;
< becomes &lt;
> becomes &gt;
" becomes &quot;
' becomes &#39

但是由于缺乏语境,这样暴力的处理,确实是安全的,但是也会让一些正常的输入变得难于理解,而让用户抓狂。

比如:

1 + 2 < 4 becomes 1 + 2 &lt 4

这样的结果显然不是用户希望看到的。

更加智能的xss-filter

XSS Filter在用户提交数据时获取变量,进行XSS检查,进行比较智能的“输入检查”,匹配XSS的特征。要设计这样一个智能的XSS Filter库是非常困难的,我们在接下来的一篇文章中,会详细讲述一个XSS-Filter库的实现。欢迎大家和我一起见证一个XSS-Filter库从无到有的过程。

输出检查

其实输出检查和输入检查做的事都差不多,但是输出检查会更加简单,因为输出检查可以结合具体语境,但是输出检查工作量会比较大,其实React框架本身已经给我们做了大量输出检查。其实输出检查也可以在接口处统一拦截,这样就和输入检查一致了。

处理富文本

处理富文本和输入检查是一致的,我们会对用户输入的信息添加一个白名单,比如在富文本里面允许用户输入<img >等标签,不允许输入<script>等标签,这个也涉及到xss-filter这个库,会在接下来的文章详细讲述。

结语

又到了结尾了,很开心大家能坚持看到最后。XSS防御还是很值得大家去深入研究的,总之一句话,希望我们大家能从我的文章学到知识。最后,祝大家周末愉快!

@Author: WaterMan