简单聊聊 Web 安全

Web 安全对于开发者来说是一个重要的课题,尤其是对于我们日常前端、服务端涉及到网络的工作。了解相关的知识点以及攻防手段,可以让我们在方案设计和代码实现过程中具备更好的安全防范意识。

常见的 Web 安全风险#

OWASP 是一个开源的、非盈利的全球性安全组织,致力于应用软件的安全研究。《OWASP Top 10》是面向开发人员和 Web 应用程序安全的标准文档,列举了 Top 10 的 Web 安全风险漏洞,用于指导大家编写更安全的代码。

以下是《OWASP Top 10》2021 版列举的 Top 10 安全风险类别:(中文版)

  1. 失效的访问控制;
  2. 加密机制失效 (以前叫敏感信息泄露);
  3. 注入式攻击 (包括 XSS);
  4. 不安全的设计;
  5. 安全配置错误 (包括 XXE);
  6. 使用含有已知漏洞的组件;
  7. 身份识别和身份验证错误;
  8. 软件和数据完整性故障;
  9. 安全日志记录和监控故障;
  10. 服务端请求伪造 (SSRF)。

这里有一些安全风险类型可能概况的比较粗粒度,也有一些安全风险类别可能没有被囊括进来,如果想详细了解可以访问 OWASP 官方查看。

题外话:除了 Web 安全外,OWASP 还有有关于机器学习安全风险、CICD、API 等等 Top 10,感兴趣可以去看看。(本人没看过)

接下来我们将讲一些比较有代表性的安全风险。

SQL注入#

SQL注入式攻击原理#

SQL 注入是注入式攻击的一种,这可能是 Web 开发人员最了解的攻击方式之一。SQL 注入漏洞允许攻击者通过查询语句与数据库进行交互,这会导致攻击者可以查询到正常情况不应该能看到的数据,比如属于其他用户的数据,或者非公开的数据等,同时也导致攻击者可以修改或者删除这些数据,进而破坏 Web 应用的正常运作。

SQL 注入漏洞的本质,需要满足以下两个条件:

  1. Web 应用对用户输入数据的合理性并没有进行校验。
  2. Web 应用将用户数据直接拼接到需要执行的代码 (SQL) 中执行。

sql-inject

SQL注入的简单例子#

接下来举一个简单的 SQL 注入例子,比如我们有如下的代码来校验用户是否登录成功,并获取用户的信息,这段代码并没有对输入的 req 进行合理性校验,req 直接拼接到 SQL 中执行。

1
2
3
sql := fmt.Sprintf("SELECT * FROM users WHERE email = '%s' AND password = '%s'",
    req.Email, req.Password)
db.Exec(sql)

如果我希望绕过鉴权,我们可以传入一个特殊设计的邮箱,可以让原本的 SQL 跳过了对密码的校验。

1
2
-- 传入邮箱为 x@xxx.com' --
SELECT * FROM users WHERE email = 'x@xxx.com' -- ' AND password = 'xxxxx'

甚至,利用这个漏洞,我们可以利用用户登录的场景去执行 SQL 修改甚至删除 Web 应用的数据。

1
2
-- 传入邮箱为 x@xxx.com'; DROP TABLE users; --
SELECT * FROM users WHERE email = 'x@xxx.com'; DROP TABLE users; -- ' AND password = 'xxxxx'

SQL转义#

为了避免引入 SQL 注入漏洞,可以使用相关库的参数化查询方式访问数据库,即在 SQL 中给用户输入保留参数占位符,单独传入用户数据,这样就可以为用户数据进行转义处理,避免出现这种直接拼接导致引入攻击 SQL 的情况。比如 Golang 常用的 gorm,可以将上述用户登录的 SQL 查询改成这样。

1
db.Where("email = ? AND password = ?", req.Email, req.Password).Take(&user)

当然,用上 ORM 是否就意味着不会有 SQL 注入漏洞了呢?答案是否定的,你确定你使用 ORM 的方式就一定对吗?

还是以 gorm 为例子,gorm 也并不是完全安全的,还是要取决于实际的代码。比如虽然使用了 gorm 和参数化查询,但是在预处理的 SQL 中却疏忽了。

1
2
// 虽然 value 无法注入了, 但是 condition 还是可以注入的
db.Where(fmt.Sprinf("%s = ?", req.Condition), req.Value).Find(&items)

比如想根据主键获取一条记录,结果传入了错误类型的数据。

1
2
// 当 id 传入字符串 1 = 1; DROP TABLE users;
db.First(&user, id)

