Skip to content

Commit

Permalink
Custom scoring engine (#3)
Browse files Browse the repository at this point in the history
* add solve info

* add scaffolding

* wip

* fix bugs

* fix int bug

* finish
  • Loading branch information
reteps authored Jun 18, 2024
1 parent 709cd72 commit ab5b166
Show file tree
Hide file tree
Showing 14 changed files with 249 additions and 27 deletions.
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
2 changes: 1 addition & 1 deletion client/src/api/challenges.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,5 @@ export const submitFlag = async (id, flag) => {
flag
})

return handleResponse({ resp, valid: ['goodFlag'] })
return handleResponse({ resp, valid: ['goodFlag', 'goodFlagRanked'] })
}
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
14 changes: 14 additions & 0 deletions migrations/1718588122222_solve-metadata.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* eslint-disable camelcase */

exports.shorthands = undefined;

Check failure on line 3 in migrations/1718588122222_solve-metadata.js

View workflow job for this annotation

GitHub Actions / lint (12)

Extra semicolon

exports.up = pgm => {
// expose json field for allowing additional metadata on a solve

Check failure on line 6 in migrations/1718588122222_solve-metadata.js

View workflow job for this annotation

GitHub Actions / lint (12)

Expected indentation of 2 spaces but found 4
pgm.addColumns('solves', {

Check failure on line 7 in migrations/1718588122222_solve-metadata.js

View workflow job for this annotation

GitHub Actions / lint (12)

Expected indentation of 2 spaces but found 4
metadata: { type: 'jsonb', notNull: true, default: '{}' }

Check failure on line 8 in migrations/1718588122222_solve-metadata.js

View workflow job for this annotation

GitHub Actions / lint (12)

Expected indentation of 4 spaces but found 8
})

Check failure on line 9 in migrations/1718588122222_solve-metadata.js

View workflow job for this annotation

GitHub Actions / lint (12)

Expected indentation of 2 spaces but found 4
};

Check failure on line 10 in migrations/1718588122222_solve-metadata.js

View workflow job for this annotation

GitHub Actions / lint (12)

Extra semicolon

exports.down = pgm => {
pgm.dropColumns('solves', ['metadata'])

Check failure on line 13 in migrations/1718588122222_solve-metadata.js

View workflow job for this annotation

GitHub Actions / lint (12)

Expected indentation of 2 spaces but found 4
};

Check failure on line 14 in migrations/1718588122222_solve-metadata.js

View workflow job for this annotation

GitHub Actions / lint (12)

Extra semicolon
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 56 additions & 9 deletions server/api/challs/submit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -48,7 +49,8 @@ export default {

req.log.info({
chall: challengeid,
flag: submittedFlag
flag: submittedFlag,
type: challenge.type
}, 'flag submission attempt')

if (!challenge) {
Expand All @@ -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

Check failure on line 80 in server/api/challs/submit.js

View workflow job for this annotation

GitHub Actions / lint (12)

Split initialized 'let' declarations into multiple statements

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

Check failure on line 89 in server/api/challs/submit.js

View workflow job for this annotation

GitHub Actions / lint (12)

Trailing spaces not allowed
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
Expand Down
10 changes: 8 additions & 2 deletions server/challenges/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ let challengesMap = new Map<string, Challenge>()
let cleanedChallengesMap = new Map<string, CleanedChallenge>()

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,
Expand All @@ -24,7 +28,9 @@ const cleanChallenge = (chall: Challenge): CleanedChallenge => {
id,
name,
category,
sortWeight
sortWeight,
type,
rankedMetadata
}
}

Expand Down
13 changes: 12 additions & 1 deletion server/challenges/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type ChallengeType = 'dynamic' | 'ranked'

export interface Points {
min: number;
max: number;
Expand All @@ -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 {
Expand All @@ -30,4 +39,6 @@ export interface Challenge {
flag: string;
tiebreakEligible: boolean;
sortWeight?: number;
}
type: ChallengeType;
rankedMetadata?: RankedMetadata;
}
13 changes: 12 additions & 1 deletion server/challenges/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Challenge } from './types'
import { deepCopy } from '../util'
import { DatabaseChallenge } from '../database/challenges'

const ChallengeDefaults: Challenge = {
id: '',
Expand All @@ -13,7 +14,8 @@ const ChallengeDefaults: Challenge = {
min: 0,
max: 0
},
flag: ''
flag: '',
type: 'dynamic'
}

export const applyChallengeDefaults = (chall: Challenge): Challenge => {
Expand All @@ -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
}
}
23 changes: 17 additions & 6 deletions server/database/solves.ts
Original file line number Diff line number Diff line change
@@ -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<Solve[]> => {
return db.query<Solve>('SELECT * FROM solves ORDER BY createdat ASC')
.then(res => res.rows)
Expand All @@ -21,7 +28,7 @@ export const getSolvesByUserId = ({ userid }: Pick<Solve, 'userid'>): Promise<So
}

export const getSolvesByChallId = ({ challengeid, limit, offset }: Pick<Solve, 'challengeid'> & { limit: number; offset: number; }): Promise<(Solve & Pick<User, 'name'>)[]> => {
return db.query<ExtractQueryType<typeof getSolvesByChallId>>('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<ExtractQueryType<typeof getSolvesByChallId>>('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)
}

Expand All @@ -30,11 +37,15 @@ export const getSolveByUserIdAndChallId = ({ userid, challengeid }: Pick<Solve,
.then(res => res.rows[0])
}

export const newSolve = ({ id, userid, challengeid, createdat }: Solve): Promise<Solve> => {
return db.query<Solve>('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<Solve> => {
return db.query<Solve>('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<Solve, 'userid'>): Promise<void> => {
await db.query('DELETE FROM solves WHERE userid = $1', [userid])
}

export const removeSolvesByUserIdAndChallId = async ({ userid, challengeid }: Pick<Solve, 'userid' | 'challengeid'>): Promise<void> => {
await db.query('DELETE FROM solves WHERE userid = $1 AND challengeid = $2', [userid, challengeid])
}
Loading

0 comments on commit ab5b166

Please sign in to comment.