diff --git a/README.md b/README.md index e400ca0..a7d0f14 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,71 @@ rCTF is redpwnCTF's CTF platform. It is developed and (used to be) maintained by the [redpwn](https://redpwn.net) CTF team. +## Installation + +install. + +``` +curl https://get.rctf.redpwn.net > install.sh && chmod +x install.sh +./install.sh +``` + +build the image. + +``` +docker build -t us-central1-docker.pkg.dev/dotted-forest-314903/rctf/rctf . +``` + +update docker compose. + +``` +# docker-compose.yml +version: '2.2' +services: + rctf: + image: us-central1-docker.pkg.dev/dotted-forest-314903/rctf/rctf # redpwn/rctf:${RCTF_GIT_REF} + restart: always + ports: + - '127.0.0.1:8080:80' + networks: + - rctf + env_file: + - .env + environment: + - PORT=80 + volumes: + - ./conf.d:/app/conf.d + depends_on: + - redis + - postgres + redis: + image: redis:6.0.6 + restart: always + command: ["redis-server", "--requirepass", "${RCTF_REDIS_PASSWORD}"] + networks: + - rctf + volumes: + - ./data/rctf-redis:/data + postgres: + image: postgres:12.3 + restart: always + ports: + - '127.0.0.1:5432:5432' + environment: + - POSTGRES_PASSWORD=${RCTF_DATABASE_PASSWORD} + - POSTGRES_USER=rctf + - POSTGRES_DB=rctf + networks: + - rctf + volumes: + - ./data/rctf-postgres:/var/lib/postgresql/data + +networks: + rctf: {} +``` + + + ## Getting Started To get started with rCTF, visit the docs at [rctf.redpwn.net](https://rctf.redpwn.net/installation/) diff --git a/client/src/api/challenges.js b/client/src/api/challenges.js index f77c3c7..cda3f2e 100644 --- a/client/src/api/challenges.js +++ b/client/src/api/challenges.js @@ -42,5 +42,5 @@ export const submitFlag = async (id, flag) => { flag }) - return handleResponse({ resp, valid: ['goodFlag'] }) + return handleResponse({ resp, valid: ['goodFlag', 'goodFlagRanked'] }) } diff --git a/docker-compose.yml b/docker-compose.yml index 777003b..cfd92f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,9 @@ version: '2.2' services: rctf: - image: redpwn/rctf:${RCTF_GIT_REF} + # image: redpwn/rctf:${RCTF_GIT_REF} + build: + dockerfile: Dockerfile restart: always ports: - '127.0.0.1:8080:80' diff --git a/migrations/1718588122222_solve-metadata.js b/migrations/1718588122222_solve-metadata.js new file mode 100644 index 0000000..eb964a8 --- /dev/null +++ b/migrations/1718588122222_solve-metadata.js @@ -0,0 +1,14 @@ +/* eslint-disable camelcase */ + +exports.shorthands = undefined; + +exports.up = pgm => { + // expose json field for allowing additional metadata on a solve + pgm.addColumns('solves', { + metadata: { type: 'jsonb', notNull: true, default: '{}' } + }) +}; + +exports.down = pgm => { + pgm.dropColumns('solves', ['metadata']) +}; diff --git a/package.json b/package.json index 0511f23..9e1f77c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "lint": "sh -c \"tsc --noEmit ; eslint .\"", "lint:strict": "sh -c \"tsc --noEmit -p tsconfig.strict.json ; eslint .\"", "start": "node --enable-source-maps --unhandled-rejections=strict dist/server/index.js", + "create-migration": "node-pg-migrate create $1", "migrate": "yarn build:ts && cross-env RCTF_DATABASE_MIGRATE=only yarn start", "build:client": "preact build --src client/src --template client/index.html --dest dist/build --no-prerender --no-inline-css", "build:ts": "tsc && yarn copy-static", diff --git a/server/api/challs/submit.js b/server/api/challs/submit.js index d9537db..5548dc2 100644 --- a/server/api/challs/submit.js +++ b/server/api/challs/submit.js @@ -5,6 +5,7 @@ import { responses } from '../../responses' import config from '../../config/server' import * as timeouts from '../../cache/timeouts' import { v4 as uuidv4 } from 'uuid' +import { challengeToRow } from '../../challenges/util' export default { method: 'POST', @@ -48,7 +49,8 @@ export default { req.log.info({ chall: challengeid, - flag: submittedFlag + flag: submittedFlag, + type: challenge.type }, 'flag submission attempt') if (!challenge) { @@ -74,21 +76,66 @@ export default { const bufSubmittedFlag = Buffer.from(submittedFlag) const bufCorrectFlag = Buffer.from(challenge.flag) - if (bufSubmittedFlag.length !== bufCorrectFlag.length) { - return responses.badFlag - } + const challengeType = challenge.type + let submittedHash, submittedScore = null + + if (challengeType === 'ranked') { + const parts = submittedFlag.split('.') + if (parts.length !== 2) { + return responses.badFlagFormatRanked + } + [submittedHash, submittedScore] = parts + // The user will receive SHA256(FLAG || answerLength) || '.' || answerLength + + const correctHash = crypto.createHash('sha256').update(bufCorrectFlag).update(submittedScore).digest('hex') + if (submittedHash != correctHash) { + return responses.badFlagRanked + } - if (!crypto.timingSafeEqual(bufSubmittedFlag, bufCorrectFlag)) { - return responses.badFlag + } else { + + if (bufSubmittedFlag.length !== bufCorrectFlag.length) { + return responses.badFlag + } + + if (!crypto.timingSafeEqual(bufSubmittedFlag, bufCorrectFlag)) { + return responses.badFlag + } } try { - await db.solves.newSolve({ id: uuidv4(), challengeid: challengeid, userid: uuid, createdat: new Date() }) - return responses.goodFlag + const metadata = (challengeType === 'ranked') ? { score: +submittedScore } : {} + // If we are a ranked challenge and we have a better solve, we want to delete the old solve + if (challengeType === 'ranked') { + const oldSolve = await db.solves.getSolveByUserIdAndChallId({ userid: uuid, challengeid }) + // If the new score is higher, delete the old solve. + if (oldSolve && (+oldSolve.metadata.score) < +submittedScore) { + await db.solves.removeSolvesByUserIdAndChallId({ userid: uuid, challengeid }) + } + // If this is a new best performance, update the challenge + const maxScore = (challenge.rankedMetadata || {}).maxScore || -1 + if (maxScore === -1 || +submittedScore > +maxScore) { + challenge.rankedMetadata = { ...(challenge.rankedMetadata || {}), maxScore: +submittedScore } + await db.challenges.upsertChallenge(challengeToRow(challenge)) + } + + // If this is a new worst performance, update the challenge + const minScore = (challenge.rankedMetadata || {}).minScore || -1 + if (minScore === -1 || +submittedScore < +minScore) { + challenge.rankedMetadata = { ...(challenge.rankedMetadata || {}), minScore: +submittedScore } + await db.challenges.upsertChallenge(challengeToRow(challenge)) + } + } + + + await db.solves.newSolve({ id: uuidv4(), challengeid: challengeid, userid: uuid, createdat: new Date(), metadata }) + return (challengeType === 'ranked') ? responses.goodFlagRanked : responses.goodFlag + + } catch (e) { if (e.constraint === 'uq') { // not a unique submission, so the user already solved - return responses.badAlreadySolvedChallenge + return (challengeType === 'ranked') ? responses.badAlreadySolvedChallengeRanked : responses.badAlreadySolvedChallenge } if (e.constraint === 'uuid_fkey') { // the user referenced by the solve isnt in the users table diff --git a/server/challenges/index.ts b/server/challenges/index.ts index fa2901e..ab612db 100644 --- a/server/challenges/index.ts +++ b/server/challenges/index.ts @@ -14,8 +14,12 @@ let challengesMap = new Map() let cleanedChallengesMap = new Map() const cleanChallenge = (chall: Challenge): CleanedChallenge => { - const { files, description, author, points, id, name, category, sortWeight } = chall + const { files, description, author, points, id, name, category, sortWeight, type, rankedMetadata } = chall + if (rankedMetadata) { + if (rankedMetadata.maxScore !== undefined) rankedMetadata.maxScore = +rankedMetadata.maxScore + if (rankedMetadata.minScore !== undefined) rankedMetadata.minScore = +rankedMetadata.minScore + } return { files, description, @@ -24,7 +28,9 @@ const cleanChallenge = (chall: Challenge): CleanedChallenge => { id, name, category, - sortWeight + sortWeight, + type, + rankedMetadata } } diff --git a/server/challenges/types.ts b/server/challenges/types.ts index 0e52700..bbb686d 100644 --- a/server/challenges/types.ts +++ b/server/challenges/types.ts @@ -1,3 +1,5 @@ +export type ChallengeType = 'dynamic' | 'ranked' + export interface Points { min: number; max: number; @@ -17,6 +19,13 @@ export interface CleanedChallenge { files: File[]; points: Points; sortWeight?: number; + type: ChallengeType; + rankedMetadata?: RankedMetadata; +} + +export interface RankedMetadata { + maxScore: number; /* The best user score */ + minScore: number; /* The minimum user score */ } export interface Challenge { @@ -30,4 +39,6 @@ export interface Challenge { flag: string; tiebreakEligible: boolean; sortWeight?: number; -} + type: ChallengeType; + rankedMetadata?: RankedMetadata; +} \ No newline at end of file diff --git a/server/challenges/util.ts b/server/challenges/util.ts index 17c2e67..2040af1 100644 --- a/server/challenges/util.ts +++ b/server/challenges/util.ts @@ -1,5 +1,6 @@ import { Challenge } from './types' import { deepCopy } from '../util' +import { DatabaseChallenge } from '../database/challenges' const ChallengeDefaults: Challenge = { id: '', @@ -13,7 +14,8 @@ const ChallengeDefaults: Challenge = { min: 0, max: 0 }, - flag: '' + flag: '', + type: 'dynamic' } export const applyChallengeDefaults = (chall: Challenge): Challenge => { @@ -24,3 +26,12 @@ export const applyChallengeDefaults = (chall: Challenge): Challenge => { ...chall } } + +export const challengeToRow = (challIn: Challenge): DatabaseChallenge => { + const { id, ...chall } = deepCopy(challIn) + + return { + id, + data: chall + } +} \ No newline at end of file diff --git a/server/database/solves.ts b/server/database/solves.ts index c2b1c39..59c3a1c 100644 --- a/server/database/solves.ts +++ b/server/database/solves.ts @@ -1,15 +1,22 @@ import db from './db' -import { Challenge } from '../challenges/types' -import { User } from './users' -import { ExtractQueryType } from './util' +import type { Challenge } from '../challenges/types' +import type { User } from './users' +import type { ExtractQueryType } from './util' + +export interface SolveMetadata { + score?: number; +} export interface Solve { id: string; challengeid: Challenge['id']; userid: User['id']; createdat: Date; + metadata: SolveMetadata; } +// psql "$RCTF_DATABASE_URL" -c $'INSERT INTO challenges (id, data) VALUES (\'id\', \'{"flag": "flag{good_flag}", "name": "name", "files": [], "author": "author", "points": {"max": 500, "min": 100}, "category": "category", "description": "description", "tiebreakEligible": true}\')' + export const getAllSolves = (): Promise => { return db.query('SELECT * FROM solves ORDER BY createdat ASC') .then(res => res.rows) @@ -21,7 +28,7 @@ export const getSolvesByUserId = ({ userid }: Pick): Promise & { limit: number; offset: number; }): Promise<(Solve & Pick)[]> => { - return db.query>('SELECT solves.id, solves.userid, solves.createdat, users.name FROM solves INNER JOIN users ON solves.userid = users.id WHERE solves.challengeid=$1 ORDER BY solves.createdat ASC LIMIT $2 OFFSET $3', [challengeid, limit, offset]) + return db.query>('SELECT solves.id, solves.userid, solves.createdat, solves.metadata, users.name FROM solves INNER JOIN users ON solves.userid = users.id WHERE solves.challengeid=$1 ORDER BY solves.createdat ASC LIMIT $2 OFFSET $3', [challengeid, limit, offset]) .then(res => res.rows) } @@ -30,11 +37,15 @@ export const getSolveByUserIdAndChallId = ({ userid, challengeid }: Pick res.rows[0]) } -export const newSolve = ({ id, userid, challengeid, createdat }: Solve): Promise => { - return db.query('INSERT INTO solves (id, challengeid, userid, createdat) VALUES ($1, $2, $3, $4) RETURNING *', [id, challengeid, userid, createdat]) +export const newSolve = ({ id, userid, challengeid, createdat, metadata }: Solve): Promise => { + return db.query('INSERT INTO solves (id, challengeid, userid, createdat, metadata) VALUES ($1, $2, $3, $4, $5) RETURNING *', [id, challengeid, userid, createdat, metadata]) .then(res => res.rows[0]) } export const removeSolvesByUserId = async ({ userid }: Pick): Promise => { await db.query('DELETE FROM solves WHERE userid = $1', [userid]) } + +export const removeSolvesByUserIdAndChallId = async ({ userid, challengeid }: Pick): Promise => { + await db.query('DELETE FROM solves WHERE userid = $1 AND challengeid = $2', [userid, challengeid]) +} \ No newline at end of file diff --git a/server/leaderboard/calculate.js b/server/leaderboard/calculate.js index 2f8eaa7..840aaf5 100644 --- a/server/leaderboard/calculate.js +++ b/server/leaderboard/calculate.js @@ -1,5 +1,5 @@ import { workerData, parentPort } from 'worker_threads' -import { getScore } from '../util/scores' +import { getRankedScore, getScore } from '../util/scores' import { calcSamples } from './samples' import config from '../config/server' @@ -27,11 +27,13 @@ let lastIndex = 0 const calculateScores = (sample) => { const challengeValues = new Map() const userScores = [] + const challengeRankedMetadata = new Map() for (; lastIndex < solves.length; lastIndex++) { const challId = solves[lastIndex].challengeid const userId = solves[lastIndex].userid const createdAt = solves[lastIndex].createdat + const solveScore = solves[lastIndex].metadata.score || 0 if (createdAt > sample) { break @@ -49,9 +51,9 @@ const calculateScores = (sample) => { } // Store which challenges each user solved for later if (!userSolves.has(userId)) { - userSolves.set(userId, [challId]) + userSolves.set(userId, [{ challId, solveScore }]) } else { - userSolves.get(userId).push(challId) + userSolves.get(userId).push({ challId, solveScore }) } } @@ -63,6 +65,12 @@ const calculateScores = (sample) => { } } + for (let i = 0; i < allChallenges.length; i++) { + if (allChallenges[i].type === 'ranked') { + challengeRankedMetadata.set(allChallenges[i].id, { ... allChallenges[i].rankedMetadata, min: allChallenges[i].points.min, max: allChallenges[i].points.max }) + } + } + for (let i = 0; i < allChallenges.length; i++) { const challenge = allChallenges[i] challengeValues.set(challenge.id, getScore( @@ -81,8 +89,24 @@ const calculateScores = (sample) => { if (lastSolve === undefined) continue // If the user has not solved any challenges, do not add to leaderboard const solvedChalls = userSolves.get(user.id) for (let j = 0; j < solvedChalls.length; j++) { - // Add the score for the specific solve loaded from the challengeValues array using ids - const value = challengeValues.get(solvedChalls[j]) + const { challId: solvedChallId, solveScore } = solvedChalls[j] + const rankedMetadata = challengeRankedMetadata.get(solvedChallId) + let value = undefined + if (rankedMetadata !== undefined) { + // If the challenge is ranked, calculate this on a per-solve basis + value = getRankedScore( + rankedMetadata.min, + rankedMetadata.max, + rankedMetadata.minScore, + rankedMetadata.maxScore, + solveScore + ) + } else { + // Add the score for the specific solve loaded from the challengeValues array using ids + + value = challengeValues.get(solvedChallId) + } + if (value !== undefined) { currScore += value } diff --git a/server/responses.ts b/server/responses.ts index 9ea66a9..195d9b8 100644 --- a/server/responses.ts +++ b/server/responses.ts @@ -119,10 +119,22 @@ export const responseList = { status: 200, message: 'The flag is correct.' }, + goodFlagRanked: { + status: 200, + message: 'The flag is correct.' + }, badFlag: { status: 400, message: 'The flag was incorrect.' }, + badFlagFormatRanked: { + status: 400, + message: 'The flag format was incorrect - e.g. "blabla.100"' + }, + badFlagRanked: { + status: 400, + message: 'The flag was incorrect.' + }, badChallenge: { status: 404, message: 'The challenge could not be not found.' @@ -131,6 +143,10 @@ export const responseList = { status: 409, message: 'The flag was already submitted' }, + badAlreadySolvedChallengeRanked: { + status: 409, + message: 'A flag was already submitted with an equal or higher score' + }, goodToken: { status: 200, message: 'The authorization token is valid' diff --git a/server/util/scores.ts b/server/util/scores.ts index a1e3f06..acf7401 100644 --- a/server/util/scores.ts +++ b/server/util/scores.ts @@ -11,3 +11,14 @@ export const getScore = (rl: number, rh: number, maxSolves: number, solves: numb const f = (x: number): number => rl + (rh - rl) * b(x / s) return Math.round(Math.max(f(solves), f(s))) } + + +// Linear interpolation of score between [minScore and maxScore], where maxScore gets you more points. +export const getRankedScore = (rl: number, rh: number, minScore: number, maxScore: number, score: number) => { + // Thanks copilot + if (maxScore === minScore) { + return rh; + } + const f = (x: number): number => rl + (rh - rl) * (x - minScore) / (maxScore - minScore) + return Math.round(Math.max(f(score), f(minScore))) +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1ed9310..4863665 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,10 @@ "inlineSourceMap": true, "inlineSources": true, "outDir": "dist/server", - "baseUrl": "." + "baseUrl": ".", + }, + "rules": { + "no-unused-vars": "off" }, "include": [ "server/**/*"