比如想要给数据做个用户自定义排序。

1
2
// order 也可以注入, 比如传入 updated_at; DROP TABLE users;
db.Order(req.OrderField).Find(&items)

当然除了 Order,还有 Group、Having、Select、Distinct 理论上都存在这种风险。因此,除了使用 ORM 外,也要了解 ORM 自身对安全的支持程度以及如何避免出现类似的漏洞。

攻击SQL构造和盲注#

前面我们举的攻击例子里面的攻击 SQL 都是 DROP TABLE users,同样也可通过其他 SQL 去获取数据或者修改数据,比如通过 UNION 组合其他表的数据,通过 UPDATE 更新数据。

1
2
3
-- 通过 union 拿到了文章的名字, 如果加上 where 条件可以拿到指定条件的数据
SELECT id, email, name FROM users WHERE email = 'x@xxx.com'
UNION SELECT 1 AS id, '' AS email, name FROM articles LIMIT 1

可以观察到,不管是 UNION 还是 DROP,攻击 SQL 的构造都需要我们对数据库和表结构有一定的了解,如果是外部的攻击者,哪怕找到存在漏洞的接口,也需要了解到库表细节之后才能开始攻击。

实际上,以 MySQL 为例,MySQL 存在一个元数据库 information_schema,这里面描述了 MySQL 上有哪些数据库、有哪些数据表、有哪些数据列。通过这个特殊的数据库,攻击者可以得到所有库表字段信息,此时他们也就知道可以查询、修改、删除哪些库、表、字段。

1
2
3
SELECT id, email, name FROM users WHERE email = 'x@xxx.com'
UNION SELECT 1 AS id, '' AS email, group_concat(table_name) AS name
FROM information_schema WHERE table_schema = database()

这样就可以通过用户信息获取接口拿到了数据库里面所有表名的信息。

那如果存在 SQL 注入漏洞的接口并没有返回数据,只知道执行成功与否,是否就不用担心数据库表信息泄露的问题了呢?答案也是否定的,执行成功与否也是一个关键信息,通过这个信息也可以破译出数据库表信息。

这一类注入攻击又叫盲注,即除了成功与否之外,没有额外的信息返回的注入式攻击。盲注实际上是一步步试探破译出最终我们需要的数据,比如我们需要拿到数据库的表,我们可以通过以下方式来获取。

  • 判断第一个表名的长度是不是1,是不是2,是不是3……直到拿到第一个表名的长度;
  • 判断第一个表名的第一个字符是不是a,是不是b,是不是c……直到拿到第一个表名;
  • 重复以上步骤,直到拿到所有表信息和字段信息。

除此之外,还有另一个盲注手段,那就是依赖时间信息,通过 MySQL 的 sleep 可以让语句延迟一段时间后返回,这样我们就可以通过判断执行 SQL 的时长来得到相应的数据。

  • 如果第一个表名的长度是1,则 sleep 1秒,如果是2,则 sleep 2秒;
  • 如果第一个表名的第一个字符是a,则 sleep 1秒,如果是b,则 sleep 2秒;
  • 重复以上步骤,直到拿到所有表信息和字段信息。

SQL注入防范#

避免 SQL 注入漏洞的关键在于将数据和命令语句、查询语句分割开,需要做到以下几点:

  • 使用安全的 API,比如 gorm 或者其他库提供的参数化 SQL 执行,避免直接拼接 SQL,但是也要正确认识使用库的安全局限性,比如以上提到的 gorm order、having 注入等;
  • 使用正确的或规范的输入验证,不能信任用户输入数据;
  • 对于剩余的动态查询,需要进行特定字符转义;
  • 在查询中合理使用 LIMIT 等,以防 SQL 注入泄露过多数据,避免返回详细的 SQL 错误日志到用户侧。

XSS注入#

XSS注入式攻击原理#

跨站脚本 (XSS) 攻击是注入式攻击的另一种手段,本质上是通过在网页上注入恶意脚本,达到破坏用户与 Web 应用之间正常交互的效果,甚至使得恶意脚本能绕过同源策略后执行,拿到用户的一些敏感信息。

XSS 攻击可以简单分为两大类:

  • 反射型 XSS:将恶意脚本拼接在URL中,诱导用户点击恶意链接,通过浏览器“反射”执行脚本达到攻击目的;
  • 存储型 XSS:将恶意脚本存储到数据库中,当用户访问到包含恶意脚本的页面时,将执行脚本达到攻击目的。

