diff --git a/docker-compose.yml b/docker-compose.yml index f7a3d55..3a1830c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,7 @@ services: MIN_PORT: ${MIN_PORT} MAX_PORT: ${MAX_PORT} BIN_UPLOADS_DIR: /app/ctf + JAIL_CONF_DIR: /app/nsjail_confs cap_add: - SYS_ADMIN security_opt: @@ -146,6 +147,8 @@ services: UPLOADS_DIR: /app/uploads BIN_UPLOADS_DIR: /app/ctf + JAIL_CONF_DIR: /app/nsjail_confs + CHALLENGE_HOST: ${CHALLENGE_HOST:-ctf.hacksu.com} FLAG_ENCRYPTION_KEY: ${FLAG_ENCRYPTION_KEY} TESTING_READ: "C++ is the Best Language, the very best." diff --git a/handler/Dockerfile b/handler/Dockerfile index fb89c71..e7e9964 100644 --- a/handler/Dockerfile +++ b/handler/Dockerfile @@ -55,9 +55,10 @@ RUN echo "www-data:99999:1" >> /etc/subuid && \ RUN mkdir -p /jail RUN mkdir -p /app/ctf RUN mkdir -p /app/jobs +RUN mkdir -p /app/nsjail_confs # move node application code and install needed dependencies -COPY ./handler/server.js ./handler/handler.js ./handler/package.json ./handler/bun.lock /app +COPY ./handler/server.js ./handler/handler.js ./handler/utils_h.js ./handler/package.json ./handler/bun.lock /app RUN bun install --frozen-lockfile # allow www-data to rw /app and /jail diff --git a/handler/HANDLER.md b/handler/HANDLER.md index 226d929..cf270d9 100644 --- a/handler/HANDLER.md +++ b/handler/HANDLER.md @@ -6,7 +6,7 @@ of challenges to be hosted compared to being stuck running a single binary. These configurations are linked to challenge entries following the rule that one challenge gets at most one configuration reference. These configurations allow easy handling of controlling what files are read-only -mounted in the jail along with configuring the entrypoint executable that is initially propogated via socat. +mounted in the jail along with configuring the entrypoint executable that is initially propogated via nsjail. ## Documentation These files are written in JSON @@ -15,16 +15,20 @@ These files are written in JSON | `name` | Name of the configuration. | | `author` | Creator meta-data. | | `files` | List of files contained in the generated jail. | -| `entrypoint` | Executable initially executed by socat. | +| `entrypoint` | Executable initially executed by nsjail. | -The `files` attribute when parsed will automatically append `entrypoint` within the list if it is not present. +The `files` attribute when parsed will automatically append `entrypoint` within the list if it is not present. For challenge files (excludes natural system files) within the jail, they will always exist at the root of the jail. ### Example Configation ```json { "name":"n0Auth", "author":"g3ne1c", - "files":[], - "entrypoint":"vuln", + "files":[ + "/vuln_comp", + "/lib/x86_64-linux-gnu/libc.so.6" + ], + "entrypoint":"/vuln", } -``` \ No newline at end of file +``` +As you can see the files `vuln` and `vuln_comp` are ctf challenge files, thus exist within the root directory, while natural files like `libc.so.6` use the absolute system path because these files are aquired from the jail host file-system. \ No newline at end of file diff --git a/handler/handler.js b/handler/handler.js index 3964602..8f97c9c 100644 --- a/handler/handler.js +++ b/handler/handler.js @@ -1,76 +1,13 @@ -import net from "net"; import fs from 'fs/promises'; import path from 'path'; import { execSync, spawn } from 'child_process'; -function shuffle(array) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } -} - -export async function GetUnusedPort(MIN, MAX) { - // Generate the port list - const ports = []; - for (let port = MIN; port <= MAX; port++) { - ports.push(port); - } - - // Randomize the order - shuffle(ports); - - // Test each port - for (const port of ports) { - const available = await new Promise((resolve) => { - const server = net.createServer(); - - server.once("error", () => resolve(false)); - - server.once("listening", () => { - server.close(() => resolve(true)); - }); - - server.listen(port, "127.0.0.1"); - }); - - if (available) { - return port; - } - } - - return -1; -} - -/** - * Check if a given bin_dir+bin path is valid - * and exists and enables the executable-bit - * on valid existing paths - * - * @param {*} bin_dir - * @param {*} bin - * @returns - */ -export async function CheckFile(bin_dir, bin) { - try { - console.log(`[*] Testing ${bin_dir}/${bin}`); - // check if the dir is valid - const requestedPath = path.normalize( - path.join(bin_dir, bin) - ); - if (!requestedPath.startsWith(bin_dir)) { - console.log("[*] Potential Mal-Path..."); - return { success: false, rc: 403, message: 'Potentially Malicious' }; - } +import { GetUnusedPort, CheckFile, PathExists, CreateDir, CreateFile } from './utils_h.js'; - await fs.access(requestedPath); - await fs.chmod(requestedPath, 0o755); // set executable-bit - return { success: true, rc: 200, message: '' }; - } catch (e) { - console.error("[-] Error:", e); - return { success: false, rc: 500, message: 'Server Error' }; - } -} +export const MIN_PORT = process.env.MIN_PORT ?? 0; +export const MAX_PORT = process.env.MAX_PORT ?? 0; +export const BINS_FOLDER = process.env.BIN_UPLOADS_DIR ?? path.join(process.cwd(), "ctf"); +export const NSJAIL_CONFS_FOLDER = process.env.JAIL_CONF_DIR ?? path.join(process.cwd(), "nsjail_confs"); const SESSION_TIMEOUT = 15 * 60; // 15 minutes const SESSION_TIMEOUT_MS = SESSION_TIMEOUT * 1000; @@ -82,21 +19,63 @@ const SESSION_TIMEOUT_MS = SESSION_TIMEOUT * 1000; * @param {*} bin_path * @returns */ -function buildNsjailCmd(jailDir, bin_path) { - const bin_name = path.basename(bin_path); +function buildNsjailCmd(jailDir, jail_conf) { + if (typeof jail_conf.entrypoint !== 'string') { + console.error('[-] Invalid jail entrypoint string!'); + return []; + } + if (!Array.isArray(jail_conf.files)) { + console.error('[-] Invalid jail file array!'); + return []; + } + const src_flag_path = path.join(jailDir, "flag.txt"); - return [ + const entrypoint_bin = path.normalize( + path.join(BINS_FOLDER, jail_conf.entrypoint) + ); + const nsjail_main = path.normalize( + path.join("/", jail_conf.entrypoint) + ); + + let nsjail_cmd = [ "/usr/bin/nsjail", "-Mo", // standalone-once: exit after one connection, no session reuse - "--chroot", jailDir, // jail root IS the challenge dir — nothing outside is reachable + "--chroot", jailDir // jail root IS the challenge dir — nothing outside is reachable + ]; - "-R", `${bin_path}:/${bin_name}`, // move binary - "-R", `${src_flag_path}:/flag.txt`, // move flag.txt + let nsjail_rbinds = [ + "-R", `${entrypoint_bin}:${nsjail_main}`, + "-R", `${src_flag_path}:/flag.txt`, "-R", "/lib64", - "-R", "/lib/x86_64-linux-gnu", + "-R", "/lib/x86_64-linux-gnu" + ]; + + for (const f of jail_conf.files) { + const requestedPath = path.normalize(f); + if (requestedPath.startsWith("/lib")) { + // @todo - This might need expanded to support other additions + nsjail_rbinds.push("-R", requestedPath); + } else { + // we know at this point this is a challenge file + const bin_path = path.normalize( + path.join(BINS_FOLDER, f) + ); + const jail_bin = path.normalize( + path.join("/", f) + ); + nsjail_rbinds.push("-R", `${bin_path}:${jail_bin}`); + } + } + + // append args into main cmd list + nsjail_cmd = [ + ...nsjail_cmd, + ...nsjail_rbinds + ]; - "-R", "/usr/bin/sh:/bin/sh", // pwntools looks for /bin/sh - "-R", "/usr/bin/bash:/bin/bash", // some people pref. bash + const nsjail_general = [ + "-R", "/usr/bin/sh:/bin/sh", // pwntools looks for /bin/sh + "-R", "/usr/bin/bash:/bin/bash", // some people pref. bash "-R", "/usr/bin/sh", "-R", "/usr/bin/bash", @@ -104,7 +83,6 @@ function buildNsjailCmd(jailDir, bin_path) { "-R", "/usr/bin/whoami", "-R", "/usr/bin/ls", "-R", "/usr/bin/cat", - "-l", "/tmp/nsjail.log", // general logging for debugging "--cwd", "/", // jail-root == chal_dir, so this is correct post-chroot @@ -129,51 +107,98 @@ function buildNsjailCmd(jailDir, bin_path) { // (nsjail's default), giving the jailed process no network at all. "--", - `/${bin_name}`, + `${nsjail_main}` + ] + nsjail_cmd = [ + ...nsjail_cmd, + ...nsjail_general ]; -} -async function PathExists(path) { - try { - await fs.access(path); - return true; - } catch { - return false; - } + return nsjail_cmd; } -async function CreateDir(path) { - try { - await fs.mkdir(path, { recursive: true }); - return true; - } catch { - return false; - } -} -async function CreateFile(path, content = "") { +async function ParseJailConfig(nsjail_conf) { try { - await fs.writeFile(path, content); - return true; - } catch { - return false; - } -} + // verify path + console.log("[*] Checking najail config path..."); + const { success, rc, message } = await CheckFile(NSJAIL_CONFS_FOLDER, nsjail_conf, false); + if (!success) return { success, rc, error: message }; -export async function CreateInstance(name, bin_dir, bin, flag_value) { - console.log("[*] Checking bin path..."); + const nsjail_conf_path = path.normalize( + path.join(NSJAIL_CONFS_FOLDER, nsjail_conf) + ); + const data = JSON.parse(await fs.readFile(nsjail_conf_path, 'utf-8')); - { - // executables exists within bin_dir - const { success, rc, message } = await CheckFile(bin_dir, bin); - if (!success) { - console.error(`[-] '${BINS_FOLDER}/${bin}' might not exist...`); - return { success: false, rc, error: message }; + // check for required attributes + if (!data.files || !data.entrypoint) { + console.error(`[-] nsjail config '${nsjail_conf_path}' is missing required attributes!`); + return { success: false, rc: 500, error: 'Server Error' }; } + + // type verify + if (typeof data.entrypoint !== 'string') { + console.error(`[-] nsjail config '${nsjail_conf_path}' entrypoint is invalid!`); + return { success: false, rc: 500, error: 'Server Error' }; + } + if (!Array.isArray(data.files)) { + console.error(`[-] nsjail config '${nsjail_conf_path}' files is invalid!`); + return { success: false, rc: 500, error: 'Server Error' }; + } + + // verify all requested jail files + const entry_included = data.files.find(f => f === data.entrypoint); + const jail_files = entry_included ? data.files : [data.entrypoint, ...data.files]; + for (const f of jail_files) { + let requestedPath = path.normalize(f); + if (requestedPath.startsWith("/lib")) { + // @todo - This might need expanded to support other additions + + // check abs non-exec files (i.e., libc.so.6) + if (await PathExists(f)) continue; + } else { + const jail_bin = path.normalize( + path.join("/", f) + ); + + // check executable challenge files + requestedPath = path.normalize( + path.join(BINS_FOLDER, jail_bin) + ); + + if (requestedPath.startsWith(BINS_FOLDER)) { + // check executable binary files + if (await CheckFile(BINS_FOLDER, jail_bin)) continue; + } else { + // files we include within nsjail come strictly from BINS_FOLDER or + // are to a system resource using absolute path like libc.so.6 + console.error(`[-] Requested jail file '${f}' is within an invalid location!`); + return { success: false, rc: 500, error: 'Server Error' }; + } + } + + console.error(`[-] Requested jail file '${f}' does not exist!`); + return { success: false, rc: 500, error: 'Server Error' }; + } + + console.log(`[+] nsjail config '${nsjail_conf_path}' parsed successfully!`); + return { success: true, rc: 200, message: '' }; + } catch (e) { + console.error("[ParseJailConfig] Error Occurred:", e); + return { success: false, rc: 500, error: 'Server Error' }; } +} - // create jail-cell based on challenge name provided - const jailDir = path.join("/jail", name); +/** + * Generates the initial file-system directory nsjail will use as the root + * for a newly created jail + * + * @param {*} jailDir + * @param {*} flag_value + * @returns + */ +async function CreateJail(jailDir, flag_value) { console.log(`[*] Checking '${jailDir}'...`); + if (!await PathExists(jailDir)) { console.log("[*] Creating directory", jailDir); if (!await CreateDir(jailDir)) { @@ -181,6 +206,7 @@ export async function CreateInstance(name, bin_dir, bin, flag_value) { return { success: false, rc: 500, error: 'Server Error' } } } + // write flag inside jail const flagFile = path.join(jailDir, "flag.txt"); @@ -190,6 +216,7 @@ export async function CreateInstance(name, bin_dir, bin, flag_value) { return { success: false, rc: 500, error: 'Server Error' } } + // write jail passwd file const jailPasswd = path.join(jailDir, "/etc/passwd"); @@ -208,11 +235,46 @@ export async function CreateInstance(name, bin_dir, bin, flag_value) { return { success: false, rc: 500, error: 'Server Error' } } - // write nsjail job file - regenerated every call so changes to - // buildNsjailCmd() take effect immediately, not just for new names + return { success: true, rc: 200, message: '' }; +} + +export async function CreateInstance(name, nsjail_conf, flag_value) { + + { + console.log("[*] Parsing nsjail config..."); + const { success, rc, error } = await ParseJailConfig(nsjail_conf); + if (!success) { + console.error(`[-] Error Parsing '${NSJAIL_CONFS_FOLDER}/${nsjail_conf}'`); + console.error(` |___ ${error}`); + return { success: false, rc, error }; + } + } + + const nsjail_conf_path = path.normalize( + path.join(NSJAIL_CONFS_FOLDER, nsjail_conf) + ); + const jail_conf = JSON.parse(await fs.readFile(nsjail_conf_path, 'utf-8')); + + // create jail-cell based on challenge name provided + const jailDir = path.join("/jail", name); + + { + console.log(`[*] Generating jail for '${name}'...`); + const { success, rc, error } = await CreateJail(jailDir, flag_value); + if (!success) { + console.error(`[-] Error making jail for '${name}'...`); + return { success: false, rc, error }; + } + } + + // in order to properly execute nsjail through socat we are + // providing socat a bash file because the nsjail command + // will be large const jobFile = path.join("/app/jobs", name + ".sh"); - const bin_path = path.join(bin_dir, bin); - const jailcmd = buildNsjailCmd(jailDir, bin_path); + const jailcmd = buildNsjailCmd(jailDir, jail_conf); + if (jailcmd.length === 0) { + return { success: false, rc: 500, error: `Invalid nsjail configuration provided! --> ${nsjail_conf_path}` }; + } console.log("[*] Writing Job File", jobFile); diff --git a/handler/server.js b/handler/server.js index ae811af..1fb6026 100644 --- a/handler/server.js +++ b/handler/server.js @@ -2,18 +2,19 @@ import express from 'express' import { exit } from 'process'; import { access, chmod } from "fs/promises"; import { join, basename, normalize } from "path"; -import { CheckFile, CreateInstance } from './handler.js'; + +import { CheckFile } from './utils_h.js'; +import { + CreateInstance, + MIN_PORT, MAX_PORT, +} from './handler.js'; const app = express() app.use(express.json()); const port = 3000 -const MIN_PORT = process.env.MIN_PORT; -const MAX_PORT = process.env.MAX_PORT; -const BINS_FOLDER = process.env.BIN_UPLOADS_DIR; - -if (!MIN_PORT || !MAX_PORT || !BINS_FOLDER) { - console.error("[-] Missing values for MIN_PORT and MAX_PORT!") +if (MIN_PORT === MAX_PORT) { + console.error("[-] Possibly missing env values for MIN_PORT and MAX_PORT!") exit(1); } @@ -23,19 +24,12 @@ app.get('/', (req, res) => { app.post('/create_instance', async (req, res) => { try { - const { name, bin, flag_value } = req.body; - - // executables exists within BINS_FOLDER - const { success, rc, message } = await CheckFile(BINS_FOLDER, bin); - if (!success) { - console.error(`[-] '${BINS_FOLDER}/${bin}' might not exist...`); - return res.status(rc).send(message); - } - + const { name, nsjail_conf, flag_value } = req.body; console.log("[*] Attempting to Create Instance..."); - const sess = await CreateInstance(name, BINS_FOLDER, bin, flag_value); + const sess = await CreateInstance(name, nsjail_conf, flag_value); return res.status(sess.rc).json(sess); } catch (err) { + console.error("[-] Error Creating Instance:", err); return res.status(500).json({ success: false, error: 'Failed to create Instance' }); } }); diff --git a/handler/utils_h.js b/handler/utils_h.js new file mode 100644 index 0000000..522b2d2 --- /dev/null +++ b/handler/utils_h.js @@ -0,0 +1,105 @@ +import net from "net"; +import path from 'path'; +import fs from 'fs/promises'; + +// @section - General File-System Operations + +export async function PathExists(path) { + try { + await fs.access(path); + return true; + } catch { + return false; + } +} + +export async function CreateDir(path) { + try { + await fs.mkdir(path, { recursive: true }); + return true; + } catch { + return false; + } +} + +export async function CreateFile(path, content = "") { + try { + await fs.writeFile(path, content); + return true; + } catch { + return false; + } +} + +/** + * Check if a given base_dir/file path is valid and exists + * + * @param {string} base_dir - The base directory + * @param {string} file - The file path relative to base_dir + * @param {boolean} [make_exec=true] - Whether to make the file executable + * @returns + */ +export async function CheckFile(base_dir, file, make_exec=true) { + try { + console.log(`[*] Testing ${base_dir}/${file}`); + // check if the dir is valid + const requestedPath = path.normalize( + path.join(base_dir, file) + ); + if (!requestedPath.startsWith(base_dir)) { + console.log("[*] Potential Mal-Path..."); + return { success: false, rc: 403, message: 'Potentially Malicious' }; + } + await fs.access(requestedPath); + + if (make_exec) { + await fs.chmod(requestedPath, 0o755); // set executable-bit + } + return { success: true, rc: 200, message: '' }; + } catch (e) { + console.error("[-] Error:", e); + return { success: false, rc: 500, message: 'Server Error' }; + } +} + + + +// @section - nsjail Utilities + +function shuffle(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } +} +export async function GetUnusedPort(MIN, MAX) { + // Generate the port list + const ports = []; + for (let port = MIN; port <= MAX; port++) { + ports.push(port); + } + + // Randomize the order + shuffle(ports); + + // Test each port + for (const port of ports) { + const available = await new Promise((resolve) => { + const server = net.createServer(); + + server.once("error", () => resolve(false)); + + server.once("listening", () => { + server.close(() => resolve(true)); + }); + + server.listen(port, "127.0.0.1"); + }); + + if (available) { + return port; + } + } + + return -1; +} \ No newline at end of file diff --git a/src/lib/database/db.ts b/src/lib/database/db.ts index b4ff962..72af844 100644 --- a/src/lib/database/db.ts +++ b/src/lib/database/db.ts @@ -642,6 +642,7 @@ async function addClaim(uid: any, cid: any): Promise { export async function CheckFlag(uid: any, cid: any, flag_value: any): Promise<{ success: boolean, message: string }> { try { // fetch users flag claims to determine if they already captured this flag + console.log("[*] Checking if already claimed"); const [data] = await db.select({ claims: schema.user.claims }) .from(schema.user) .where(eq(schema.user.id, uid)).limit(1); @@ -651,16 +652,26 @@ export async function CheckFlag(uid: any, cid: any, flag_value: any): Promise<{ } } - const [challenge] = await db.select({ + console.log("[*] Attempting to find challenge entry"); + const c_data = await db.select({ flag: schema.challenges.flag, - bin_file: schema.challenges.bin_file, + nsjail_conf: schema.challenges.nsjail_conf, }).from(schema.challenges).where(eq(schema.challenges.id, cid)).limit(1); + + if (c_data.length === 0) { + console.error("Error occurred challenge not found!"); + return { success: false, message: 'Error Occurred' }; + } - const claimed = challenge && (challenge.bin_file - ? await decryptFlag(challenge.flag) === flag_value - : challenge.flag === await SHA256(flag_value)); + console.log("[*] Verifying Flag"); + const challenge = c_data[0]; + const flag_claimed = challenge.nsjail_conf ? ( + await decryptFlag(challenge.flag) === flag_value + ) : ( + challenge.flag === await SHA256(flag_value) + ); - if (claimed) { + if (flag_claimed) { const has_appended = await addClaim(uid, cid); console.log(`[*] Appended Claim Status: ${has_appended}`); return { success: true, message: 'Correct Flag!' }; @@ -686,7 +697,7 @@ export async function GetFlagHash(cid: any) { .where(eq(cid, schema.challenges.id)).limit(1); return data.length > 0 ? data[0].flag : ""; } catch (error) { - console.error('Error occurred checking flag:', error); + console.error('Error occurred fetching flag:', error); return ""; } } diff --git a/src/lib/server/flag-crypto.ts b/src/lib/server/flag-crypto.ts index 51a2b5b..e3f0b53 100644 --- a/src/lib/server/flag-crypto.ts +++ b/src/lib/server/flag-crypto.ts @@ -6,6 +6,7 @@ async function importKey(): Promise { if (!KEY_HEX || KEY_HEX.length !== 64) { throw new Error("FLAG_ENCRYPTION_KEY must be a 64-char hex string (32 bytes)"); } + console.log("[KEY CHECK]", KEY_HEX?.slice(0, 8)); const keyBytes = Uint8Array.from(Buffer.from(KEY_HEX, "hex")); return crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]); } @@ -21,10 +22,15 @@ export async function encryptFlag(plaintext: string): Promise { } export async function decryptFlag(encoded: string): Promise { - const key = await importKey(); - const combined = Buffer.from(encoded, "base64"); - const iv = combined.subarray(0, 12); - const ciphertext = combined.subarray(12); - const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); - return new TextDecoder().decode(plaintext); + try { + const key = await importKey(); + const combined = Buffer.from(encoded, "base64"); + const iv = combined.subarray(0, 12); + const ciphertext = combined.subarray(12); + const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + return new TextDecoder().decode(plaintext); + } catch (e: any) { + console.error("[FLAG-DEC] Error Occurred:", e); + return ""; + } } diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts index ba8bcc0..558bb91 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/+page.server.ts @@ -108,7 +108,7 @@ export const actions = { written_by: formData.written_by, category: formData.category, difficulty: formData.difficulty, - flag: formData.bin_file ? await encryptFlag(formData.flag) : await SHA256(formData.flag), + flag: formData.nsjail_conf ? await encryptFlag(formData.flag) : await SHA256(formData.flag), points, hlinks: attached_files, hints, @@ -154,7 +154,7 @@ export const actions = { if (!flag_value || flag_value.length === 0) { flag_value = await GetFlagHash(formData.id); } else { - flag_value = formData.bin_file ? await encryptFlag(formData.flag) : await SHA256(formData.flag); + flag_value = formData.nsjail_conf ? await encryptFlag(formData.flag) : await SHA256(formData.flag); } try {