Full cleared Minuteman CTF 2025!#
One challenge I liked is chunked, it is a hard forensics challenge.
Description: Bloo keeps corrupting my files! I managed to save bits of a really nice photo I took… but it’s all messed up. Can you recover the original image?

unzipping the artifact yielded 4 corrupted png images. inclusion of corrupted image is intended




If you can’t see the image, you can download the zip file here#
As you can see in the second image, you can see the starting part of the flag. So initially I started using online repairing of png images, which all yielded no results. Was confused and scratching my head.
Then I stumbled upon this tool called pngcheck when searching for “how to check png for errors” which allowed me to check the images.
running pngcheck on each sample shows these results:
| 1
2
3
4
5
6
7
8
9
 | pngcheck part0.png 
part0.png:  invalid chunk name "ԥE" (ffffffd4 ffffffa5 45 11)
ERROR: part0.png
part1.png:  invalid chunk name ")J)" (29 4a 7f 29)
ERROR: part1.png
part2.png:  invalid chunk name "a��" (61 ffffffb8 16 ffffffc6)
ERROR: part2.png
part3.png:  invalid chunk name "" (00 ffffffe6 ffffffd4 19)
ERROR: part3.png
 | 
 
As we can see here, pngcheck showed errors on certain chunks of png.
What the heck is a chunk and why is it corrupted?#
After some research, and reading this article I can understand the PNG format uses chunks (this is how your images can show up partially in webpages), and that corruption in one chunk can render the image un-showable.
Essentially, on a file level, png may look something like this:
| 1
2
3
4
5
 | MAGIC (used to identify as PNG 89 50 4E 47 0D 0A 1A 0A) 
IHDR (critical header chunk, contains information such as dimension/)
PLTE (critical color palette chunk)
IDAT (actual image pixel data, must be consecutive and end with IEND)
IEND
 | 
 
While each chunk looks like this:
| 1
2
3
4
 | 4 byte length of it's data
4 byte chunk type (ASCII)
*actual data*
4 byte of CRC (calculated on all preceding data)
 | 
 
It is important to keep in mind that each chunk has a CRC, this can be used to check if itself is corrupted.
Sample hexdump:
| 1
2
3
 | 00000000  89 50 4e 47 0d 0a 1a 0a  00 00 00 0d 49 48 44 52  |.PNG........IHDR|
00000010  00 00 0f e7 00 00 09 2f  08 02 00 00 00 91 4d fb  |......./......M.|
00000020  bb 00 00 00 09 70 48 59  73 00 00 0e c4 00 00 0e  |.....pHYs.......|
 | 
 
What’s wrong with the images?#
With this information, I started looking online on how to verify PNG chunks. I discovered the PNG File chunk inspector online tool. With this tool, you can see very detailed information about each chunk of a PNG file, including which chunk is corrupted. I uploaded all the images, you can see a sample here.
part0.png

part1.png:

You can see that the tool detects chunk 165 010 as corrupted for part1.png, while the same chunk appears to be normal in part0.png.
How do I recover them?#
I suspected that all of these pngs are essentially the same file but with different chunks corrupted. Which I then verified the hypophysis by uploading all the samples into the chunk verifier. Which I will not show here. This proved my hypophysis correct.
In order to recover the image, I just have to replace all the corrupted chunks with a known-good chunk. I could do this manually, but there were too many, so I needed to write a script.
Writing a PNG parser#
I stumbled upon this blog-post while researching how to write a png parser. Writing a (simple) PNG decoder might be easier than you think
Look simple, let’s borrow and modify this code to parse our images :3
I wrote a read-chunk function like so:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 | def read_chunk(f):
    header = f.read(8)
    if len(header) < 8:
        return (False, b"IEND", b"")  # if the image have corrupted IEND chunk, add one
    
    chunk_length, chunk_type = struct.unpack(">I4s", header)
    if chunk_type not in [
        b"IHDR",
        b"IDAT",
        b"IEND",
        b"pHYs",
        b"iTXt",
    ]:  # we don't care about other non-critical chunks
        f.seek(chunk_length + 4, 1)  # skip chunk data + crc
        return (False, chunk_type, b"")
    
    print("Reading chunk:", chunk_type, "length", chunk_length)
    chunk_data = f.read(chunk_length)
    chunk_expected_crc = struct.unpack(">I", f.read(4))
    chunk_actual_crc = zlib.crc32(
        chunk_data, zlib.crc32(struct.pack(">4s", chunk_type))
    )
    if chunk_expected_crc != chunk_actual_crc:
        print("chunk checksum failed")
    return (chunk_expected_crc == chunk_actual_crc, chunk_type, chunk_data)
 | 
 
The function takes in a image file, then returns a tuple that contains validity of the chunk, it’s type and data on each call.
Using this function, we are read in all of the chunks of the corrupted parts. PS: I know this can be looped but I can’t be bothered.
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
 | part0_chunks = []
part1_chunks = []
part2_chunks = []
part3_chunks = []
while True:
    part0_chunks.append(read_chunk(p0))
    if part0_chunks[-1][1] == b"IEND":
        break
while True:
    part1_chunks.append(read_chunk(p1))
    if part1_chunks[-1][1] == b"IEND":
        break
