Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom scoring engine #3

Merged
merged 6 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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 @@

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

if (!challenge) {
Expand All @@ -74,21 +76,66 @@
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
@@ -1,7 +1,7 @@
import config from '../config/server'
import path from 'path'
import { Challenge, CleanedChallenge } from './types'

Check warning on line 3 in server/challenges/index.ts

View workflow job for this annotation

GitHub Actions / lint (12)

'Challenge' is defined but never used

Check warning on line 3 in server/challenges/index.ts

View workflow job for this annotation

GitHub Actions / lint (12)

'CleanedChallenge' is defined but never used
import { Provider, ProviderConstructor } from './Provider'

Check warning on line 4 in server/challenges/index.ts

View workflow job for this annotation

GitHub Actions / lint (12)

'Provider' is defined but never used

Check warning on line 4 in server/challenges/index.ts

View workflow job for this annotation

GitHub Actions / lint (12)

'ProviderConstructor' is defined but never used
import { challUpdateEmitter, publishChallUpdate } from '../cache/challs'
import { EventEmitter } from 'events'

Expand All @@ -14,8 +14,12 @@
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 @@
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
Loading