FRA-utmaningen 2025
Writeup för challenge.fra.se/75746d-2025
Prova gärna själv först :)
Utmaningen består av fem steg i en kedja. Varje sida har en flagga på formen flagga{...} och nästa steg ligger på challenge.fra.se/75746d-2025/<sha1>/ där sha1 är hashen av flaggan. Startsidan visar själv hur det funkar med en testflagga:
$ echo -n 'flagga{testflagga}' | sha1sum
6381c4c9cac7041c06dcfb7be864480f1f4ca009 -
Den flaggan skulle alltså leda till challenge.fra.se/75746d-2025/6381c4c9cac7041c06dcfb7be864480f1f4ca009/ som nästa sida. Har man rätt flagga finns sidan, annars svarar servern 404, så en gissning går att testa med ett curl-anrop.
Steg 1. Sidfoten
Första sidan har inte mycket på sig, typ instruktionerna, FRA-loggan och en sidfot som ser ut som en blå ljudvåg. Ctrl+U och kolla källkoden, då ser man att bilderna hämtas från olika ställen:
<img src="../fralogo.jpg" alt="" border="0" height="86" width="158">
<img src="./footer_graphic.png">
Loggan hämtas alltså från webbroten, challenge.fra.se/fralogo.jpg, men sidfoten challenge.fra.se/75746d-2025/footer_graphic.png från utmaningens egen katalog. En sidfot finns också i roten som challenge.fra.se/footer_graphic.png utan katalog. Ladda ner båda och kör exiftool på dem en och en:
$ wget -O steg1.png https://challenge.fra.se/75746d-2025/footer_graphic.png
$ wget -O rot.png https://challenge.fra.se/footer_graphic.png
$ exiftool steg1.png
...
File Size : 73 kB
...
Image Width : 1229
Image Height : 92
Bit Depth : 8
Color Type : RGB with Alpha
...
Background Color : 255 255 255
Modify Date : 2025:07:14 12:39:44
...
Datecreate : 2025-07-14T10:34:39+00:00
Datemodify : 2025-07-14T08:30:13+00:00
...
$ exiftool rot.png
...
File Size : 63 kB
...
Image Width : 1229
Image Height : 92
Bit Depth : 8
Color Type : RGB
...
Det skiljer alltså. exiftool har också en egen jämförelse som visar alla skillnader på en gång:
$ exiftool steg1.png -diff rot.png
======== diff < steg1.png > rot.png
< [ExifTool] Warning : [minor] Text/EXIF chunk(s) found after PNG IDAT (may be ignored by some readers) [x2]
< [System] File Name : steg1.png
> : rot.png
< [System] File Size : 73 kB
> : 63 kB
...
< [PNG] Color Type : RGB with Alpha
> : RGB
< [PNG] Gamma : 2.2
< [PNG] White Point X : 0.3127
< [PNG] White Point Y : 0.329
< [PNG] Red X : 0.64
< [PNG] Red Y : 0.33
< [PNG] Green X : 0.3
< [PNG] Green Y : 0.6
< [PNG] Blue X : 0.15
< [PNG] Blue Y : 0.06
< [PNG] Background Color : 255 255 255
< [PNG] Modify Date : 2025:07:14 12:39:44
< [PNG] Datecreate : 2025-07-14T10:34:39+00:00
< [PNG] Datemodify : 2025-07-14T08:30:13+00:00
Samma namn och samma storlek i pixlar, men inte samma fil. Bilden i utmaningens katalog har en alfakanal, är större och har redigerats i juli 2025. Den i roten har inget av det.
Alfakanalen lockar till att leta där, men den är vilseledande. Den är 255, alltså helt ogenomskinlig, i varje pixel.
Två nästan likadana bilder, då jämför man väl dem :) Kör compare från imagemagick:
$ compare -metric AE steg1.png rot.png diff.png
compare svarar 330, alltså skiljer sig 330 pixlar mellan bilderna. Men skillnaden är som mest 2 steg av 255 i rött, grönt eller blått, så det syns inte alls. I diff.png gör compare varje ändrad pixel röd och då ser man flaggan.
Flaggan: flagga{alpha_mail}
Steg 2. Spiralen
"Bra jobbat! Nu blir det lite svårare." Under det finns en bild och under bilden berättar sidan att FRA använder utmaningar för att hitta nya medarbetare. I källkoden (Ctrl+U) ser man att bilden är en SVG direkt i sidan, en spiral med 231 cirklar som är antingen svarta eller vita. Två färger skulle kunna vara ettor och nollor.
Frågan är hur de ska grupperas. 231 delat med 8 blir 28,875 och vi vill gärna ha heltal :) Med 7 går det jämnt ut, 231 = 33 x 7. Sju bitar räcker för ASCII, så läs cirklarna i den ordning de står i koden, inifrån och ut, med svart som 1.
Ladda ner sidan och plocka ut färgerna, b blir 1 och w blir 0:
$ wget -O steg2.html https://challenge.fra.se/75746d-2025/710e77d190bf3c45a7c966b214bdaf1686d84c35/
$ grep -o 'fill="[bw]' steg2.html | cut -c7 | tr bw 10 | tr -d '\n'
110011011011001100001110011111001111100001111101111011011101111111001011001011011111111010011011111100100110000111110011011111111010011010001100001110111010111111111001110010111100111110100110010111100101100100110000111110011111101
Klistra in utdatan i cyberchef och använd From Binary med Byte Length 7. Det luriga är just sjuan.
Flaggan: flagga{more_today_than_yesterday}
Steg 3. Labyrinten
"En analytiker har gått lite fel och hamnat i en lavalabyrint. Hjälp honom att hitta ut!" Sidan är ett spel där man styr analytikern med piltangenterna genom en labyrint på 1025 x 1025 rutor. Går han ut i lavan dör han och man får börja om.
Labyrinten är alldeles för stor att köra manuellt (och lite oförlåtande) så nästa steg är källkoden :) Flaggan finns inte i klartext. I settings.mjs ligger den krypterad i flagData, tillsammans med labyrintens storlek och seed:
export const settings = {
cols: 1025,
rows: 1025,
seed: 1337,
flagData: "/mBljuOijfGuVcO/ycJEkb7Ac1q6A8XViPs2y2eRgWoRhM1fbjROdoeYZCrgWZorlbnZ1O63DIDeNBvk3bTqNeNpSg=="
}
I utils.mjs syns hur den dekrypteras. Det är AES-GCM och nyckeln är en SHA-256 av lösenordet:
export async function aesGcmDecrypt(ciphertext, password) {
const pwUtf8 = new TextEncoder().encode(password);
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8);
const ivStr = atob(ciphertext).slice(0,12);
...
const alg = { name: 'AES-GCM', iv: iv };
...
Lösenordet kommer från game.js, där win() körs när man når målet:
async win(){
this.active = false
this.player = new Player(this);
const key = cyrb53(this.input.path.join(""), 1)
try{
const flag = await aesGcmDecrypt(settings.flagData, key)
document.getElementById('flag').innerText = flag
} catch (e) {
console.log(e)
}
...
Lösenordet är en hash av this.input.path med cyrb53, en enkel hashfunktion som utils.mjs också innehåller. I input.js ser man att listan fylls på med varje tryck på en piltangent:
window.addEventListener('keydown', e => {
if(( e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight')
&& this.keys.length < this.max_events) {
this.keys.push(e.key);
this.path.push(e.key);
}
Lösenordet byggs alltså av något i stil med ArrowRightArrowDownArrowDown... Att bara patcha och hoppa rakt till goodboy i win() funkar inte eftersom lösenordet då blir fel. Man måste gå exakt rätt väg utan en enda omväg. Sjukt störigt :)
Labyrinten blir likadan varje gång, så man kan ladda ner de tre moduler som behövs och köra dem i Node.js för att få exakt samma labyrint.
$ wget https://challenge.fra.se/75746d-2025/7653d14b014510aab7719a584027b205b52d1a7d/js/{maze,utils,settings}.mjs
Det finns bara en väg genom labyrinten. Skriptet nedan följer gångarna och backar när det tar stopp. När det backar stryks steget ur listan igen, så bara rätt väg blir kvar. player.js säger att start är ruta (0, 1) och mål ruta (n-1, n-2).
Spara skriptet som solve.mjs i samma katalog som modulerna:
import { backtrackingMaze } from './maze.mjs';
import { cyrb53, aesGcmDecrypt } from './utils.mjs';
import { settings } from './settings.mjs';
const map = backtrackingMaze(settings.cols, settings.rows, settings.seed), n = settings.cols;
const moves = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] };
const path = [];
let x = 0, y = 1;
while (x != n - 1 || y != n - 2) {
map[x][y] = 1;
const step = Object.keys(moves).find(k => map[x + moves[k][0]]?.[y + moves[k][1]] === 0);
if (step) {
path.push(step);
x += moves[step][0]; y += moves[step][1];
} else {
const back = path.pop();
x -= moves[back][0]; y -= moves[back][1];
}
}
console.log(await aesGcmDecrypt(settings.flagData, cyrb53(path.join(''), 1)));
Kör det:
$ node solve.mjs
flagga{man_kan_vara_lite_vilsen_ibland}
| Storlek | Drag | Lösenord |
|---|---|---|
| 1025 x 1025 | 129 330 | b7ce1998310cd802 |
Flaggan: flagga{man_kan_vara_lite_vilsen_ibland}
Steg 4. Ringarna
"Nu blir det snurrigt!" Sidan visar färgade ringar som snurrar och pulserar var femte sekund. Två ord på sidan har en egen, ljusblå färg:
Nu blir det snurrigt! Samarbeta gärna med någon om du inte lyckas på egen hand!
Ta en fikapaus om du inte lyckas komma vidare, det är du värd!
Egen ... värd ... egenvärde! Snyggt ordskämt :)
Ringarna ritas från en matris d med 25 x 25 stora heltal, en rad per ring och en båge per tal.
const d = [[38734037503497961n, 21035784905420008n, 71668129214377877n, ...], ...]
numpy klarar inte det här. Talen är för stora för att få plats exakt i ett flyttal och redan de små avrundningsfelen gör svaret helt fel. Egenvärdena måste räknas exakt. Ladda ner sidan och plocka ut matrisen, utan suffixet n:
$ wget -O steg4.html https://challenge.fra.se/75746d-2025/b29dafb3a3b038c7012c887ea9e0e0ac6a921802/
$ grep 'const d = ' steg4.html | cut -d= -f2 | tr -d 'n; ' > matrix.txt
Sympy är kungen här och bryr sig inte ett dugg om hur stora talen är :)
import json
from sympy import Matrix
ev = sorted(Matrix(json.load(open('matrix.txt'))).eigenvals())
print(ev)
[102, 140, 161, 199, 231, 257, 315, 345, 367, 405, 415, 467, 496, 521, 558, 575, 621, 645, 671, 722, 751, 789, 814, 836, 893]
Alla 25 egenvärden är heltal. Flaggan börjar ju med flagga{, så jämför de första egenvärdena med de tecknen. Första egenvärdet är 102, alltså ett f i ASCII. Nästa är 140, men l är 108, skillnad 32. Sen 161, men a är 97, skillnad 64:
| Position i | Egenvärde | Tecken | ASCII | Skillnad |
|---|---|---|---|---|
| 0 | 102 | f | 102 | 0 |
| 1 | 140 | l | 108 | 32 |
| 2 | 161 | a | 97 | 64 |
| 3 | 199 | g | 103 | 96 |
| 4 | 231 | g | 103 | 128 |
| 5 | 257 | a | 97 | 160 |
| 6 | 315 | { | 123 | 192 |
Skillnaden är 32 gånger positionen. Dra av den så kommer flaggan fram, lägg till den här raden sist i skriptet:
print(''.join(chr(v - 32*i) for i, v in enumerate(ev)))
Flaggan: flagga{you_spin_me_round}
Steg 5. Musiken
"Dags för femte och sista flaggan!" Sidan har bara ett klot av linjer som rör sig till musik när man klickar. Ctrl+U visar att sidan laddar js/scripts.js och där hämtas musiken:
audioLoader.load('./pcap.mp3', function(buffer) {
sound.setBuffer(buffer);
window.addEventListener('click', function() {
if(!sound.isPlaying)
sound.play();
});
});
Musiken är alltså pcap.mp3 i samma katalog som sidan. Ladda ner filen och kör file först:
$ wget https://challenge.fra.se/75746d-2025/6836c802f702bcfdfa2158f140a4a5b945f00fdf/pcap.mp3
$ file pcap.mp3
pcap.mp3: MPEG ADTS, layer III, v1, 256 kbps, 44.1 kHz, Stereo
Sen metadatan. exiftool hittar en ID3-tagg där titel, artist, album och år är tomma, men kommentaren är ifylld:
$ exiftool pcap.mp3 | tail -8
ID3 Size : 128
Title :
Artist :
Album :
Year :
Comment : Created with LMMS
Genre : None
Duration : 0:02:50 (approx)
Och ffprobe, samma kommentar och längden 2:49,90:
$ ffprobe pcap.mp3
ffprobe version 7.1.5-0+deb13u1 Copyright (c) 2007-2026 the FFmpeg developers
...
Input #0, mp3, from 'pcap.mp3':
Metadata:
comment : Created with LMMS
Duration: 00:02:49.90, start: 0.000000, bitrate: 256 kb/s
Stream #0:0: Audio: mp3 (mp3float), 44100 Hz, stereo, fltp, 256 kb/s
LMMS är ett musikprogram, ungefär som FL Studio.
Det är alltså en vanlig ljudfil, så nästa steg är att öppna den i Sonic Visualiser och lyssna. Två ställen sticker ut direkt, mellan 48 och 63 sekunder och mellan 138 och 153 sekunder, med snabba pip. Kalla dem A och B. Lägg till ett spektrogram med Layer > Add Spectrogram så syns pipen tydligt.
Pipen låter helt slumpmässiga, men låten är gjord i LMMS och har ett fast tempo. Det är värt att ta reda på och det gör man med aubio:
$ aubio tempo pcap.mp3
131.13 bpm
Det är en uppskattning. Tar man ut varje taktslag med aubio beat och räknar snittet över hela låten blir det 130,8:
$ aubio beat pcap.mp3 | awk 'NR==1{f=$1} {l=$1; n++} END{print 60*(n-1)/(l-f)}'
130.821
Tempot torde vara 130 eller 131 BPM. Det avgör man med partierna. Båda börjar på ett trumslag, på 48,00 s i A och 138,46 s i B (zooma in i Sonic Visualiser), så avståndet 90,46 s bör vara ett helt antal slag. I 130 BPM är ett slag 60 / 130 = 0,46154 s och 90,46 / 0,46154 = 196,0. I 131 BPM blir det 197,5, alltså mitt emellan två slag. Tempot är 130 BPM.
Med det vet man var slagen ska ligga och kan räkna hur många det är. Från trumslaget före första pipet till trumslaget efter sista är varje parti 14,77 s och 14,77 / 0,46154 = 32 slag. I 131 BPM hade det blivit 32,25, så även det talar för 130. A går alltså från 48,00 till 62,77 s och B från 138,46 till 153,23 s. Markerar man dem i Sonic Visualiser ser man hela mönstret:
Slagen syns direkt i spektrogrammet. Trummorna slår på varje slag och blir höga ljusa lodräta streck med 0,46154 s mellanrum. De slår också mellan slagen, ibland lika hårt, men de strecken räknas inte. Pipen mellan två taktslag hör till samma slag och ett pip som ligger på ett taktslag är slagets första.
Lägg till ett lager med Layer > Add Melodic Range Spectrogram, då blir varje pip ett tunt streck på sin tonhöjd. Tonerna byter uppsättning var fjärde slag räknat från partiets början, så en takt är fyra slag.
Går man igenom takterna ser man att ingen har fler än åtta olika höjder. I första takten är det A, B, C# och E i två oktaver.
Båda partierna har samma följd av ackord: A, E, D, E, A, E, D och A en oktav ner. Varje ackord har sina egna åtta toner.
| Ackord | Takter | Toner, från lägsta till högsta | Hz |
|---|---|---|---|
| A | 1, 5 | A4 B4 C#5 E5 A5 B5 C#6 E6 | 440 494 554 659 880 988 1109 1319 |
| E | 2, 4, 6 | E4 F#4 G#4 B4 E5 F#5 G#5 B5 | 330 370 415 494 659 740 831 988 |
| D | 3, 7 | D4 E4 F#4 A4 D5 E5 F#5 A5 | 294 330 370 440 587 659 740 880 |
| A, en oktav ner | 8 | A3 B3 C#4 E4 A4 B4 C#5 E5 | 220 247 277 330 440 494 554 659 |
I några takter hörs bara sex eller sju av tonerna. Ackordet har ändå åtta toner och den saknade ser man i en annan takt med samma ackord, ibland i det andra partiet. Det är lätt att missa och då hamnar alla toner ovanför luckan på fel plats.
Takterna går att läsa av för hand i den här vyn. Man ser var slagen går och vet vilka åtta toner som kan finnas i takten. Då är det mest att titta och bocka av, det finns inget att gissa på. Det krävs dock en del kaffe för 64 slag :)
Inom ett slag kommer tonerna i blandad ordning och ingen ton spelas två gånger i samma slag. Ordningen verkar alltså inte betyda något, det som skiljer slagen åt är vilka toner som spelas. Åtta möjliga toner per slag, det är ju en byte! Skriv upp för varje slag vilka av ackordets åtta toner som hörs. Vilken ton som är vilken bit får man prova sig fram till. Lägsta tonen som bit 7 och högsta som bit 0 är den ordning som ger text på slutet, åt andra hållet blir det bara skräp.
I första slaget spelas A4 och B5, bit 7 och bit 2, alltså 10000100 = 0x84 i hex. Andra slaget har A4, A5, B5, C#6 och E6, det blir 0x8F på samma sätt.
32 byte per parti. I rutnäten nedan är varje kolumn ett slag och varje rad en ton, lägsta tonen längst ner.
Varken A eller B blir text för sig. Lägsta tonen spelas i 17 av 32 slag, så bit 7 är satt i över hälften av slagen och i ASCII är den ju alltid noll.
Men lägsta tonen spelas i exakt samma slag i båda partierna, det stämmer i alla 32! Lika långa partier, samma ackord, samma bit 7 överallt. XOR alltså, som nollar bit 7 i varje byte.
Det är också en bra kontroll när man skriver av för hand. Lägsta tonen måste stämma mellan A och B i varje slag, annars har man läst fel någonstans.
| Slag | A | B | A XOR B | Tecken |
|---|---|---|---|---|
| 1 | 84 | E2 | 66 | f |
| 2 | 8F | E3 | 6C | l |
| 3 | 1C | 7D | 61 | a |
| 4 | 70 | 17 | 67 | g |
| 5 | 9C | FB | 67 | g |
| 6 | E7 | 86 | 61 | a |
| 7 | FC | 87 | 7B | { |
Till sist i Python:
A = bytes.fromhex('848F1C709CE7FC482E279826F595ED3927AE1AABE157D2201C9A9B955E351DF9')
B = bytes.fromhex('E2E37D17FB86872B5745FD548FCADC4A78DC299F8D3BAB7F74A9E3E16D567584')
print(bytes(a ^ b for a, b in zip(A, B)).decode())
Flaggan: flagga{cyberz_1s_r34lly_h3xt3ch}
Lite indicier till sist: A börjar på sekund 48, alltså 4 slag och 8 bitar. Mp3:n kör 256 kbit/s och varje parti är 256 bitar. Slump? :)
Stort tack till FRA för en riktigt välgjord utmaning!
/Andreas
9E 21 30 14 A5 30 C7 64 85 C4 25 74 B6 25 7D 14 AF 7D B6 26 30 8C 2E