Challenge / Pwn

TicTacToed

TicTacToed is a sanitized challenge note from the local HTB archive, organized for quick review by category, difficulty, evidence flow, and reusable operator

MediumPublished 2025-04-23Sanitized local writeup

Scenario

TicTacToed attack path

TicTacToed is a sanitized challenge note from the local HTB archive, organized for quick review by category, difficulty, evidence flow, and reusable operator

Objective

Challenge walkthrough focused on Pwn evidence, validation, and reusable operator lessons.

TicTacToed sanitized attack graph

Walkthrough flow

01

The outer Rust tictactoe binary contains a hidden 5x5...

02

obfuscate_pattern() builds the required move history...

03

The access-code material is split into three...

04

A successful credential check calls execute_c2(),...

05

The embedded C2 has a use-after-free primitive: E can...

Source coverage

High source coverage

Status: complete. This article is generated from 4 sanitized Markdown sources and keeps raw flags, credentials, keys, cookies, and reusable secrets out of the rendered blog.

100% coverage
Evidence verdict

High confidence: the page is reconstructed from a primary walkthrough plus multiple supporting notes or evidence sources. Treat the chain as source-backed, while still checking the listed source files for sensitive values.

  • Pwn/TicTacToed/writeup.md
  • htb-challenge/Pwn/TicTacToed/notes.md
  • htb-challenge/Pwn/TicTacToed/memory-summary.md
  • htb-challenge/Pwn/TicTacToed/hypothesis-board.md

Technical Walkthrough

Writeup

Challenge

  • Name: TicTacToed
  • Category: Pwn
  • Difficulty: Medium
  • Mode: hybrid

Summary

TicTacToed is a hybrid pwn challenge with an outer Rust tic-tac-toe gate and

an embedded C command-and-control ELF. The solve chain is:

  1. Play the hidden 5x5 tic-tac-toe move sequence that unlocks the operator

interface.

  1. Derive the access code from the XOR-obfuscated bytes in the Rust binary.
  2. Use the C2 menu to leak a PIE text pointer via printID.
  3. Trigger the stale-agent use-after-free with E/Y, then reclaim the freed

chunk through F.

  1. Overwrite the stale function pointer with the live getSecret address and

capture the HTB-format flag.

Artifact Inventory

Reference analysis/artifact-inventory.json and summarize the relevant files or remote surface.

  • files/extracted/pwn_tictactoed/tictactoe: outer 64-bit Rust PIE executable.
  • analysis/C2_executable.full: carved embedded C2 ELF used after the hidden

game/access-code gate.

  • analysis/outer-*: disassembly notes for the Rust gate, key derivation, and

embedded C2 extraction.

  • analysis/C2-full-*: symbol, disassembly, and string evidence for the C2

menu, getSecret, printID, Hackupdate, and the stale agent pointer.

  • solve/solve.py: final reproducible remote solver.
  • solve/probe_fast_offsets.py: bounded calibration helper used to resolve

remote C2 layout drift.

Analysis

Static analysis showed the Rust binary decrypts an access code by XORing an

embedded byte sequence with 0x5a. The resulting bytes hash to the embedded

SHA-256 value, proving the access-code derivation without storing the raw value

in the writeup.

The Rust check_winner path contains a hidden move-history condition. The

required sequence is:

text
X:00 O:04 X:11 O:13 X:22 O:31 X:33 O:40 X:44

After that pattern is recognized, the binary prompts for operator credentials

and executes the embedded C2 as /tmp/C2_executable.

The C2 allocates a global agent structure and repeatedly calls the function

pointer at the start of that structure. Menu option H sets that pointer to

printID, which prints a text pointer for PIE calibration. Menu option E

sets the function pointer to exitProgram; confirming with Y frees the

global agent without clearing it. Menu option F calls Hackupdate, which

allocates 8 bytes and reads 8 raw bytes from stdin. This reclaims the freed

chunk and lets the solver overwrite the stale function pointer before

executeAction(agent) is called again.

The carved local C2 had getSecret at symbol offset 0x1269, but live

