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.

  1. Steg 1. Sidfoten
  2. Steg 2. Spiralen
  3. Steg 3. Labyrinten
  4. Steg 4. Ringarna
  5. Steg 5. Musiken

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.

Sidfotsbilden, en blå ljudvåg på svart botten
Sidfoten. För ögat är de två versionerna exakt likadana.

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.

Utsnitt ur diff.png där flaggan kan läsas som text

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.

Spiralen från sidan, fast på vit bakgrund i stället för svart så att båda färgerna syns.

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}
StorlekDragLösenord
1025 x 1025129 330b7ce1998310cd802
Hela labyrinten med lösningsvägen i orange
Hela labyrinten. Ser hyfsat jobbig ut att köra manuellt ;)

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 :)

Bild ur animationen, 25 ringar av färgade bågar runt ett vitt kors
En bild ur animationen.

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 iEgenvärdeTeckenASCIISkillnad
0102f1020
1140l10832
2161a9764
3199g10396
4231g103128
5257a97160
6315{123192

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:

Parti A i Sonic Visualiser
Parti A, 48,00-62,77 s.
Parti B i Sonic Visualiser
Parti B, 138,46-153,23 s.

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.

Sonic Visualiser med de första slagen i parti A inramade i grönt
De första slagen i A, ett slag per grön ruta.
Början av parti A med ett lila lodrätt streck på varje taktslag
Samma slag med lila streck som ligger 0,46154 s isär. Strecken hamnar på trumslagen, nåja, lite före för att inte dölja den riktiga linjen :)

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.

Första takten i A som Melodic Range Spectrogram, med åtta tonnivåer
Första takten i A som Melodic Range Spectrogram. Pipen ligger på åtta olika höjder. De stora diagonala fläckarna längst ner är trummorna.

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.

AckordTakterToner, från lägsta till högstaHz
A1, 5A4 B4 C#5 E5 A5 B5 C#6 E6440 494 554 659 880 988 1109 1319
E2, 4, 6E4 F#4 G#4 B4 E5 F#5 G#5 B5330 370 415 494 659 740 831 988
D3, 7D4 E4 F#4 A4 D5 E5 F#5 A5294 330 370 440 587 659 740 880
A, en oktav ner8A3 B3 C#4 E4 A4 B4 C#5 E5220 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.

AEDEAEDAABXORflagga{cyberz_1s_r34lly_h3xt3ch}
Vilka toner som hörs i varje slag i A och B plus XOR av dem. En fylld ruta betyder att tonen spelas.

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.

SlagABA XOR BTecken
184E266f
28FE36Cl
31C7D61a
4701767g
59CFB67g
6E78661a
7FC877B{

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