Contents
  1. 1. 代码审计
  2. 2. 实际操作

这道题是安恒杯18年5月份月赛的一道原题,我在做“安恒杯”Web 安全测试大赛测试赛的时候遇到了它。

扫描目录发现git源码泄露,用githack把源码拖下来后,开始审计

代码审计

首先看下目录结构

phpmailer 用于发送电子邮件的PHP函数包
captcha.php 生成验证码
chgemail.php 修改邮箱
class.mail.php 发送邮箱方法
class.user.php 用户相关操作
class.zcaptcha.php 验证码相关
functions.php
index.php 获取flag
init.php 配置文件
login.php 登陆
register.php 注册用户
style.css
switch.php 切换用户
verify.php 验证邮箱

首先看index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
require_once('init.php');
header("Content-type: text/html; charset=utf-8");

if(!isset($_SESSION['username'])){
header('location: ./login.php');
exit;
}
$userObj = new zUser();//实例化ZUser类
$user = zUserFile::get_attrs($_SESSION['username']);//获取当前用户信息
$flag = "";
if($userObj->is_admin($_SESSION['username']) && file_exists(FLAGFILE)){//判断如果是admin用户时输出flag
$flag = "WELL DONE! ".file_get_contents(FLAGFILE);
}
?>

发现如果是admin用户,会直接输出flag,接着查看is_admin函数的实现

class.user.php is_admin

1
2
3
4
5
6
7
8
9
public function is_admin($username){//判断是否是管理员
if(!zUserFile::validate_username($username)){//验证用户名格式
return false;
}
$user = zUserFile::get_attrs($username);//获取user用户信息数组
if($user['is_admin'] === 1)
return true;
return false;
}

根据代码可以看出来,当用户信息的is_admin为1时,即判断为admin用户,也就是说我们要想办法登陆到管理员用户才可以获取flag,继续向下审计

switch.php 切换用户功能

1
2
3
4
5
6
7
8
9
10
$username = isset($_GET['username'])?trim($_GET['username']):'';

if($username != false && zUserFile::is_exists($username)){//判断用户名为真并且存在
$to_user = zUserFile::get_attrs($username);
if($user['email_verify'] === 1 && $to_user['email_verify'] === 1 && $user['email'] === $to_user['email']){//邮箱相等且验证通过
$userObj->login2($username);
header('Location: ./');
exit;
}
}

这里对进行了邮箱是否被验证,邮箱是否相等进行了判断

1
$user['email_verify'] === 1 && $to_user['email_verify'] === 1 && $user['email']

我们继续看下条件满足后的登陆部分

class.user.php login2

1
2
3
4
5
6
7
8
public function login2($username){//登陆2
$username = trim($username);
if(!zUserFile::validate_username($username)){
return false;
}
$_SESSION['username'] = $username;
return true;
}

login2这里只要是通过了判断用户名格式就可以切换用户,也就是说,要进行切换的两个用户,只要完成邮箱验证,并且邮箱相同就可以切换,显然这里存在逻辑漏洞,我们可以想办法去把邮箱换成管理员的邮箱,并且设法去完成验证就可以拿到flag了。

继续看修改绑定邮箱功能

chgemail.php 修改绑定邮箱

1
2
3
4
5
6
7
8
9
10
11
if(isset($_POST['submit'])){
if(!chktoken()){
die('INVALID REQUEST');
}
$email = isset($_POST['email'])?trim($_POST['email']):'';
if($userObj->chg_email($_SESSION['username'], $email))//修改绑定邮箱
die('SUCCESS');
else
die('FAILED');

}

这里可以随意切换绑定邮箱