calibration proved the remote C2 layout was shifted. A bounded probe against

the active target found the correct live getSecret page offset at 0x259.

The final solver uses the leaked page base plus 0x259.

Solve

Run the final solver through the harness executor:

bash
cd <local workspace>
python3 scripts/challenge_exec.py Pwn/TicTacToed --phase exploit --output analysis/flag-candidate-rerun.txt -- python3 Pwn/TicTacToed/solve/solve.py --output Pwn/TicTacToed/analysis/flag-candidate-rerun.txt
python3 scripts/challenge_harness.py capture-flag Pwn/TicTacToed --from analysis/flag-candidate-rerun.txt

The solver performs the hidden game sequence, derives the access code at

runtime, leaks the C2 text pointer, performs the UAF function-pointer overwrite,

and writes the captured candidate to analysis/flag-candidate-rerun.txt.

Flag

Raw flag is stored in loot/flag.txt and intentionally not reproduced here.

Lessons

  • Validate CTF exploit chains against live behavior before assuming the local

carved binary and remote child process are byte-identical.

  • For menu-driven UAFs that mix scanf and raw read, send the raw overwrite

only after the read prompt. Pre-buffering binary pointer bytes can be

swallowed by stdio buffering.

  • Keep broad probing bounded. The decisive step here was calibrating against a

known-good printID target, then probing only the plausible shifted

getSecret window.

Source-Backed Dossier

The sections below are merged from companion Markdown notes for the same case. They are rendered after sanitization so the article stays precise without publishing raw flags, credentials, or target-specific secrets.

Notes

Scope

  • Challenge: TicTacToed
  • Category: Pwn
  • Difficulty: Medium
  • Mode: hybrid
  • Remote instance: none
  • Start time: 2026-06-11T13:47:27Z
  • Operator: harness
  • State file: challenge-state.json

Harness Status

  • Current phase: see challenge-state.json
  • Next allowed actions: see next-action.json
  • Raw flags and sensitive material stay in loot/ only. Do not paste them here.

Artifact Inventory

FileSizeSHA256TypeNotes
files/a12c7379-3b7c-4871-89f7-3fae2eae22c7.zip1603456<hash redacted>Zip archive data, at least v1.0 to extract, compression method=storezip entries: 2 shown in artifact inventory JSON

Evidence Ledger