xss-inject

上图可以观察到,XSS 攻击需要满足以下两个条件:

  1. 攻击者可通过 URL 或服务端注入恶意脚本,并有办法诱导用户点击恶意链接或访问包含恶意脚本的页面;
  2. 网页前端存在漏洞导致恶意脚本可以被执行。

XSS注入的简单例子#

接下来列举一个 XSS 的简单例子,比如我们有一个博客系统,用户可以在上面发表评论,也可以看到其他用户发表的评论。如果我们有办法将恶意脚本注入到评论中,那么所有访问到这个评论的用户的浏览器都将会执行这段恶意脚本,这就是一个基础的存储型 XSS。

1
2
3
4
5
6
<!-- 假设评论是这样渲染出来的 -->
<div id="comment"></div>

<script>
  document.querySelector("#comment").innerHTML = comment;
</script>

上面的代码可以发现,评论的信息是直接通过 DOM 插入到 HTML 里面的,如果是一个正常的评论,那么不会有任何影响,如果是一个恶意的评论,比如携带上一些恶意脚本,那么在渲染评论的时候将会错误执行这些脚本。

1
2
3
这是一个很正常的<script>alert(1)</script>评论。
这是一个很正常的<a href="javascript:alert(1)">链接</a>。
这是一个很正常的图片。<img src="" onerror="alert(1)"></img>

反射型 XSS 的案例也很相似,只不过恶意脚本的来源由数据库变成了前端页面 URL。有一些业务场景需要从 URL 里面解析参数,然后将参数数据渲染到页面上,比如搜索场景,从 URL 中拿到搜索词,然后渲染到搜索输入框或者文本中。如果没有防范,那么我们就可以往参数里面注入恶意脚本,达到攻击目的。

1
2
3
4
5
6
7
<!-- 假设搜索页面是这样渲染出来的 -->
<div id="search"></div>

<script>
  const searchParams = new URLSearchParams(window.location.search);
  document.querySelector("#search").innerHTML = `以下是“${searchParams.get('q')}”的结果:`
</script>

我们可以通过 q 参数,构造出如下的恶意链接。

1
https://x.xxx/search?q=<img src="" onerror="alert(1)" />

由于反射型 XSS 并没有将恶意脚本持久化,想要让受害者的浏览器执行相关脚本,那么接下来的活,就是如何诱导受害者点击这个恶意链接了。

XSS注入防范#

通常,可以通过以下几种手段来避免出现 XSS 注入漏洞:

  • 使用正确的或规范的输入验证,不能信任用户输入数据;
  • 使用安全的 API 进行数据渲染,确保数据经过合理的编码和转义,比如使用 textContent 替代 innerHTML 等;
  • 配置合理的内容安全策略 (CSP),仅执行明确来源的脚本。

React注入#

现代 Web 应用大多不再是原生的写 HTML、JS、CSS,而是使用 React、Vue 等库或框架进行应用搭建。以 React 为例,React 从设计层面上就具备了一些防范 XSS 注入的能力。

React DOM 在渲染所有输入内容之前,默认都会进行转义,比如 < 转义成 &lt; 等,这样即使输入的内容是恶意代码,也只是以文本的形式展示,就能有效的防止 XSS 攻击。除此之外,React 还对函数参数进行了强类型校验,类似 onerror 之类的参数不再允许传递字符串数据。

1
2
3
<Card>
  <div>{userInput}</div> // 先转义再渲染
</Card>

与上面提到的 gorm 类似,React 也并不能完全避免 XSS。

  1. 如果在 React 中直接使用 HTML DOM,依然会有以上提到的注入风险;
  2. 如果存在 URL 注入漏洞,且存在传参给 href、onfocus、onerror 之类可以直接执行字符串形式代码的场景,依然会有注入风险。

比如以下代码,实现了从 URL querystring 中直接解析参数组成一个 props,然后不经过任何校验就传递给 div。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function App() {
  const searchParams = new URLSearchParams(window.location.search);
  const props = Object.entities(searchParams);

  return (
    <Card>
      <div {...props}>这是一个卡片</div>
    </Card>
  )
}

我们直接传递 onfocus 无法完成注入,由于 React 对函数类型的参数都做了校验,禁止传入字符串;但是如果我们指定了 is=1,那么 React 就会认为这个组件是自定义组件,不再限制 onfocus 之类的参数校验。

https://x.xxx/?is=1&autofocus&onfocus=alert(1)

