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
- Flagga 1. Svansen
- Flagga 2. DNS-frågan
- Flagga 3. Statuskoderna
- Flagga 4. Pingpaketen
- Flagga 5. Dokumentet
- 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:
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:
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:
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:
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:
Högerklicka på raden [Request URI: /c/index.html] och välj Apply as Column. Då får sidan en egen kolumn i listan:
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:
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:
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:
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:
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:
| Alfa | x, y | Rött | Grönt | Blått | Bit |
|---|---|---|---|---|---|
| 1 | 2283, 1567 | 255 | 255 | 254 | 0 |
| 2 | 898, 1407 | 254 | 255 | 255 | 1 |
| 3 | 259, 568 | 254 | 255 | 255 | 1 |
| 4 | 635, 1295 | 255 | 255 | 254 | 0 |
| 5 | 3521, 291 | 255 | 255 | 254 | 0 |
| 6 | 2597, 1323 | 255 | 255 | 254 | 0 |
| 7 | 1203, 582 | 254 | 255 | 255 | 1 |
| 8 | 3193, 479 | 254 | 255 | 255 | 1 |
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