农历新年初一, 我在代码审计知识星球分享了一个红包, 但领取红包的条件是破解我出的一道代码审计相关题目, 题干如下:
2018.mhz.pw:62231
0x01 拉取源码
题干比较简单, 我们用浏览器打开, 发现提示
ERR_INVALID_HTTP_RESPONSE
, 说明这个端口并非 HTTP 服务用 nmap 进行端口指纹识别:
nmap -sV -p 62231 2018.mhz.pw
可见, 这是一个 rsync 服务, 我们使用 rsync 命令查看目录, 发现只有一个目录, 名为 pwnhub_6670.git, 将其拉取下来:
查看 pwnhub_6670.git 目录, 发现这是一个 git 裸仓库 (git bare repository)
Git 裸仓库怎么还原呢? 其实非常简单, 我们平时用的 GithubGitlab 上存的所有仓库其实都是裸仓库, 比如我们拉取 vulhub 的源码, 执行
git clone https://github.com/vulhub/vulhub.git
, 其中 vulhub.git 其实就是 Github 服务器上的一个裸仓库可见, 裸仓库一般以项目名称. git 为名
Git 支持通过本地文件 SSHHTTPS 或 GIT 协议拉取信息我们既然已经用 rsync 将裸仓库下载到本地了, 所以只需要
git clone pwnhub_6670.git
即可将裸仓库拉取下来, 成为一个标准的仓库:
仓库 pwnhub_6670 文件夹下的内容就是我们需要审计的源代码
0x02 SQL 注入漏洞挖掘
作为一个出题人, 我耍了点小阳谋我用了一个叫 speed 的小众 PHP 框架, 但改了核心文件名为 core.php 就是为了防止大家去找这个框架本身的漏洞导致走偏方向, 所有有漏洞的代码都出在我写的代码中
拿到源码仓库, 第一步先看看 git log 和 branchtags 等信息, 也许会暴漏一些目标的敏感信息这里没有, 于是我们就应该把目标放向代码本身
目标网站 http://2018.mhz.pw 只有简单的注册登录功能, 有关输入的代码如下:
- <?php
- escape($_REQUEST);
- escape($_POST);
- escape($_GET);
- function escape(&$arg) {
- if(is_array($arg)) {
- foreach ($arg as &$value) {
- escape($value);
- }
- } else {
- $arg = str_replace(["'",'\\','(',')'], ["", '\\\\', '(', ')'], $arg);
- }
- }
- function arg($name, $default = null, $trim = false) {
- if (isset($_REQUEST[$name])) {
- $arg = $_REQUEST[$name];
- } elseif (isset($_SERVER[$name])) {
- $arg = $_SERVER[$name];
- } else {
- $arg = $default;
- }
- if($trim) {
- $arg = trim($arg);
- }
- return $arg;
- }
escape 是将 GPR 中的单引号圆括号转换成中文符号, 反斜线进行转义; arg 是获取用户输入的 $_REQUEST 或 $_SERVER 显然, 这里 $_SERVER 变量没有经过转义, 先记下这个点
全局没其他值得注意的地方了, 所以开始看 controller 的代码
- <?php
- function actionRegister(){
- if ($_POST) {
- $username = arg('username');
- $password = arg('password');
- if (empty($username) || empty($password)) {
- $this->error('Username or password is empty.');
- }
- $email = arg('email');
- if (empty($email)) {
- $email = $username . '@' . arg('HTTP_HOST');
- }
- if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
- $this->error('Email error.');
- }
- $user = new User();
- $data = $user->query("SELECT * FROM `{$user->table_name}` WHERE `username` ='{$username}'");
- if ($data) {
- $this->error('This username is exists.');
- }
- $ret = $user->create([
- 'username' => $username,
- 'password' => md5($password),
- 'email' => $email
- ]);
- if ($ret) {
- $_SESSION['user_id'] = $user->lastInsertId();
- } else {
- $this->error('Unknown error.');
- }
- }
- }
以上是注册功能的代码, 比较简单用户名和密码是必填项, 邮箱如果没有填写, 则自动设置为用户名 @网站域名最后将三者传入 create 方法, create 方法其实就是拼接了一个 INSERT 语句
值得注意的是, 网站域名是从 arg('HTTP_HOST') 中获取, 也就是从 $_REQUEST 或 $_SERVER 中获取因为 $_SERVER 没有经过转义, 我们只需要在 HTTP 头 Host 值中引入单引号, 即可造成一个 SQL 注入漏洞
但 email 变量经过了
filter_var($email, FILTER_VALIDATE_EMAIL)
的检测, 我们首先要绕过之
- 0x03
- FILTER_VALIDATE_EMAIL
绕过
这就是今天第一个 trick 这个点早在当初 PHPMailer 的 CVE-2016-10033 就提到过
RFC 3696 规定, 邮箱地址分为 local part 和 domain part 两部分 local part 中包含特殊字符, 需要如下处理:
将特殊字符用 \ 转义, 如
Joe\'Blow@example.com
或将 local part 包裹在双引号中, 如
"Joe'Blow"@example.com
local part 长度不超过 64 个字符
虽然 PHP 没有完全按照 RFC 3696 进行检测, 但支持上述第 2 种写法所以, 我们可以利用之绕过
FILTER_VALIDATE_EMAIL
的检测
因为代码中邮箱是用户名 @Host 三者拼接而成, 但用户名是经过了转义的, 所以单引号只能放在 Host 中我们可以传入用户名为 ",Host 为 aaa'"@example.com, 最后拼接出来的邮箱为
"@aaa'"@example.com
这个邮箱是合法的:
这个邮箱包含单引号, 将闭合 SQL 语句中原本的单引号, 造成 SQL 注入漏洞
0x04 绕过 Nginx Host 限制
这是今天第二个 trick
我们尝试向目标注册页面发送刚才构造好的用户名和 Host:
直接显示 404, 似乎并没有进入 PHP 的处理过程
这就回到问题的本质了, Host 头究竟是做什么的?
众所周知, 如果我们在浏览器里输入 http://2018.mhz.pw, 浏览器将先请求 DNS 服务器, 获取到目标服务器的 IP 地址, 之后的 TCP 通信将和域名没有关系那么, 如果一个服务器上有多个网站, 那么 Nginx 在接收到 HTTP 包后, 将如何区分?
这就是 Host 的作用: 用来区分用户访问的究竟是哪个网站 (在 Nginx 中就是 Server 块)
如果 Nginx 发现我们传入的 Host 找不到对应的 Server 块, 将会发送给默认的 Server 块, 也就是我们通过 IP 地址直接访问的那个 Nginx 默认页面:
默认网站并没有 / main/register 这个请求的处理方法, 所以自然会返回 404
这里给出解决这个问题的两个方法, 也许还有更多新方法我没有想到, 欢迎补充
法 1
Nginx 在处理 Host 的时候, 会将 Host 用冒号分割成 hostname 和 port,port 部分被丢弃所以, 我们可以设置 Host 的值为
2018.mhz.pw:xxx'"@example.com
, 这样就能访问到目标 Server 块:
如上图, 成功触发 SQL 报错
法 2
当我们传入两个 Host 头的时候, Nginx 将以第一个为准, 而 PHP-FPM 将以第二个为准
也就是说, 如果我传入:
- Host: 2018.mhz.pw
- Host: xxx'"@example.com
Nginx 将认为 Host 为 2018.mhz.pw, 并交给目标 Server 块处理; 但 PHP 中使用
$_SERVER['HTTP_HOST']
取到的值却是 xxx'"@example.com 这样也可以绕过:
这个方法我以前在某群里提到过, 只有 Nginx+PHP 会出现这个问题, Apache 的情况下将会是另一个样子, 此处不展开讨论
0x05 Mysql 5.7 INSERT 注入方法
这是今天第三个 trick
既然已经触发了 SQL 报错, 说明 SQL 注入近在眼前通过阅读源码中包含的 SQL 结构, 我们知道 flag 在 flags 表中, 所以不废话, 直接注入读取该表
插入显示位
因为用户成功登录后, 将会显示出该用户的邮箱地址, 所以我们可以将数据插入到这个位置发送如下数据包:
- POST /main/register HTTP/1.1
- Host: 2018.mhz.pw
- Host: '),('t123',md5(12123),(select(flag)from(flags)))#"@a.com
- Accept-Encoding: gzip, deflate
- Accept: */*
- Accept-Language: en
- User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
- Connection: close
- Content-Type: multipart/form-data; boundary=--------356678531
- Content-Length: 176
- ----------356678531
- Content-Disposition: form-data; name="username"
- "a
- ----------356678531
- Content-Disposition: form-data; name="password"
- aaa
- ----------356678531--
可见, 我闭合了 INSERT 语句, 并插入了一个新用户 t123, 并将 flag 读取到 email 字段登录该用户, 获取 flag:
flag 是支付宝红包口令:)
报错注入
为了降低难度, 我特地给出了 Mysql 的报错信息, 没想到居然还增加了难度, 这一点我没考虑到, 还是 @burnegg 同学提出来的解决方法
很多同学上来就测试报错注入, 但这里有两个需要绕过的坑:
由于邮箱的限制, 注入语句长度需要小于 64 位
Mysql 5.7 默认开启严格模式, 部分字符串连接语法将导致错误:
ErrorInfo: Truncated incorrect INTEGER value
我们可以不使用字符串连接语法, 而使用 <>= 等比较符号来触发漏洞:
更多 INSERT 注入相关内容, 可以阅读 MySQL Injection in Update, Insert and Delete
0x06 一个总结
题目出出来以后, 有千余同学参加, 最快拿到支付宝红包的是 @超威蓝猫 , 大概在初二凌晨 1 点
除了安全研究者以外, 有一些程序员同学也参与了游戏, 但因为不熟悉 CTF 比赛和安全相关漏洞, 所以有的人跑偏了, 没有聚焦在漏洞和安全技术本身, 而去猜测红包口令是否藏在图片或者其他什么地方
希望这次游戏给你带来不仅是过年的欢乐, 而且有技术的提升~
来源: https://juejin.im/entry/5a8f81516fb9a0634b4d8b5f