$ cat writeup.md…
$ cat writeup.md…
metactf
Task: a MetaCTF domain hides its real payload in numbered DNS TXT records instead of a normal host response. Solution: rebuild the base64 JAR from TXT chunks, reverse the dynamically loaded Java validator, and invert the rotating XOR transformation to recover the only valid input string.
Target domain: data-needs-splitting.umbccd.net
English summary: the challenge hides its actual artifact inside DNS TXT records. Reassembling those records yields a Java archive whose validator must be reversed to recover the correct flag string.
The main trick is that the domain does not behave like a normal service endpoint. Instead of returning a useful A record, it stores the payload across many numbered TXT records. Those chunks reconstruct a JAR file, and the JAR hides its real validator logic in assets/file.dat, which is dynamically loaded as a Java class.
One important oddity is the final flag format: the challenge was solved under MetaCTF, but the valid flag is clearly DawgCTF-formatted:
DawgCTF{REDACTED}
That mismatch should not be ignored during solving, because it is a strong hint that the backend content was reused or shared even though the event metadata says MetaCTF.
Initial DNS resolution did not produce a useful normal host record. Querying TXT records immediately revealed multiple long strings prefixed with two-digit indices such as 00, 01, 02, and so on.
Useful enumeration commands:
nslookup -type=TXT data-needs-splitting.umbccd.net curl -s -H "accept: application/dns-json" "https://dns.google/resolve?name=data-needs-splitting.umbccd.net&type=TXT"
This strongly suggests chunked data exfiltration or staged storage over DNS. The numbered prefixes indicate the intended recovery process: strip the index, sort by index, and concatenate the remaining base64 data.
Each TXT answer starts with a two-digit sequence number followed by a base64 fragment. After removing the index bytes, sorting numerically, and concatenating the rest, the result decodes cleanly into a ZIP/JAR file.
Recovery workflow:
index = chunk[:2] and data = chunk[2:].index.data pieces.challenge_blob.bin.The reconstructed file is actually a Java archive even though it has a .bin extension. Listing it shows:
META-INF/MANIFEST.MF Loader.class Main.class assets/file.dat
Useful inspection commands:
unzip -l challenge_blob.bin javap -classpath challenge_blob.bin -c -p Main javap -classpath challenge_blob.bin -c -p Loader unzip -p challenge_blob.bin assets/file.dat > file.dat cp file.dat Validator.class javap -c -p Validator
Main loads /assets/file.dat through Loader and invokes validate(). Although the file is named like data, it is actually a compiled Java class named Validator.
Disassembly of Validator.validate() shows the whole check:
Enter the flag:i, it extracts two rotating 16-bit masks from the long constants
2194307438957234483148527584754938272(i % 4) * 16, so the masks repeat every four characterstransformed = ord(ch) ^ mask1 ^ mask2
StringBuilder145511939249997195145441944550467175145531942549987228145401943650017203145451934650207244145651934650127169
The critical detail is that the program does not store separators between numbers. So reversing requires both undoing the XOR and segmenting the big decimal string correctly.
Because the masks are fully known and repeat every four positions, each character can be tested independently once we choose how many decimal digits belong to that position. A small backtracking script over printable characters recovers the unique string whose transformed values concatenate to the target decimal blob.
Full working solve script:
#!/usr/bin/env python3 import json import base64 import zipfile from functools import lru_cache from pathlib import Path DOMAIN = "data-needs-splitting.umbccd.net" DNS_JSON = Path("dns_txt.json") JAR_PATH = Path("challenge_blob.bin") TARGET = "145511939249997195145441944550467175145531942549987228145401943650017203145451934650207244145651934650127169" CONST_A = 2194307438957234483 CONST_B = 148527584754938272 def rebuild_jar_from_dns() -> None: data = json.loads(DNS_JSON.read_text()) answers = data["Answer"] txt_chunks = [] for answer in answers: if answer.get("type") != 16: continue chunk = answer["data"] txt_chunks.append((int(chunk[:2]), chunk[2:])) blob_b64 = "".join(piece for _, piece in sorted(txt_chunks)) JAR_PATH.write_bytes(base64.b64decode(blob_b64)) def mask_at(index: int) -> int: shift = (index % 4) * 16 m1 = (CONST_A >> shift) & 0xFFFF m2 = (CONST_B >> shift) & 0xFFFF return m1 ^ m2 @lru_cache(maxsize=None) def recover(pos: int, idx: int): if pos == len(TARGET): return [""] out = [] mask = mask_at(idx) for ch in range(32, 127): encoded = str(ch ^ mask) if TARGET.startswith(encoded, pos): for rest in recover(pos + len(encoded), idx + 1): out.append(chr(ch) + rest) return out def main() -> None: rebuild_jar_from_dns() with zipfile.ZipFile(JAR_PATH) as zf: print("JAR entries:") for name in zf.namelist(): print(f" - {name}") candidates = recover(0, 0) if len(candidates) != 1: raise SystemExit(f"Expected one solution, got {len(candidates)}: {candidates}") flag = candidates[0] print(f"Recovered flag: {flag}") if __name__ == "__main__": main()
Running the logic yields exactly one valid candidate:
DawgCTF{REDACTED}
The recovered string can be checked directly against the rebuilt JAR:
printf 'DawgCTF{REDACTED}\n' | java -jar challenge_blob.bin
Observed output:
Enter the flag: Correct!
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar