Corrupted Vision: Cryptography / Stego

Flag: KPMG_CTF{S33K_AND_F1ND_M3}

File: Corrupted Vision, 322,687 bytes, no extension, file reports only data Category: Cryptography (file-format forensics + text steganography / Morse)

Scenario

We intercepted a file our tools can’t classify. It looks like a simple profile picture, but we know there’s more to it. Expand your view to uncover the hidden payload.

Hints in the description:

  • “tools can’t classify” → the file signature (magic bytes) is broken.
  • “profile picture” → it’s an image.
  • “Expand your view” → the image dimensions were shrunk to hide part of it.

Layers, outermost first:

fake PNG magic  ->  JPEG with height cut down  ->  hidden paragraph  ->  word lengths = Morse  ->  flag

I worked on a copy (cv.bin) in a scratch directory so the original stayed untouched.

Step 1: Triage

$ cp "Corrupted Vision" cv.bin
$ file cv.bin
cv.bin: data

file can’t identify it. Dump the start and end of the file (xxd wasn’t installed, so I used od):

$ od -A x -t x1z cv.bin | head -12
000000 89 50 4e 47 0d 0a 1a 0a 49 46 00 01 01 00 00 01  >.PNG....IF......<
000010 00 01 00 00 ff db 00 43 00 03 02 02 02 02 02 03  >.......C........<
...
000050 12 13 12 10 13 0f 10 10 10 ff db 00 43 01 03 03  >............C...<
...
000090 10 10 10 10 10 10 10 10 10 10 10 10 10 10 ff c0  >................<
0000a0 00 11 08 02 26 03 20 03 01 22 00 02 11 01 03 11  >....&. .."......<

$ od -A x -t x1z cv.bin | tail -2
04ec70 04 1e d7 bd f8 f4 17 e4 fa 7d 8f b0 1f ff d9     >.........}.....<
04ec7f

The file is a mix of two formats:

Location Bytes Meaning
0x00–0x07 89 50 4E 47 0D 0A 1A 0A PNG signature
0x08 49 46 00 01 01 … IF\0 + version 1.01, the end of a JPEG/JFIF APP0 header (JF + IF\0)
0x14 FF DB JPEG DQT (quantisation table)
0x9E FF C0 JPEG SOF0 (baseline frame header)
end FF D9 JPEG EOI (end of image)

So this is really a JPEG. Its first 8 bytes were overwritten with the PNG signature, which breaks detection in every tool. A normal JFIF file starts with:

FF D8          SOI  (start of image)
FF E0 00 10    APP0 marker, segment length 16
4A 46 49 46 00 "JFIF\0"
01 01          version 1.01

Only the first 8 bytes (FF D8 FF E0 00 10 4A 46) were replaced; the rest (49 46 00 01 01…) is still intact.

Step 2: Layer 1: Restore the JPEG magic bytes

$ python3 -c "
d=bytearray(open('cv.bin','rb').read())
d[0:8]=bytes.fromhex('ffd8ffe000104a46')
open('fixed.jpg','wb').write(d)"

$ file fixed.jpg
fixed.jpg: JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16,
           baseline, precision 8, 800x550, components 3

The file is now a valid 800×550 JPEG. Opened, it shows a black-and-white photo of a masked figure, with the first line of a paragraph cut off at the bottom edge. That cut-off text means the image is taller than the header says.

Step 3: Layer 2: “Expand your view”, fix the height

The SOF0 segment at 0x9E stores the frame size:

0x9E  FF C0        SOF0 marker
0xA0  00 11        segment length 17
0xA2  08           precision 8 bits
0xA3  02 26        HEIGHT = 0x0226 = 550
0xA5  03 20        WIDTH  = 0x0320 = 800
0xA7  03           3 components

A JPEG decoder stops drawing once it reaches the height in the header, even if the compressed data continues. Changing the height to a large value shows how far the real image goes:

$ python3 -c "
d=bytearray(open('fixed.jpg','rb').read())
d[0xa3:0xa5]=(1600).to_bytes(2,'big')
open('tall.jpg','wb').write(d)"
$ file tall.jpg
tall.jpg: JPEG image data, JFIF standard 1.01, ... baseline, precision 8, 800x1600, components 3

At 1600 px the real picture ends around row 1000; below that is black/garbage where the decoder runs out of real data. With height = 1000, the image is clean:

$ python3 -c "
d=bytearray(open('fixed.jpg','rb').read())
d[0xa3:0xa5]=(1000).to_bytes(2,'big')
open('h1000.jpg','wb').write(d)"

The full 800×1000 image shows the photo with a paragraph of text below it:

Hackers sneak through, and steal network secrets daily. Now foreign systems are falling offline today. Our agent seeks answers without delay tonight. The mystery grows thicker after all hunters for every ghost machine trace can uncover these signals beneath radar. You watch their codes, but virus worms creep forward forever. And worse logic bombs corrupt records. All shadows exist outside the walls where digital thieves dwell quietly. The alert watcher can analyze bytes, yet nothing makes sense. His chief focus remains finding those hackers. Who wrote these complex files? New teams writing strange scripts nightly. Our nearest enemy has dropped decoy nodes. Now every cyber soldier marches ahead bravely. Men extract botnets, and purge dirty files forever tonight. The victory takes massive efforts under attacks.

The text reads awkwardly, which suggests it was written to carry a hidden message.

Step 4: Layer 3: Finding the channel in the text

I saved the paragraph to t.txt and tried the usual text-stego checks.

Attempt: acrostics (fail)