while True:
    part2_chunks.append(read_chunk(p2))
    if part2_chunks[-1][1] == b"IEND":
        break
while True:
    part3_chunks.append(read_chunk(p3))
    if part3_chunks[-1][1] == b"IEND":
        break
 | 
 
Now with all of the chunks read-in, we can select one as the basis of our recovery image, iterate over all the chunks, then replace it with a good copy if it’s corrupted (invalid crc).
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
 | recon = part2_chunks[:]
for i in range(len(recon)):
    # check if the chunk is valid
    valid = recon[i][0]
    if valid:
        continue
    # else search for the next valid chunk to replace it
    if part0_chunks[i][0]:
        recon[i] = part0_chunks[i]
    elif part1_chunks[i][0]:
        recon[i] = part1_chunks[i]
    elif part3_chunks[i][0]:
        recon[i] = part3_chunks[i]
    else:
        print("error, cant find replacement on", i)
        break
 | 
 
At last, we write all the chunks of the recovered image out:
| 1
2
3
4
5
6
7
8
9
 | with open("reconstructed.png", "wb") as out:
    out.write(magic)
    for valid, chunk_type, chunk_data in recon:
        if not valid:
            print("warning, writing invalid chunk", chunk_type)
        out.write(struct.pack(">I4s", len(chunk_data), chunk_type))
        out.write(chunk_data)
        chunk_crc = zlib.crc32(chunk_data, zlib.crc32(struct.pack(">4s", chunk_type)))
        out.write(struct.pack(">I", chunk_crc))
 | 
 
We get our beautiful re-constructed image with the full flag.

Funny moment#
I had very bad skill issue reading the font, confusing “1” with “I”, had a crashout :(. Thanks to my friend Shanzay who has better reading skill.

Thanks for reading!
Full Solve:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
 | import zlib
import struct
p0 = open("part0.png", "rb")
p1 = open("part1.png", "rb")
p2 = open("part2.png", "rb")
p3 = open("part3.png", "rb")
parts = [p0, p1, p2, p3]
chunk_reconstruct = []
magic = b"\x89PNG\r\n\x1a\n"
for p in parts:
    if p.read(len(magic)) != magic:
        raise Exception("Invalid PNG Signature in part")
def read_chunk(f):
    header = f.read(8)
    if len(header) < 8:
        return (False, b"IEND", b"")  # if the image have corrupted IEND chunk, add one
    
    chunk_length, chunk_type = struct.unpack(">I4s", header)
    if chunk_type not in [
        b"IHDR",
        b"IDAT",
        b"IEND",
        b"pHYs",
        b"iTXt",
    ]:  # we don't care about other non-critical chunks
        f.seek(chunk_length + 4, 1)  # skip chunk data + crc
        return (False, chunk_type, b"")
    
    print("Reading chunk:", chunk_type, "length", chunk_length)
    chunk_data = f.read(chunk_length)
    chunk_expected_crc = struct.unpack(">I", f.read(4))
    chunk_actual_crc = zlib.crc32(
        chunk_data, zlib.crc32(struct.pack(">4s", chunk_type))
    )
    if chunk_expected_crc != chunk_actual_crc:
        print("chunk checksum failed")
    return (chunk_expected_crc == chunk_actual_crc, chunk_type, chunk_data)
part0_chunks = []
part1_chunks = []
part2_chunks = []
part3_chunks = []
while True:
    part0_chunks.append(read_chunk(p0))
    if part0_chunks[-1][1] == b"IEND":
        break
while True:
    part1_chunks.append(read_chunk(p1))
    if part1_chunks[-1][1] == b"IEND":
        break
while True:
    part2_chunks.append(read_chunk(p2))
    if part2_chunks[-1][1] == b"IEND":
        break
while True:
    part3_chunks.append(read_chunk(p3))
    if part3_chunks[-1][1] == b"IEND":
        break
print(len(part0_chunks), "chunks in part0")
print(len(part1_chunks), "chunks in part1")
print(len(part2_chunks), "chunks in part2")
print(len(part3_chunks), "chunks in part3")
recon = part2_chunks[:]
for i in range(len(recon)):
    # check if the chunk is valid
    valid = recon[i][0]
    if valid:
        continue
    # else search for the next valid chunk to replace it
    # print(part1_chunks[i])
    # print(part1_chunks[i])
    # print(part2_chunks[i])
    # print(part3_chunks[i])
    if part0_chunks[i][0]:
        recon[i] = part0_chunks[i]
    elif part1_chunks[i][0]:
        recon[i] = part1_chunks[i]
    elif part3_chunks[i][0]:
        recon[i] = part3_chunks[i]
    else:
        print("error, cant find replacement on", i)
        break
with open("reconstructed1.png", "wb") as out:
    out.write(magic)
    for valid, chunk_type, chunk_data in recon:
        if not valid:
            print("warning, writing invalid chunk", chunk_type)
        out.write(struct.pack(">I4s", len(chunk_data), chunk_type))
        out.write(chunk_data)
        chunk_crc = zlib.crc32(chunk_data, zlib.crc32(struct.pack(">4s", chunk_type)))
        out.write(struct.pack(">I", chunk_crc))
 |