因此,即使使用了 React、Vue 之类的库或者框架,也需要注意这些可能导致 XSS 注入的实现方案。

CSRF攻击#

CSRF攻击原理#

跨站请求伪造 (CSRF) 是一种允许攻击者伪装成受信任用户对目标网站发出无意识和未经授权的请求的攻击方式。与注入式攻击不同,注入式攻击需要找到注入代码或脚本的契机,而 CSRF 可以直接以用户的身份发起请求,无需得到用户的同意。

csrf-inject

CSRF 攻击需要满足以下三个条件:

  1. 受害者曾经正常访问过网站A,且目前依然处于登录状态,即浏览器相关 Cookie 未失效;
  2. 网站A不具备 CSRF 攻击的防御手段;
  3. 受害者被诱导访问网站B,且网站B浏览器后台主动发起对网站A的请求。

当普通用户访问网站B时,网站B可以通过 img、form 表单甚至直接通过 XMLHttpRequest 发起对网站A的请求,而这个请求可能是会导致用户数据泄露的用户数据、隐私数据获取请求,也可能是直接修改用户数据、删除用户数据的请求。

比如网站A是一个支付平台,网站B操作用户向攻击者转账1分钱。比如网站A是一个咨询平台,网站B操作用户给某篇文章点赞。

CSRF攻击防范#

CSRF 攻击通常是在第三方网站上发起的,且攻击方只是能使用 Cookie 而非直接获取 Cookie,因此我们可以针对这点实现一些 CSRF 防御手段。

  • 同源请求检查、同源 Cookie 策略配置;
  • 请求时候携带一些难以伪造的参数 (验证码 或者 CSRF Token)。

在 HTTP 协议中,存在 Origin 和 Referer 两个 Header 指定了发起网络请求的网页地址,网站A收到请求的时候,可以判断这两个 Header 的值是否在预期的域名/网页列表内,如果不在则可以认为是一个非法的请求。

Origin: https://x.xxx
Referer: https://x.xxx/login_page

为了从源头避免出现跨域请求伪造攻击,HTTP 协议上给 Set-Cookie 响应 Header 新增了 Same-Site 属性,用于表明这个 Cookie 是一个同源 Cookie,不能被其他站点使用或者只能有限制的使用。当我们指定 Same-Site=Strict 的时候,网站B发起网站A的请求时再也无法携带网站A的 Cookie,这样就没法伪装成用户执行恶意操作了。

但一些业务可能确实存在跨域的场景,无法直接将 Cookie 设置为同源。除了对 Origin 和 Referer 校验外,我们还能引入一些难以伪造的参数。可以对核心逻辑增加验证码校验,比如交易请求、修改密码请求等;也可以在网站 HTML 加载的时候下发一个 CSRF Token,后续所有请求都要携带这个 Token 并进行校验。

GET https://x.xxx
Cookie: csrftoken=xxxxx; xxxxxx

OAuth2.0与CSRF#

现在大部分网站除了自身的用户账号体系外,一般还提供第三方社交账号登录或绑定,比如 QQ、微信、抖音、飞书等,这里使用的技术就是 OAuth2.0 授权协议。虽然 OAuth2.0 本身是为了授权安全而制定的业界标准,但是如果不正确的使用 OAuth,依然会存在安全风险,比如 CSRF 攻击。

一个正常的 OAuth 登陆或绑定的流程如下:

oauth

但是在用户访问网站A的回调地址之前,网站A是不知道用户在 OAuth 平台的身份的,只知道用户在网站A的身份。当用户访问网站A的回调地址之后,网站A才能拿到用户在 OAuth 平台的身份凭证。这里有一个潜在的 CSRF 漏洞,设想下这是一个绑定第三方社交账号的场景,攻击者构造了一个有效的网站A回调地址,然后在构造一个恶意网站,使得用户访问了回调地址,那就可以将受害者的网站A账号跟任意的社交账号绑定起来。

如果网站A并没有具备有效的 CSRF 防范手段,那攻击者可以肆意通过 iframe、src、link 等方式进行 CSRF 攻击;如果网站A具备了 CSRF 防范手段,那攻击者也可以通过诱导用户访问指定回调地址的方式达到目的 (只不过用户可能已经意识到被攻击了)。

oauth-inject

为了最大限度防止出现这种 CSRF 攻击,OAuth 2.0 授权协议定义了一个 state 参数,用于保存用户完成授权之前的状态,并在 302 重定向回调地址中携带它。