$ python3 -c "
import re
t=open('t.txt').read()
w=re.findall(r'[A-Za-z]+',t)
print(''.join(x[0] for x in w))                                         # first letter of each word
print(''.join(s.split()[0][0] for s in re.split(r'(?<=[.?])\s+',t)))   # first letter of each sentence"
HstasnsdNfsafotOasawdtTmgtaahfegmtcutsbrYwtcbvwcffAwlbcrAseotwwdtdqTawcabynmsHcfrfthWwtcfNtwssnOnehddnNecsmabMebapdfftTvtmeua
HNOTYAATHWNONMT

Neither gives anything readable.

Word lengths (works)

$ python3 -c "
import re
from collections import Counter
w=re.findall(r'[A-Za-z]+',open('t.txt').read())
L=[len(x) for x in w]; print(len(w)); print(L); print(Counter(L))"
125
[7, 5, 7, 3, 5, 7, 7, 5, 3, 7, 7, 3, 7, 7, 5, 3, 5, 5, 7, 7, 5, 7, 3, 7, 5, 7, 5, 3, ...]
Counter({7: 51, 5: 49, 3: 25})

All 125 words are exactly 3, 5 or 7 letters long. Natural English never does this, so the word lengths carry the message. Three symbols, one of which (3) is rarer and evenly spaced, fits Morse code:

Word length Morse symbol
5 . dot
7 - dash
3 letter separator

Checking the first group: Hackers(7) sneak(5) through(7) and(3) → -.- / → K, which matches the start of KPMG.

Step 5: Layer 3: Decode the Morse

$ python3 -c "
import re
w=re.findall(r'[A-Za-z]+',open('t.txt').read())
s=''.join({5:'.',7:'-',3:' '}[len(x)] for x in w); print(s)
M={'.-':'A','-...':'B','-.-.':'C','-..':'D','.':'E','..-.':'F','--.':'G','....':'H','..':'I',
   '.---':'J','-.-':'K','.-..':'L','--':'M','-.':'N','---':'O','.--.':'P','--.-':'Q','.-.':'R',
   '...':'S','-':'T','..-':'U','...-':'V','.--':'W','-..-':'X','-.--':'Y','--..':'Z',
   '-----':'0','.----':'1','..---':'2','...--':'3','....-':'4','.....':'5','-....':'6',
   '--...':'7','---..':'8','----.':'9','..--.-':'_','-.--.':'(','-.--.-':')'}
print(''.join(M.get(c,'?') for c in s.split()))"
-.- .--. -- --. ..--.- -.-. - ..-. -.--. ... ...-- ...-- -.- ..--.- .- -. -.. ..--.- ..-. .---- -. -.. ..--.- -- ...-- -.--.-
KPMG_CTF(S33K_AND_F1ND_M3)

Symbol by symbol:

-.-    K      -.--.  (      .-     A      .----  1
.--.   P      ...    S      -.     N      -.     N
--     M      ...--  3      -..    D      -..    D
--.    G      ...--  3      ..--.- _      ..--.- _
..--.- _      -.-    K      ..-.   F      --     M
-.-.   C      ..--.- _                    ...--  3
-      T                                  -.--.- )
..-.   F

Morse has no { or }, so the author used ( (-.--.) and ) (-.--.-) instead. Swapping them back gives the flag:

KPMG_CTF{S33K_AND_F1ND_M3}

“Seek and find me”. Morse has no letter case, so if the uppercase version is rejected, try KPMG_CTF{s33k_and_f1nd_m3}.

One-shot solver

python3 -c "
import re
d=bytearray(open('Corrupted Vision','rb').read())
d[0:8]=bytes.fromhex('ffd8ffe000104a46')    # restore JPEG SOI + APP0 header
d[0xa3:0xa5]=(1000).to_bytes(2,'big')       # SOF0 height 550 -> 1000
open('restored.jpg','wb').write(d)
"
# open restored.jpg, transcribe the paragraph into t.txt, then:
python3 -c "
import re
M={'.-':'A','-...':'B','-.-.':'C','-..':'D','.':'E','..-.':'F','--.':'G','....':'H','..':'I',
   '.---':'J','-.-':'K','.-..':'L','--':'M','-.':'N','---':'O','.--.':'P','--.-':'Q','.-.':'R',
   '...':'S','-':'T','..-':'U','...-':'V','.--':'W','-..-':'X','-.--':'Y','--..':'Z',
   '-----':'0','.----':'1','..---':'2','...--':'3','....-':'4','.....':'5','-....':'6',
   '--...':'7','---..':'8','----.':'9','..--.-':'_','-.--.':'{','-.--.-':'}'}
w=re.findall(r'[A-Za-z]+',open('t.txt').read())
print(''.join(M[c] for c in ''.join({5:'.',7:'-',3:' '}[len(x)] for x in w).split()))"
KPMG_CTF{S33K_AND_F1ND_M3}

Takeaways

  • Check magic bytes against the trailer. A PNG signature at the start but FF D9 (JPEG EOI) at the end, plus JFIF/FF DB/FF C0 markers inside, means a header was swapped. file only reads the first few bytes.
  • Fix only the bytes that were overwritten. Here just 8 bytes were replaced; the rest of the JFIF APP0 segment (IF\0 01 01 …) was still correct.
  • JPEG/PNG height tricks: decoders draw only as many rows as the header says. Shrinking the height in SOF0 (JPEG) or IHDR (PNG) hides the bottom of the picture without touching the image data. Raise it until the real content ends and garbage begins.
  • Text that reads awkwardly is usually hiding something. When acrostics fail, look at the word-length distribution. Only 2–3 distinct lengths means binary or Morse.
  • Morse has no braces or case: expect (/) in place of {/} and an uppercase result.