从钓鱼邮件到 CSRF 攻击

684 阅读4分钟

前段时间有不少同事收到过钓鱼邮件,还有在做小店这边需求的时候,安全这边的同学提出需要做 CSRF 防御,安全的重要性不容忽视,所以这次分享一下关于 CSRF 相关的知识。

1624183068892.png

什么是 CSRF/XSRF

CSRF(Cross-site request forgery)跨站请求伪造:攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的注册凭证,绕过后台的用户验证,达到冒充用户对被攻击的网站执行某项操作的目的。

CSRF 攻击一般有以下这些流程:

  • 受害者登录信任网站 A,并保留了登录凭证(Cookie)。
  • 攻击者引诱受害者访问了网站 B。
  • 网站 B 向网站 A 发送了一个请求,浏览器会默认携带网站 A 的Cookie。
  • 网站 A 接收到请求后,对请求进行验证,并确认是受害者的凭证,误以为是受害者自己发送的请求。
  • 网站 A 以受害者的名义执行了这个请求。
  • 攻击完成,攻击者在受害者不知情的情况下,冒充受害者,让网站 A 执行了自己定义的操作。

image.png

真实事件: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 的防护策略分为三个步骤:

  1. 将 Token 返回给客户端

    在用户登录后,服务器需要给这个用户生成一个 Token 并且返回给客户端,该 Token 通过加密算法对数据进行加密,显然在提交前 Token 不能再放在 Cookie 中 了,否则又会被攻击者冒用,可以选择放在 localstorage 中。

  2. 页面提交的请求携带这个 Token

    在发送 Ajax 请求前从 localstorage 中获取 Token 并把 Token 放到 Authorization Header 中,或者其他 header 也可,前后端约定好一个 header 即可。

  3. 服务器验证 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 内容的资源(例如图片)被解析为网页。
  • 对于用户上传的图片,进行转存或者校验。不要直接使用用户填写的图片链接。
  • 当前用户打开其他用户填写的链接时,需告知风险(这也是很多论坛不允许直接在内容中发布外域链接的原因之一,不仅仅是为了用户留存,也有安全考虑)。

参考:tech.meituan.com/2018/10/11/…