网站A在跳转到 OAuth 登陆页面时,可以为该用户创建一个 CSRF Token 或者一个随机值,并通过 state 携带并透传到 OAuth 平台,这个 Token 或者随机值关联了用户。当用户在 OAuth 完成登陆后,通过回调地址透传这个 state 值,这样通过校验 state 跟用户是否有关联,就可以确保请求跟响应的用户是否一致,达到了防范 CSRF 攻击的目的。

不过据观察,包括飞书、抖音在内的 OAuth 文档对 state 的描述都没有提到这一点,且标明 state 是可选的。而 Google 的 OpenID 文档给 state 标明了 “可选但强烈推荐”,并且明确指出该字段有帮助于防范 CSRF 攻击。

CSRF与内网攻击#

既然 CSRF 攻击需要构造出对目标网站的实际请求,是不是内网应用就不用担心遇到 CSRF 攻击呢?理论上攻击者不可达内网地址,但是实际上受害者的网络环境可能是内网,通过受害者的浏览器就可以直接对内部 IP 或域名发起访问。理论上攻击者并不了解内部应用的请求构造方式,但是实际上一方面可以靠猜测+破译,另一方面可能存在内鬼或者代码等资料泄露。

如果攻击者掌握了内网应用的 CSRF 漏洞,那他也可以构造一个恶意网页,然后诱导内部人员在内网环境访问,然后就可以神不知鬼不觉地获取内网的一些权限,修改或删除一些数据。当然,如果内部人员并非在内网环境打开,那攻击手段将会失效。

SSRF攻击#

SSRF攻击原理#

服务端请求伪造 (SSRF) 是一种由攻击者构造的、由服务端发起请求的安全漏洞。与 CSRF 不同的是,CSRF 请求由浏览器发起,而 SSRF 请求由服务端发起,因此 SSRF 的攻击目标通常就是外网无法访问的内部系统。

ssrf-inject

一般来说,满足 SSRF 攻击的条件要满足以下两个:

  1. 存在对外的服务,允许用户执行数据获取、下载、请求等操作;
  2. 该服务并未对可能产生 SSRF 攻击的请求添加相关有效限制。

一旦对外服务存在这种在服务端发起请求的口子,且没有限制好请求,那很容易被攻击者利用来嗅探、访问甚至攻击内网平台。比如可以用来扫描内网 IP 端口是否可达,获取内网敏感数据信息,获取服务端 pod 本地的文件,甚至获取某些内网平台的权限。

能够对外发起网络请求的地方都容易遭受 SSRF 攻击,一些容易被利用的常见场景如下:

  • 从指定 URL 地址获取图片 (网盘/资源/用户头像);
  • 从指定 URL 地址下载文件 (网盘/资源);
  • 获取网站的标题 (网站预览);
  • 还有爬虫等等。

SSRF攻击防范#

能满足 SSRF 攻击的场景基本都是对外有请求需求,而对内无请求需求,想要防御 SSRF 的本质就是避免对内网发起网络请求。

  1. 可以制定一个白名单清单,比如只能请求白名单上的地址;
  2. 请求前进行校验,对于请求内网的流量进行拦截。

当然,并不能简单使用字符串匹配的方式判断是不是内网域名或者内网 IP。理想中攻击者可能直接对 127.0.0.1 发起请求,但是实际上攻击者的请求方式五花八门。比如:

  • 用户提交了对 localhost 的请求;
  • 用户提交了对 0177.0.0.01 的请求;
  • 用户提交了对 127.1 的请求;
  • 用户提交了对 my-host.xxx 的请求,实际 DNS 解析到 127.0.0.1;
  • 用户提交了对 icytown.com@127.0.0.1 的请求;
  • 用户提交了对 my-host.xxx 的请求,但是该 GET 请求返回了 302 重定向,redirect 到 127.0.0.1;
  • 用户提交了一个 file://xxxxx 请求。

为了防止 SSRF 漏洞,应该在发起网络请求之前解析访问链接得到最终 IP,然后过滤掉所有内网 IP 的请求。最终实际发起请求也应该是直接向该 IP 请求,并禁止掉 302 重定向。

结语#

Web 安全这个课题也比较大,本文也仅是简单介绍了 注入式攻击、CSRF 攻击、SSRF 攻击几种容易出现的 Web 漏洞和防范手段。其实其他的攻防相关的知识也比较有趣,比如 DDOS 攻击是如何里面用 Memcached 将流量放大5万倍等,感兴趣可以去了解一下,这里就不展开了。