TimeActionOutput/FileFindingConfidenceNext
2026-06-11T13:47:27Zharness initchallenge-state.jsonWorkspace initialized with deterministic state fileHighInventory artifacts
2026-06-11T13:47:38Zartifact inventoryanalysis/artifact-inventory.json1 artifact(s) inventoriedHighBuild or update hypotheses
2026-06-11T13:50:39Zhypothesis recordedhypothesis-board.mdExploit embedded C2 use-after-free: leak PIE via H/User ID, free agent via E/Y, allocate same tcache chunk with F, overwrite stale agent function pointer with getSecret, then let main execute it.MediumInteract with local or remote service to confirm H leaks generateUserID and E/Y followed by F accepts 8 raw bytes before executeAction.
2026-06-11T13:50:39Zhypothesis recordedhypothesis-board.mdResolve the outer Rust tic-tac-toe/access-code gate that drops and executes the embedded C2 executable.MediumCapture remote initial prompts and statically inspect outer validate_access_code/check_winner/decrypt_key to derive the required username/access code or winning board input.
2026-06-11T13:50:40Zinstrumentation plananalysis/instrumentation-plan.mdReach and exploit the embedded C2 menu to call getSecret and read flag.txt.HighStop after two remote attempts without a new fact, or if the remote prompt differs from the carved C2/outer-gate model.
2026-06-11T13:51:16Zcheckpoint recordedanalysis/checkpoint-analysis-20260611T135116391545Z-93872ad1.mdCheckpoint for ANALYSISHighUse checkpoint to drive next decision
2026-06-11T13:57:03Zsource auditanalysis/source-audit.mdSource audit recordedHighGate before exploit
2026-06-11T13:57:16ZRAG queryanalysis/rag/rag-query-20260611T135703679266Z-cc983fbd.txtRAG helper exited 0; output savedMediumRecord retrieval tag and validation
2026-06-11T13:57:52ZRAG recordanalysis/rag-records.mdRetrieved memory tagged GENERICMediumValidate or reject with live evidence
2026-06-11T13:59:07Zlocal memory recordanalysis/local-memory-records.mdPrior local notes reviewed as fallback/advisory contextMediumValidate against current evidence
2026-06-11T13:59:19Zevaluatoranalysis/evaluator-20260611T135919386535Z-b3c84a12.mdProceedHighExecute solve/solve.py through challenge_exec.py, then capture-flag from analysis/flag-candidate.txt.
2026-06-11T21:54:51Zbranch closedhypothesis-board.mdRemote child layout drift invalidated the fixed local generateUserID offset assumption. Solver now derives base from leaked text-page offset instead.HighRerank hypotheses
2026-06-11T21:55:07Zcheckpoint recordedanalysis/checkpoint-analysis-20260611T215507719409Z-cd2cfa04.mdCheckpoint for ANALYSISHighUse checkpoint to drive next decision
2026-06-11T22:34:53Zflag captureloot/flag.txtHTB-format flag captured; raw value kept in loot onlyHighWrite solution and run completion gate
2026-06-11T22:34:53Zremote endpoint updateanalysis/calibrate-printid-newtarget.txtReplacement target <TARGET>:32372 validated; hidden gate, access gate, and UAF overwrite still workHighResolve remote C2 layout drift
2026-06-11T22:34:53Zfocused offset probeanalysis/fast-probe-newtarget-0x220-0x269.logLive getSecret page offset found at 0x259; raw flag captured to analysis candidate then lootHighPatch solver default and rerun
2026-06-11T22:34:53Zsolver rerunanalysis/flag-candidate-rerun.txtFinal solve/solve.py reproduced the flag capture through challenge_exec.pyHighComplete harness state
2026-06-11T22:36:53Zflag captureloot/flag.txtHTB-format flag captured; raw value kept in loot onlyHighWrite solution and run completion gate
2026-06-11T22:36:53Zcompletion gatechallenge-state.jsonCompletion gate passed; state marked COMPLETEHighOptional sanitized memory summary approval

Key Findings

  • Outer Rust binary requires a hidden 5x5 move-history sequence before the operator interface appears.
  • The operator access code is derived by XORing embedded bytes with 0x5a; the solver derives it at runtime.
  • Embedded C2 has a stale global agent pointer after E/Y frees it.
  • Menu option F reclaims the freed chunk and reads 8 raw bytes, allowing a function-pointer overwrite.
  • The replacement remote C2 layout differs from the carved local ELF, so the final solver uses the live-calibrated getSecret page offset 0x259.

RAG / Advisory Memory

RAG output is advisory only. Record evaluated retrievals with:

bash
scripts/challenge_harness.py rag-record <workspace> --query "..." --tag MATCHED|PARTIAL|MISSING|<secret redacted>|GENERIC --validation "..."

Secrets/Flags

Raw flags and sensitive material stay in loot/ only. Use scripts/challenge_harness.py capture-flag to validate and record flag capture without printing the value.

Memory Summary

Metadata

  • Platform: HackTheBox Challenges
  • Category: Pwn
  • Challenge: TicTacToed
  • Difficulty: Medium
  • Source workspace: <local workspace>

Validated Solve Chain

Concepts only. Do not include raw flags, reusable credentials, tokens, cookies, private keys, or live secrets.

  1. The outer Rust tictactoe binary contains a hidden 5x5 move-history gate in check_winner().
  2. obfuscate_pattern() builds the required move history from nine 4-byte fragments: X:00, O:04, X:11, O:13, X:22, O:31, X:33, O:40, X:44.
  3. The access-code material is split into three encrypted arrays. Each decrypt closure XORs bytes with 0x5a; the derived value matches the embedded SHA-256 constant.
  4. A successful credential check calls execute_c2(), which drops and executes an embedded Linux C ELF.
  5. The embedded C2 has a use-after-free primitive: E can free the global agent, and F can reallocate the same tcache-size chunk and write 8 attacker-controlled bytes.
  6. The C2 H menu action leaks a text pointer; compute PIE base from the leaked text-page offset and overwrite the stale function pointer with getSecret.
  7. getSecret() reads flag.txt in the C2 process working directory.

