CERT-SE CTF 2026

Writeup för CERT-SE:s utmaning under cybersäkerhetsmånaden

Utmaningen är en nätverksdump med sex gömda flaggor på formen ctf[...] eller CTF[...] och allt går att lösa utan internet. Ladda ner zip-filen, kolla hashsumman mot den på CERT-SE:s sida och packa upp:

$ wget https://www.cert.se/ctf/CERT-SE_CTF2026.zip
$ sha256sum CERT-SE_CTF2026.zip
cf271adc6abd284eadfa79f00cbfcc1fcad2eaffc8af9e95e371465cf8c8ca78  CERT-SE_CTF2026.zip
$ unzip CERT-SE_CTF2026.zip
Archive:  CERT-SE_CTF2026.zip
  inflating: CERT-SE_CTF_2026.pcapng
  1. Flagga 1. Svansen
  2. Flagga 2. DNS-frågan
  3. Flagga 3. Statuskoderna
  4. Flagga 4. Pingpaketen
  5. Flagga 5. Dokumentet
  6. Flagga 6. Bakgrundsbilden

Flagga 1. Svansen

Det enklaste först. strings plockar ut all läsbar text ur en fil, hur binär den än är. grep letar sedan efter ett mönster i det som kommer ut. Flaggorna börjar med ctf[ eller CTF[, så sök efter det i hela filen. -i struntar i stora och små bokstäver och bakstrecket före hakparentesen behövs eftersom [ annars betyder något eget för grep:

$ strings CERT-SE_CTF_2026.pcapng | grep -i 'ctf\['
ctf[first_flag]

Flaggan: ctf[first_flag]

En träff, det var enkelt :) De andra fem syns alltså inte som vanlig text, de är gömda eller omkodade på något sätt.

Innan resten, en sak till. capinfos följer med Wireshark och visar fakta om en dumpfil, hur många paket den har, när den spelades in och hur länge. Här klagar den i stället:

$ capinfos CERT-SE_CTF_2026.pcapng
capinfos: An error occurred after reading 19007 packets from "CERT-SE_CTF_2026.pcapng".
capinfos: The file "CERT-SE_CTF_2026.pcapng" appears to be damaged or corrupt.
(pcapng: total block length 1936877928 is too large (> 134348832))

19007 paket gick bra att läsa, sen kommer något som inte är ett paket. Kolla vad som ligger allra sist i filen. tail -c 32 tar de 32 sista byten och hexdump -C visar dem som hexadecimala tal med texten bredvid, 16 byte per rad:

$ tail -c 32 CERT-SE_CTF_2026.pcapng | hexdump -C
00000000  00 00 00 00 00 00 00 00  00 00 00 00 6c 00 00 00  |............l...|
00000010  63 74 66 5b 66 69 72 73  74 5f 66 6c 61 67 5d 0a  |ctf[first_flag].|
00000020

Det är flaggan igen! Den står allra sist efter det sista paketet och fyller precis den sista raden: 15 tecken plus radbrytningen 0a, alltså 16 byte. Wireshark försöker läsa dem som ett paket till och tycker därför att filen är trasig. Kapa bort dem så slipper man felmeddelandet i resten av kommandona. head -c -16 skriver ut allt utom de 16 sista byten och > sparar det i en ny fil:

$ head -c -16 CERT-SE_CTF_2026.pcapng > clean.pcapng

Flagga 2. DNS-frågan

Öppna clean.pcapng i Wireshark. 19007 paket är alldeles för mycket att bläddra igenom, så börja med en översikt över vilka protokoll som finns. Den ligger under Statistics → Protocol Hierarchy:

Wiresharks Protocol Hierarchy för dumpen

TLS och QUIC är krypterat, det går inte att läsa. Kvar blir DNS, HTTP och ICMP. Under HTTP finns dessutom en png-bild och något som heter Media Type, alltså filer som har hämtats. De får vänta lite ;)

Ta dem uppifrån, först DNS. Skriv dns i filterraden högst upp och tryck Enter:

Början av DNS-listan: uppslag av mozilla och wikipedia, sedan paket 2380 som frågar efter Y3RmW3NpbXBsZV9kbnNfZXhmaWxd.flag.com och får svaret No such name

Mest är det Firefox som slår upp sina vanliga adresser. Men kolla paket 2380, det där är inget vanligt namn :) Svaret är No such name, namnet finns inte. Samma fråga kommer tillbaka flera gånger längre ner i listan.

Första delen ser ut som base64, ett sätt att skriva vilka byte som helst med bara bokstäver, siffror och tecknen + samt / i stället för rå data. Det känns igen på att det ser ut som ordsallad med blandade stora och små bokstäver. Base64 tar tre byte i taget. Saknas en eller två byte i sista gruppen avslutas strängen med = respektive == som padding. Här går det jämnt upp så ingen padding behövs. Klistra in den i CyberChef med From Base64 eller kör base64 -d i terminalen:

$ echo Y3RmW3NpbXBsZV9kbnNfZXhmaWxd | base64 -d
ctf[simple_dns_exfil]

Flaggan: ctf[simple_dns_exfil]

Flagga 3. Statuskoderna

Nästa i översikten är HTTP. Skriv http i filterraden:

Början av paketlistan med filtret http: GET-frågor och svar med 404 och 200

Den som frågar ber om en sida med GET och raden under är serverns svar, en statuskod: 200 om sidan finns och 404 om den inte finns. Här kommer GET efter GET med sidnamn som ser ut att komma ur en ordlista och nästan alla får 404. Det ser ut som att någon kör en scan mot webbservern och provar sidnamn för att se vad som finns.

Över tusen frågor orkar man inte läsa en och en. Låt Wireshark räkna svaren i stället, det ligger under Statistics → HTTP → Packet Counter:

HTTP Packet Counter: 765 svar 404, 22 svar 403, 285 svar 200

765 sidor finns inte och 285 finns. Men 22 svar är 403 Forbidden, sidan finns men man får inte se den. Det sticker ut :) Filtrera fram dem med http.response.code == 403 i filterraden.

Listan säger bara 403 Forbidden, inte vilken sida svaret gäller. Klicka på ett av svaren och fäll ut Hypertext Transfer Protocol i rutan under listan. Längst ner står sidan som frågan gällde:

Paketdetaljerna för ett 403-svar med raden Request URI: /c/index.html markerad

Högerklicka på raden [Request URI: /c/index.html] och välj Apply as Column. Då får sidan en egen kolumn i listan:

De 22 svaren med 403 och kolumnen Request URI, där katalogerna är ett tecken var

Alla 22 går till en katalog med ett enda tecken. Läs tecknen uppifrån och ner. %5B och %5D är ett tecken var, hakparenteserna i flaggan. Scannen provar massor av andra kataloger med ett tecken också, men de får 404 eller 200 och är bara brus.

Flaggan: ctf[inspect_the_codes]

Flagga 4. Pingpaketen

Sist i listan, ICMP. Skriv icmp i filterraden:

Filtret icmp: request och reply om vartannat, ttl=128 på paket 1610, 2147 och 2182 och annars ttl=64

192.168.122.48 pingar 192.168.122.1 som svarar varje gång, därav request och reply om vartannat. Ett vanligt CTF-knep är att gömma data i pingpaketens innehåll, men klicka igenom några så syns det att alla har samma data. Så fyller ping i Linux alltid ut sina paket, där finns inget.

Kolla i stället ttl i Info-kolumnen. Svaren har alltid 64 men pingpaketen hoppar mellan 64 och 128, det är skumt :) TTL, time to live, är ett tal i varje IP-paket som räknas ner för varje router paketet passerar. Det börjar normalt på 64 i Linux och 128 i Windows, så paket från samma dator brukar ha samma värde hela tiden.

Kolla tiden mellan pingpaketen också. Den här gången i terminalen med tshark, som följer med Wireshark. -r läser dumpen, -Y är samma filter som i filterraden och -T fields -e skriver bara ut de fält man vill ha. icmp.type==8 är bara pingpaketen, svaren har typ 0:

$ tshark -r clean.pcapng -Y 'icmp.type==8' -T fields -e frame.time_delta_displayed -e ip.ttl | head -8
0.000000000	64
0.202848876	64
6.002398452	128
0.202972826	64
6.002728414	128
0.202461145	64
0.202181444	128
6.002453114	64

Kör utan head så syns det att alla 95 pingpaket ser ut så. De kommer i grupper om en till fem, med 0,2 s inom gruppen och 6 s till nästa grupp. Två olika värden i små grupper ... morse! TTL 64 är kort, TTL 128 är lång och pausen på 6 s betyder ny bokstav. Utan pauserna går det inte att läsa, de åtta första tecknen ..-.-.-. kan vara både INKE och FC.

