Corrupted Vision: Cryptography / Stego
On this page
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, plusJFIF/FF DB/FF C0markers inside, means a header was swapped.fileonly 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.