Retrieved article excerpt
Open article · Retrieved 2026-09-16T16:22:27.349288+00:00
# The red team: two hundred and thirty-one attacks, and the five that worked
`validate/redteam.py` is a script that attacks Taper. Every case in it is
something that must be refused, and the script exits non-zero if any of them is
allowed, so it runs in CI on every push and gates every release. This document
is what it does, what it found, and what it cannot tell you.
Run it yourself:
```
git clone https://github.com/Walex4/taper.git && cd taper
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
python validate/redteam.py
```
(The harness ships with the repository, not the PyPI package — it attacks the
source you can read, not a wheel.)
It takes about ten seconds and prints one line per attack.
## Why it is a separate thing from the test suite
The unit tests check that the code does what I meant. The red team checks that
the system refuses what someone else meant. Those are different questions, and
a suite that only asks the first one tends to pass.
There is a second reason. Attacks invented by the author of a defence are the
attacks the defence already handles, because the same head produced both. So
wherever a published bypass exists, the harness uses that payload rather than
one of mine — the pgAdmin CVE-2026-17351 string, the git-shell option
injection, the rsync `-e`, the double-encoded traversal. The point is to be
attacked by people who were not thinking about Taper when they wrote the attack.
## What it throws
Two hundred and thirty-one cases in seventeen sections (one hundred and fifteen at v0.3.0, eighty-one at v0.2.1, fifty-nine at v0.1.1). The count is the count on this commit; it
goes up when adapters are added, and the number is not the claim.
| section | cases | what is being tested |
| --- | --- | --- |
| 1. Shell injection | 10 | `; rm -rf /`, backticks, `$(…)`, newlines, pipes, redirects, `--upload-pack=sh`, `-e/bin/sh`, `--output=…/authorized_keys` — as an `ssh.exec` argument |
| 2. Option smuggling in the program slot | 6 | `bash`, `sh`, `/bin/sh`, `git;bash`, `../../bin/bash`, `ssh` as the program |
| 3. Host escape | 5 | a host outside the grant, `host:2222`, two hosts in one string, `#` comments, `-oProxyCommand=sh` |
| 4. Extra fields | 4 | `shell`, `env=LD_PRELOAD`, `ProxyCommand`, `args_` — fields the schema does not know about |
| 5. SQL | 9 | DDL, a write under a select-only grant, `COPY … FROM PROGRAM`, `DO $$`, stacked statements, the real pgAdmin payload, a table outside the grant, `pg_read_file`, `dblink` |
| 5b. `pg.describe` | 9 | a table outside the grant, `pg_catalog.pg_shadow`, an unqualified name, injection in the name, a trailing newline, a three-part name, an extra field, the wrong database; and one positive check — the permitted request binds the name as a parameter and runs read-only |
| 6. HTTP | 5 | `/v1/../../admin`, wrong host, method escalation, path outside prefix, header injection |
| 7. Token attacks | 17 | widen a host or add a program during attenuation; a forged widening block, with strict verification on and off; splice a block from another chain; edit an existing block; extend TTL past the parent; replay an expired token; use a child of a revoked parent; mint a sibling from a received token; a token from a different root; six malformed strings |
| 7b. The subject | 5 | a child block claiming another subject; the root subject rewritten; the root subject stripped; a child repeating the root's subject; Alice's child spliced under Bob's root — every one refused, and none reaches the log with a subject |
| 9. The tower | 8 | a decision claiming allow for a chain from another root; a good chain with no proof; a proof for a different request; a broker lying about which chain the decision is about; a decision naming a different subject; an expired chain; a clearance's material taken twice; the tape intact through all of it — nothing but a verified decision mints a credential |
| 10. Declared operations | 34 | at load: a free field after `-c` or `--command`; `bash` or `sudo` as the program; the program from a field; a field inside a literal; two fields in one element; an optional field right after a flag; a literal with `&&` in it; shadowing a built-in; a SQL statement with a field written into it; two statements; a path segment with a slash; an HTTP method from a field; no `layer2` key. At decision: ten values for a name field — `;id`, a space, `$(id)`, backticks, a pipe, a newline, a quote, `{namespace}`, `../`, `--all-namespaces` — each inexpressible; a resource outside the enum; a namespace outside the grant; an unknown field; a wrong type. The definition: the file edited after the grant; a grant that never committed; a child block carrying its own definitions. Plus the honest request, allowed with argv exactly the template, and the tape intact |
| 11. Tower for SSH and AWS | 25 | SSH: the honest request is cleared; the certificate pins the shim to this request's hash; a different argument list is a different hash; no extensions; this host as a principal; sixty seconds; material taken twice; a certificate with its force-command edited; a certificate from another CA. The shim: the named request runs; three other requests under the same clearance are refused. AWS: cleared with a session; the policy names this bucket and prefix only; another bucket, a prefix outside the grant, a wildcard bucket, and a `../` prefix never reach STS; an action wildcard, a resource wildcard, a placeholder outside the resource part and a user ARN as the role are refused at load; a policy with no resource; both tapes intact |
| 12. The root of trust | 7 | both keys verify during a rotation; a chain signed by a retired root, and a child of one; a chain wearing a trusted kid but signed by another key; a child block naming a root key; a signer answering for a key it was not named for, caught at mint; an unnamed root against a trust set of several, where the verifier does not guess |
| 13. SPIFFE | 20 | the attested workload is allowed, and then: no SVID at all; an SVID from a CA the bundle does not hold; a genuine SVID for a workload outside the pattern; the right certificate signed by a key that is not its own; an attestation made for a different request; the same attestation twice; a timestamp an hour old; an expired SVID; a child block naming its own workload; the root's workload rewritten; a broker with no trust bundle refusing rather than ignoring; six malformed SPIFFE IDs; the tape intact |
| 14. The identity provider | 28 | `alg: none`; HS256 signed with the provider's own public key as the shared secret; a token signed by another RSA key; an empty signature on a real algorithm; a `kid` outside the pinned set, which is refused rather than tried against every key; another issuer; another audience; an expired token; a token dated in the future; a group nobody mapped; no subject claim; a subject with a newline in it. Then the mapping: a dev's token yields the dev policy and its one-hour ceiling; membership in two groups takes the first rule in file order rather than the widest; the same token cannot mint twice; a token with no `jti` is still spent once by its hash; the seen-file holds neither token nor `jti` and is 0600; the record names the issuer, the person and the rule and carries no token and no other claim; an http issuer, an empty rule set, a workload that is not a SPIFFE id and two unknown keys are all refused at load; a symmetric key in the key set is not a signing key; and no HMAC or `none` algorithm exists in the table to be selected |
| 15. Tower stage 2 | 36 | The plan: a broker plan naming a statement nobody asked for; a plan edited after the decision; a plan pointing at another vault entry; a decision with no plan; a request outside the grant the broker allowed anyway; a request the schema refuses — each refused before the CA is touched, and the honest one cleared with `plan_checked` on the tape. The socket: an unknown call; a `take` with a non-string id; an undecodable plan; an extra field; an empty revocation id; a uid the tower does not know, refused before its request is parsed; and an unreachable tower answering "I have an SSH CA" so the plan fails closed instead of quietly using the vault identity. Holds: a held operation minting nothing while it waits; a release spent by the request that used it; a release for one request not covering another; a key that is per request and per token rather than per operation; a denied hold that cannot be released afterwards; six malformed policy files; and the asker's own uid refused `holds`, `release` and `deny` |
| 8. Audit integrity | 3 | the hash chain is intact after the run; every denial above was recorded; deleting a record is detected |
Sections 1 through 4 share one property that matters more than any individual
case: the attack is supposed to die in field validation, before policy is
consulted. The SSH adapter builds `argv` directly and never assembles a shell
string, and its argument pattern is `^[A-Za-z0-9@%_+=:,./\-]{0,4096}\Z`. A
semicolon cannot be represented in a request. The failure mode is not
"refused," it is "inexpressible," which is the stronger property because it
does not depend on the refusal logic being complete.
Section 7 is the one that would embarrass me most if it failed, because the
token is the part with a literature. The construction is Biscuit's (credited in
`DESIGN.md`), and the attacks are the ones Biscuit's own documentation warns
about: a block that claims more than its parent, a block moved between chains,
a chain re-signed by the wrong key. Attenuation is intersection over typed
constraints, so a widening block is rejected structurally — there is no policy
that could accidentally accept it.
## The five that got through
On its first complete run the harness found four live bypasses. All four were
in the adapters, none in the token or the broker, and all four are the same
shape of mistake: a classifier that looked at what a request *started with*
rather than what it *was*. They were fixed before the repository's public
history begins, so there is no "before" commit to link; the regression tests
in `tests/test_taper.py`, under the comment `regressions found by validate/redteam.py`, are the record.
The fifth came on 16 September 2026, the day Tower's AWS stage was written:
section 11's "a resource wildcard" case loaded a declaration whose `aws`
block named `arn:aws:s3:::*` as the resource, which would have made every
session the role's whole reach. The loader had refused `*` on its own and an
action wildcard, and not an ARN whose resource part was only a wildcard. It
was fixed in the same commit, with the case left in place; the regression is
`TestDeclared::test_an_aws_resource_wildcard_is_refused_at_load`.
### 1. Stacked statements classified as a select
```
SELECT 1; DROP TABLE public.events
```
The classifier matched the leading keyword. This starts with `SELECT`, so it
was a select, so a select-only grant permitted it. The database would then have
been handed two statements.
**Fix.** `classify()` counts statements before it looks at keywords, and
anything with more than one is `multi`, a kind no policy can grant. The
docstring now says "order is the entire point," because it is.
Pinned by `test_stacked_statements_do_not_classify_as_select`.
### 2. The pgAdmin payload
```
SELECT 'a\'; COMMIT; DROP TABLE public.events; --
```
This is the CVE-2026-17351 shape. pgAdmin wrapped AI Assistant queries in
`BEGIN TRANSACTION READ ONLY` and used Python's `sqlparse` to confirm there was
one statement. Under PostgreSQL's default `standard_conforming_strings = on`, a
backslash before a quote is a literal character; `sqlparse` treats it as an
escape. So `sqlparse` saw one string containing a semicolon, and PostgreSQL saw
a string, a `COMMIT`, and a `DROP`. The `COMMIT` walked out of the read-only
wrapper.
Taper's statement counter had the same disagreement with PostgreSQL, and the
payload went through as one select.
*