class.user.php chg_email切换绑定邮箱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
	public function chg_email($username, $email){
if(!zUserFile::is_exists($username)){//判断用户名是否存在
return false;
}
if($email == false || !zUserFile::validate_email($email)){//当邮箱为空,或者检测邮箱不存在时
return false;
}
$user = zUserFile::get_attrs($username);//获得当前用户的所有信息
$old_email = $user['email'];//获取当前邮箱
$emails = zUserFile::get_emails();//获取邮箱数组
if(isset($emails[$old_email])){
$emails[$old_email] = array_diff($emails[$old_email], array($username));//返回所有在emails里面但是没有在username里面的差值
if($emails[$old_email] == false){
unset($emails[$old_email]);
}
}
zUserFile::update_attr($username, 'email_verify', 0);//设置验证参数为未验证
zUserFile::update_attr($username, 'email', $email);//设置邮箱
zUserFile::update_attr($username, 'token', '');//清空token
$us = @is_array($emails[$email])?$emails[$email]:array();
$emails[$email] = array_merge($us, array($username));//将邮箱和用户名数组合并
return zUserFile::update_emails($emails);
}

这里切换了邮箱之后,会将email_verify置0,也就是转为未验证的状态,需要完成验证,才可以进行切换,继续向下看验证邮箱功能。

verify.php 邮件验证

发送部分没有什么好看的,我们直接看验证部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if(isset($_GET['token']) && isset($_GET['username'])){
$token = isset($_GET['token'])?trim($_GET['token']):'';
$username = isset($_GET['username'])?trim($_GET['username']):'';
if($token == false || $username == false){
die('INVALID INPUT');
}
if($userObj->verify_email($username, $token)){
$userObj->login($username);
header('location: ./');
exit;
}

die('INVALID TOKEN OR USERNAME');
}

这里进行了简单的非空判断后,就直接调用verify_email进行了验证

class.user.php 邮件验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function verify_email($username, $token){//绑定邮箱
if(!zUserFile::is_exists($username)){//判断用户名是否存在
return false;
}
$token = trim($token);
if($token == false){
return false;
}
$user = zUserFile::get_attrs($username);//获得用户信息数组
$real_token = $user["token"];//获得token
if(md5($real_token) !== md5($token)){
return false;
}
sleep(3);//提高选手成功率
zUserFile::update_attr($username, 'token', '');//清空token
zUserFile::update_attr($username, 'email_verify', 1);//email检测成功
return true;
}

sleep(3)是出题人为了提高选手做题成功率,加的,在git下来的代码里面是没有的,这也是坑点之一,正是这个sleep导致了条件竞争的产生。

这里验证了邮箱,将email_verify置1,而正是此处是我们做题的关键。

攻击思路

  1. 注册用户绑定自己邮箱
  2. 获取校验链接
  3. 请求校验链接
  4. 在请求校验链接的同时,修改绑定邮箱为管理员邮箱如果能够在代码执行“校验成功”和“更新账户绑定邮箱状态”步骤之间成功修改邮箱,就能直接将邮箱状态设置为已校验(条件竞争/时间竞争)

实际操作

注册用户(注册时填写自己的邮箱)

1569426736718

验证邮箱

1569426787604

下面的页面会往邮箱发送一封含有token的邮件

1569426859249
发送成功
1569426873110

查看邮件,如果收不到邮件可以去看一下垃圾箱

1569426938482

这里的域名是没法访问的,需要手动替换成该题目的地址

1
http://114.55.36.69:8023/verify.php?token=wr9eF2779NGcAbOke7qqYjxFb8Hl59z9&username=GetFlag

换绑邮箱

1569427043221

进入换绑邮箱页面,我们通过这里将邮箱换为管理员邮箱`ambulong@vulnspy.com`(管理员邮箱在注册页面出现过)

条件竞争

需要的东西都准备好了,现在开始利用时间差,进行条件竞争攻击,以达到我们要将邮箱换绑为管理员邮箱并且让其成为已验证的状态。

1569427793881

这里一定要用两个不同的浏览器,否则在verify.php sleep的时候,chgemail.php也会sleep,导致攻击失败。

先访问验证邮箱的url,在其sleep过程中,迅速点击修改绑定邮箱页面的submit。

1569427928150

该状态为条件竞争成功的状态,已经验证邮箱成功不会在出现SEND_EMAIL...的链接

切换用户

此时进行切换用户操作,点击SWITCH ACCOUNTS

1569428020917

在打开的切换用户页面中点击admin

1569428067250

即可完成切换,跳转回主页,拿到flag

1569428117730

Contents
  1. 1. 代码审计
  2. 2. 实际操作