Reusable Lessons

  • Rust wrappers can hide the real pwn target as an embedded child ELF; carve secondary ELF headers and audit both stages.
  • A game win condition may be a hidden move-history regex rather than ordinary board logic.
  • For small C menu binaries, stale global pointers after free() plus same-size malloc() paths are enough for direct function-pointer redirection when a code pointer leak exists.

Dead Ends

  • Normal tic-tac-toe win alone is insufficient because the credential/C2 path is tied to the hidden move-history regex.
  • RAG had no useful TicTacToed-specific memory; the solve chain is validated from current local artifacts.

Tool Quirks

  • Local macOS cannot execute the Linux ELFs directly; validation relied on static disassembly plus one controlled remote run.
  • Docker was installed but the daemon was unavailable during tool readiness, so containerized local execution was not used.

Evidence Paths

  • analysis/binary-audit.md
  • analysis/outer-decrypt-closures.txt
  • analysis/outer-obfuscate-pattern-disasm.txt
  • analysis/outer-check-winner-disasm.txt
  • analysis/outer-main-disasm.txt
  • analysis/outer-credentials-disasm.txt
  • analysis/C2-full-disasm.txt
  • analysis/C2-full-nm.txt
  • analysis/remote-unavailable-20260611.md
  • solve/solve.py

Ingestion Decision

  • Proposed for LightRAG: yes
  • Requires user approval before ingestion: yes

Hypothesis Board

Keep no more than 3 active hypotheses on Easy/Medium and 5 on Hard unless the user explicitly asks for breadth.

RankPathEvidenceMissing ProofCheapest ValidationConfidenceStatus
1Exploit embedded C2 use-after-free: leak PIE via H/User ID, free agent via E/Y, allocate same tcache chunk with F, overwrite stale agent function pointer with getSecret, then let main execute it.Embedded C2 ELF carved at analysis/C2_executable.full; symbols show getSecret=0x1269, generateUserID=0x143c, printID leaks generateUserID with User ID: %p; processInput frees global agent on E and Hackupdate malloc(8)+read(8) can overlap freed agent.Interact with local or remote service to confirm H leaks generateUserID and E/Y followed by F accepts 8 raw bytes before executeAction.MediumActive
2Resolve the outer Rust tic-tac-toe/access-code gate that drops and executes the embedded C2 executable.Outer symbols include detect_debugger, decrypt_key, validate_access_code, ask_for_credentials, check_winner, execute_c2, and embedded C2 path /tmp/C2_executable.Capture remote initial prompts and statically inspect outer validate_access_code/check_winner/decrypt_key to derive the required username/access code or winning board input.MediumActive

Closed Branches

BranchEvidence TestedFailure OutputReason ClosedRevisit Condition
Fixed local generateUserID-offset PIE calculationLive C2 leaks consistently ended with low bits inconsistent with the local carved generateUserID offset; using leak - 0x143c + 0x1269 caused the C2 child to exit without getSecret output.analysis/remote-unavailable-20260611.mdRemote child layout drift invalidated the fixed local generateUserID offset assumption. Solver now derives base from leaked text-page offset instead.Only revisit if a restarted remote leak has low bits matching the local generateUserID offset, or if the patched page-offset calculation fails with a new transcript.

Technical analogy

How to remember this solve

Think of the challenge as a small system with one rule that matters more than the rest. The solve is finding that rule, validating it, and using it carefully enough to reach the final proof.

For TicTacToed, keep the mental model simple: identify the trusted assumption, prove it with the smallest safe test, then automate or repeat only the part that directly leads to the flag.