awk läser rad för rad och $1 och $2 är första och andra kolumnen. Är pausen över 3 s skrivs ett mellanslag, sen en punkt för TTL 64 och annars ett streck. END lägger till en radbrytning sist:

$ tshark -r clean.pcapng -Y 'icmp.type==8' -T fields -e frame.time_delta_displayed -e ip.ttl | awk '$1 > 3 { printf " " } { printf $2 == 64 ? "." : "-" } END { print "" }' > morse.txt
$ cat morse.txt
.. -. -.- . -- .-- ..--- ..- .--- ..-. --. ..- ... - ... .... .-.. ..... . ...- --. -..- ..--- --. -.- ...- .... ..-. ..---

Klistra in morsen i CyberChef och lägg till From Morse Code. Ut kommer INKEMW2UJFGUSTSHL5EVGX2GKVHF2 och ingen flagga än. Bara stora bokstäver och siffror mellan 2 och 7, det är base32. Lägg till From Base32 under, så kommer flaggan. I terminalen gör morse -d från bsdgames och base32 -d samma sak:

$ morse -d < morse.txt | base32 -d
CTF[TIMING_IS_FUN]

Flaggan: CTF[TIMING_IS_FUN]

Flagga 5. Dokumentet

Dags för filerna som syntes under HTTP i översikten. Med filtret http igen syns det längst ner i listan vad det är, ett dokument och en bild:

Slutet av HTTP-listan: GET /Om_oss.odt och GET /white_wallpaper.png med svaren 200 OK

En fil som skickas över nätet delas upp i många paket, men Wireshark kan sätta ihop dem igen. Öppna File → Export Objects → HTTP och scrolla längst ner, där har vi dem:

Export Objects för HTTP: längst ner Om_oss.odt på 50 kB och white_wallpaper.png på 42 kB, ovanför massor av html-sidor

Markera Om_oss.odt, klicka på Save och spara den i en ny katalog som heter filer bredvid dumpen. Gör likadant med white_wallpaper.png, den behövs senare.

Öppna filer/Om_oss.odt i LibreOffice. Det är texten från CERT-SE:s sida Om CERT-SE och den tar slut en bit ner på sidan två. Ändå säger raden längst ner i fönstret att dokumentet har sex sidor. Slå på formateringstecken med Ctrl+F10 eller Visa → Formateringstecken, så syns det att sidorna efter texten inte är tomma. De är fulla av tabbar och styckeslut.

I HTTP-trafiken finns förresten Wikipedias artikel Whitespace character. En liten knuff åt rätt håll :)

Två sorters tecken igen, precis som TTL-värdena. Låt varje tabb vara en etta och varje styckeslut en nolla. En dator sparar varje bokstav som ett tal, c är till exempel 99 och 99 skrivet med ettor och nollor blir 01100011. Åtta bitar i taget blir alltså en bokstav:

I dokumentetBitar0→→11000→→110→→→1110→1000→→1100→→11001100011= c01110100= t01100110= f

Fyra sidor för hand är inget kul, så låt datorn göra resten. Spara dokumentet som text med Arkiv → Spara som och filtypen Text (.txt). LibreOffice frågar om formatet, välj att använda Text. Då blir varje stycke en rad och varje tabb ett tabbtecken. grep -v visar raderna som inte matchar, här alla rader utan små bokstäver. Texten försvinner och kvar blir raderna med bara tabbar eller ingenting. tr byter tecken mot tecken, här tabb mot 1 och radbrytning mot 0:

$ grep -v '[a-z]' filer/Om_oss.txt | tr '\t\n' '10' > bits.txt
$ cat bits.txt
011000110111010001100110010110110110111001101111011101000101111101100101011101100110010101110010011110010101111101110111011010000110100101110100011001010111001101110000011000010110001101100101010111110110100101110011010111110111010001101000011001010101111101110011011000010110110101100101010111010

297 bitar, alltså 37 byte och en nolla över från det allra sista styckeslutet. Gör om de 296 första till text. I CyberChef heter det From Binary och i terminalen gör basenc samma sak. head -c 296 tar de 296 första och --base2msbf betyder ettor och nollor med den viktigaste biten först, alltså som vi läser dem:

$ head -c 296 bits.txt | basenc -d --base2msbf
ctf[not_every_whitespace_is_the_same]

Flaggan: ctf[not_every_whitespace_is_the_same]

Flagga 6. Bakgrundsbilden

Nu den andra filen. Kolla vad det är för bild med exiftool:

$ exiftool filer/white_wallpaper.png
ExifTool Version Number         : ...
File Name                       : white_wallpaper.png
...
MIME Type                       : image/png
Image Width                     : 4096
Image Height                    : 2160
Bit Depth                       : 8
Color Type                      : RGB with Alpha
...

4096 x 2160 pixlar och RGB with Alpha, alltså rött, grönt, blått och genomskinlighet (alfa) för varje pixel. Lite märkligt för en bakgrundsbild, den behöver ju ingen genomskinlighet. Öppna filer/white_wallpaper.png så är den helt vit.

En helt vit bild borde ha exakt en färg. Räkna dem med ImageMagick, -format '%k\n' betyder antal unika färger:

$ magick filer/white_wallpaper.png -format '%k\n' info:
209

209 färger i en vit bild. -unique-colors gör en bild med en pixel per färg och txt: skriver ut den som text, så det blir en lista:

$ magick filer/white_wallpaper.png -unique-colors txt:
# ImageMagick pixel enumeration: 209,1,0,255,srgba
0,0: (255,255,254,1)  #FFFFFE01  srgba(255,255,254,0.00392157)
1,0: (254,255,255,3)  #FEFFFF03  srgba(254,255,255,0.0117647)
2,0: (254,255,255,2)  #FEFFFF02  srgba(254,255,255,0.00784314)
3,0: (255,255,254,5)  #FFFFFE05  srgba(255,255,254,0.0196078)
4,0: (255,255,254,4)  #FFFFFE04  srgba(255,255,254,0.0156863)
...
206,0: (255,255,254,207)  #FFFFFECF  srgba(255,255,254,0.811765)
207,0: (254,255,255,208)  #FEFFFFD0  srgba(254,255,255,0.815686)
208,0: (255,255,255,255)  #FFFFFFFF  white

Talen i parentesen är rött, grönt, blått och alfa, var och en mellan 0 och 255. För alfa är 255 helt täckande och 0 helt genomskinligt. Sista raden är den vita färgen som nästan hela bilden har. De andra 208 färgerna finns i en enda pixel var och deras alfa är 1, 2, 3 och så vidare upp till 208. Alfa är alltså ett ordningsnummer.

Var i bilden de ligger verkar inte spela någon roll. Det intressanta är rött, som är 254 eller 255. Blått är alltid tvärtom mot rött och säger inget nytt. Ordna pixlarna efter alfa och skriv en etta för rött 254 och en nolla för 255:

Alfax, yRöttGröntBlåttBit
12283, 15672552552540
2898, 14072542552551
3259, 5682542552551
4635, 12952552552540
53521, 2912552552540
62597, 13232552552540
71203, 5822542552551
83193, 4792542552551

De åtta första bitarna blir 01100011, alltså 99 och ett c precis som i dokumentet. Resten på samma sätt. 208 pixlar är för många att ta för hand, så låt ett litet Python-skript göra det. Spara det som pixlar.py:

from PIL import Image

bild = Image.open('filer/white_wallpaper.png')
px = bild.load()
pixlar = []
for y in range(bild.height):
    for x in range(bild.width):
        r, g, b, alfa = px[x, y]
        if alfa < 255:
            pixlar.append((alfa, r))
pixlar.sort()
bitar = ''
for alfa, r in pixlar:
    bitar += '1' if r == 254 else '0'
text = ''
for i in range(0, len(bitar), 8):
    text += chr(int(bitar[i:i+8], 2))
print(text)

Skriptet går igenom bilden rad för rad och sparar alfa och rött för pixlarna som inte är helt täckande. sort() ordnar dem efter alfa, eftersom alfa står först i varje par. Sen blir rött 254 en etta och 255 en nolla. Till sist tar skriptet åtta bitar i taget. int(…, 2) läser dem som ett binärt tal och chr gör om talet till ett tecken, precis som med c ovan:

$ python3 pixlar.py
ctf[hidden_in_plain_sight]

Flaggan: ctf[hidden_in_plain_sight]


Stort tack till CERT-SE för en rolig utmaning!
/Andreas