前段时间有不少同事收到过钓鱼邮件,还有在做小店这边需求的时候,安全这边的同学提出需要做 CSRF 防御,安全的重要性不容忽视,所以这次分享一下关于 CSRF 相关的知识。
什么是 CSRF/XSRF
CSRF(Cross-site request forgery)跨站请求伪造:攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的注册凭证,绕过后台的用户验证,达到冒充用户对被攻击的网站执行某项操作的目的。
CSRF 攻击一般有以下这些流程:
- 受害者登录信任网站 A,并保留了登录凭证(Cookie)。
- 攻击者引诱受害者访问了网站 B。
- 网站 B 向网站 A 发送了一个请求,浏览器会默认携带网站 A 的Cookie。
- 网站 A 接收到请求后,对请求进行验证,并确认是受害者的凭证,误以为是受害者自己发送的请求。
- 网站 A 以受害者的名义执行了这个请求。
- 攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让网站 A 执行了自己定义的操作。
真实事件:Gmail 在 2007 年的漏洞 www.davidairey.com/google-gmai…
主要讲的是国外有个小伙叫 David Airey,在 Gmail 中点开过一个黑客的链接,然后所有邮件都被窃取了。在 David Airey 的Gmail中,被偷偷设置了一个过滤规则,这个规则使得所有的邮件都会被自动转发到pay.irv@gmail.com。
不久之后的一天,David Airey 发现自己的域名已经被转让了。他以为是域名到期自己忘了续费,直到有一天,对方开出了 $650 的赎回价码,他才开始觉得不太对劲。
David Airey 仔细查了下域名的转让,对方是拥有自己的验证码的,而域名的验证码只存在于自己的邮箱里面。David Airey 回想起那天奇怪的链接,打开后重新查看了“空白页”的源码:
<form method="POST" action="https://mail.google.com/mail/h/ewt1jmuj4ddv/?v=prf" enctype="multipart/form-data">
<input type="hidden" name="cf2_emc" value="true"/>
<input type="hidden" name="cf2_email" value="pay.irv@gmail.com"/>
.....
<input type="hidden" name="irf" value="on"/>
<input type="hidden" name="nvp_bu_cftb" value="Create Filter"/>
</form>
<script>
document.forms[0].submit();
</script>
这个页面只要打开,就会向 Gmail 发送一个 post 请求。请求中,执行了“Create Filter”命令,将所有的邮件,转发到“pay.irv@gmail.com”。
David Airey 由于登陆了 Gmail,所以这个请求发送时,携带着他的登录凭证(Cookie),Gmail 的后台接收到请求,验证了确实有他的登录凭证,于是成功给他配置了过滤器。
黑客可以查看 David Airey 的所有邮件,包括邮件里的域名验证码等隐私信息。拿到验证码之后,黑客就可以要求域名服务商把域名重置给自己。
所以后面即使找到了那条过滤器,将其删除,但已经泄露的邮件,已经被转让的域名,再也无法挽回……
CSRF 的攻击类型
GET 类型
银行网站 A,它以 GET 请求来完成银行转账的操作 如:www.mybank.com/api/transfe…
危险网站 B,它里面有一段 HTML 的代码如下:
<img src="http://www.mybank.com/api/transfer?payee=ls&amount=2000">
首先,你登录了银行网站 A,然后访问危险网站 B,这时你会发现你的银行账户少了 2000 块......
为什么会这样?原因是银行网站 A 违反了 HTTP 规范,使用 GET 请求更新资源。在访问危险网站 B 的之前,你已经登录了银行网站 A,而 B 中的 <img> 以 GET 的方式请求第三方资源(这里的第三方就是指银行网站了,原本这是一个合法的请求,但这里被不法分子利用了),所以你的浏览器会带上你的银行网站 A 的 Cookie 发出 Get 请求,去获取资源 “http://www.mybank.com/api/transfer?payee=ls&amount=2000”, 结果银行网站服务器收到请求后,认为这是一个更新资源操作(转账操作),所以就立刻进行转账操作......
POST 类型
为了杜绝上面的问题,银行决定改用 POST 请求完成转账操作。
银行网站 A 的 WEB 表单如下:
<form action="http://www.mybank.com/api/transfer" method="post">
收款人 <input type="text" name="payee" />
金额 <input type="number" name="amount" />
</form>
危险网站 B 的代码:
<form name="sneak" action="http://www.mybank.com/api/transfer" method="post">
<input type="text" name="payee" value="ls" />
<input type="number" name="amount" value="2000" />
</form>
<script>
window.onload = () => {
document.sneak.submit();
};
</script>
如果用户仍是继续上面的操作,很不幸,结果将会是再次不见 2000 块......因为这里危险网站 B 暗地里发送了 POST 请求到银行!
总结一下上面两个例子,CSRF 主要的攻击模式基本上是以上的两种,其中第 1 种触发条件很简单,一个 <img> 就可以了,而第 2 种比较麻烦,需要使用 JavaScript,但无论是哪种情况,只要触发了 CSRF 攻击,后果都有可能很严重。
理解上面的两种攻击模式,其实可以看出,CSRF 攻击是源于 WEB 的隐式身份验证机制并利用 img 和 form 表单等绕过跨域限制!WEB 的身份验证机制虽然可以保证一个请求是来自于某个用户的浏览器,但却无法保证该请求是用户批准发送的!
CSRF 的防御
CSRF 通常从第三方网站发起,被攻击的网站无法防止攻击发生,只能通过增强自己网站针对 CSRF 的防护能力来提升安全性。
同源检测
既然 CSRF 大多来自第三方网站,那么我们就直接禁止外域(或者不受信任的域名)对我们发起请求。
在 HTTP 协议中,每一个异步请求都会携带两个 Header,用于标记来源域名:
- Origin Header
- Referer Header 这两个 Header 在浏览器发起请求时,大多数情况会自动带上,并且不能由前端自定义内容。 服务器可以通过解析这两个 Header 中的域名,确定请求的来源域。
但需要注意的是 Origin 在以下两种情况下并不存在:
- IE11 同源策略: IE 11 不会在跨站 CORS 请求上添加 Origin 标头,Referer 头将仍然是唯一的标识。最根本原因是因为 IE 11 对同源的定义和其他浏览器有不同,有两个主要的区别,可以参考 MDN Same-origin_policy#IE_Exceptions
- 302 重定向: 在 302 重定向之后 Origin 不包含在重定向的请求中,因为 Origin 可能会被认为是其他来源的敏感信息。对于 302 重定向的情况来说都是定向到新的服务器上的 URL,因此浏览器不想将 Origin 泄漏到新的服务器上。
而且在部分情况下,攻击者可以隐藏,甚至修改自己请求的 Referer。
如果 Origin 和 Referer 都不存在,建议直接进行阻止。
代码演示:
const referer = ctx.request.headers["referer"] || ctx.request.headers["origin"] || "";
if (referer.includes("www.mybank.com")) {
// TODO
} else {
ctx.body = RESPONSE(-1, "illegal source of request .");
}
CSRF 大多数情况下来自第三方域名,但并不能排除本域发起。如果攻击者有权限在本域发布评论(含链接、图片等),那么它可以直接在本域发起攻击,这种情况下同源策略无法达到防护的作用。
综上所述:同源验证是一个相对简单的防范方法,能够防范绝大多数的 CSRF 攻击。但这并不是万无一失的,对于安全性要求较高,或者有较多用户输入内容的网站,我们就要对关键的接口做额外的防护措施。
验证码
这个方案的思路是:每次的用户提交都需要用户在表单中填写一个图片上的随机字符串,强制用户必须与应用进行交互,才能完成最终请求。这个方案可以完全解决 CSRF,但个人觉得在易用性方面似乎不是太好,用户体验比较差。
代码演示:
// 生成并返回验证码
const svgCaptcha = require("svg-captcha");
const captcha = svgCaptcha.create();
ctx.body = {
...
data: {
code: captcha.data
}
}
user.code = captcha.text; // 将 code 存在服务端
// 请求时带上验证码
axios
.post("/api/transferByCode", {
payee,
amount,
code,
})
// 服务端验证验证码
const { code } = ctx.request.body;
if (code === user.code) {
// TODO
} else {
ctx.body = RESPONSE(-1, "code error.");
}
Token
在 CSRF 攻击中攻击者无法直接窃取到用户的信息(Cookie,Header,网站内容等),仅仅是冒用 Cookie 中的信息。
而 CSRF 攻击之所以能够成功,是因为服务器误把攻击者发送的请求当成了用户自己的请求。那么我们可以要求所有的用户请求都携带一个 CSRF 攻击者无法获取到的 Token。服务器通过校验请求是否携带正确的 Token,来把正常的请求和攻击的请求区分开,也可以防范 CSRF 的攻击。
CSRF Token 的防护策略分为三个步骤:
-
将 Token 返回给客户端
在用户登录后,服务器需要给这个用户生成一个 Token 并且返回给客户端,该 Token 通过加密算法对数据进行加密,显然在提交前 Token 不能再放在 Cookie 中 了,否则又会被攻击者冒用,可以选择放在 localstorage 中。
-
页面提交的请求携带这个 Token
在发送 Ajax 请求前从 localstorage 中获取 Token 并把 Token 放到 Authorization Header 中,或者其他 header 也可,前后端约定好一个 header 即可。
-
服务器验证 Token 是否正确
服务器从 Authorization Header 中取出 Token,服务器需要对 Token 解密,并判断其有效性。
代码演示:
// 1. 通过 jwt 生成 token
const jwt = require("jsonwebtoken");
router.post("/api/login", (ctx) => { // 登录成功后返回 token
...
/*
jwt.sign()
payload:载体,一般把用户信息作为载体来生成 token
secret:秘钥,可以是字符串也可以是文件
expiresIn:过期时间 1h 表示一小时
*/
const token = jwt.sign(user, secret, { expiresIn: "1h" });
ctx.body = RESPONSE(0, { token }, "登录成功");
});
// 2.登录后将 token 存本地
axios
.post("/api/login", {
username,
password,
})
.then((res) => {
if (res.data.code === 0) {
const { token } = res.data.data;
localStorage.setItem("token", token);
...
}
});
// 3. 请求时带上 token
axios.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 4. 服务器验证 token
const koaJwt = require("koa-jwt"); // 使用 koa-jwt 控制哪些路由需要jwt验证,哪些接口不需要验证
app.use(
koaJwt({
secret,
}).unless({
path: [/^\/login/, /^\/register/]
})
);
// 通过中间件验证 token
app.use((ctx, next) => {
if (ctx.header && ctx.header.authorization) {
const [scheme, token] = ctx.request.headers["authorization"].split(" ");
if (/^Bearer$/i.test(scheme)) {
try {
// 验证 token 是否有效
jwt.verify(token, secret, {
complete: true
});
} catch (error) {
//token 过期,生成新的token
const newToken = jwt.sign(user, secret, { expiresIn: "1h" });
//将新 token 放入 Authorization 中返回给前端
ctx.res.setHeader("Authorization", newToken);
}
}
}
return next().catch((err) => {
if (err.status === 401) {
ctx.status = 401;
ctx.body = "Protected resource, use Authorization header to get access\n";
} else {
throw err;
}
});
});
// 5. token 过期需要刷新 token
axios.interceptors.response.use(
(response) => {
// 获取更新的 token
const { authorization } = response.headers;
authorization && localStorage.setItem("token", authorization);
return response;
},
(err) => {
if (err.response) {
const { status } = err.response;
// 如果 401 或 405 则到登录页
if (status == 401 || status == 405) {
history.push("/login");
}
}
return Promise.reject(err);
}
);
Token 是一个比较有效的 CSRF 防护方法,是公认最合适的方案,只要页面没有 XSS 漏洞泄露 Token,那么接口的 CSRF 攻击就无法成功。
双重 Cookie 验证
利用 CSRF 攻击不能获取到用户 Cookie 的特点,我们可以要求 Ajax 和表单请求携带一个 Cookie 中的值。
双重 Cookie 采用以下流程:
- 在用户访问网站页面时,向请求域名注入一个 Cookie,内容为随机字符串(例如csrfcookie=v8g9e4ksfhw)。
- 在前端向后端发起请求时,取出 Cookie,并添加到 URL 的参数中
POST:www.mybank.com/api/transfe… - 后端接口验证 Cookie 中的字段与 URL 参数中的字段是否一致,不一致则拒绝。
代码演示:
// 1. 登录后注入 cookie
router.post("/api/login", (ctx) => {
...
ctx.cookies.set(SESSION_ID, cardId, {
httpOnly: false,
});
});
// 2.前端发起请求时取出 cookie,添加到 url 上
const cookie = getCookie("session");
axios
.post(`/api/transferBy2Cookie?cookie=${cookie}`, {
payee,
amount,
})
.then((res) => {
// TODO
});
function getCookie(cname) {
const name = cname + "=";
const ca = document.cookie.split(";");
for (let i = 0; i < ca.length; i++) {
const c = ca[i].trim();
if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
}
return "";
}
// 3. 验证 cookie
router.post("/api/transferBy2Cookie", (ctx) => {
const { cookie } = ctx.request.query;
if (cookie === ctx.cookies.get(SESSION_ID)) {
// TODO
} else {
ctx.body = RESPONSE(-1, "illegal cookie of request .");
}
});
由于任何跨域都会导致前端无法获取 Cookie 中的字段(包括子域名之间),于是发生了如下情况:
- 如果用户访问的网站为 www.a.com, 而后端的 api 域名为 api.a.com。那么在 www.a.com 下,前端拿不到 api.a.com 的 Cookie,也就无法完成双重 Cookie 认证。
- 于是这个认证 Cookie 必须被种在 a.com 下,这样每个子域都可以访问。
- 任何一个子域都可以修改 a.com 下的Cookie。
- 某个子域名存在漏洞被 XSS 攻击(例如 upload.a.com )。虽然这个子域下并没有什么值得窃取的信息。但攻击者修改了 a.com 下的 Cookie。
- 攻击者可以直接使用自己配置的 Cookie,对 XSS 中招的用户再向 www.a.com 下,发起 CSRF 攻击。
缺点:
- 如果有其他漏洞(例如 XSS ),攻击者可以注入 Cookie,那么该防御方式失效。
- 难以做到子域名的隔离。
- 为了确保 Cookie 传输安全,采用这种防御方式的最好确保用整站 HTTPS 的方式,如果还没切 HTTPS 的使用这种方式也会有风险。
防止网站被利用
CSRF 的攻击可以来自:
- 攻击者自己的网站。
- 有文件上传漏洞的网站。
- 第三方论坛等用户内容。
- 被攻击网站自己的评论功能等。
如何防止自己的网站被利用成为攻击的源头呢?
- 严格管理所有的上传接口,防止任何预期之外的上传内容(例如 HTML )。
- 添加 Header X-Content-Type-Options: nosniff 防止黑客上传 HTML 内容的资源(例如图片)被解析为网页。
- 对于用户上传的图片,进行转存或者校验。不要直接使用用户填写的图片链接。
- 当前用户打开其他用户填写的链接时,需告知风险(这也是很多论坛不允许直接在内容中发布外域链接的原因之一,不仅仅是为了用户留存,也有安全考虑)。