◐ Off-By-One · answer catalog

python-security-rule-adversarial-vectors

2 answer(s)godockergodocker

re.compile(r"os.killpg\s(\s[01]\s,", re.S),

📦 Source in repository (JSON)

Answer 1

Root cause: the killpg rule encoded the spec constant literally — [1] (os.killpg(1, …), init) — but POSIX killpg(0, …) signals the calling process's own process group, which is the terminal-jail's own group. The rule never enumerated that sibling vector, so os.killpg(0, …) sailed through as ALLOW. Fix = enumerate the whole killpg family (not just the spec example), apply it in the engine, and keep the aggressive probe as a separate always-run tripwire.

Files (~/terminal-jail-interruptor/):

terminal_jail_interruptor.py — engine with the fixed rule set and a lexical sanitizer:

def build_rules() -> list[Rule]:
    return [
        # [1] extended to [01]: literal own-pgrp (0) or init (1)
        Rule("killpg-own-or-init",
             re.compile(r"os\.killpg\s*\(\s*[01]\s*,", re.S),
             "killpg targeting own process group (0) or init (1) ... [1] extended to [01]"),
        # computed own-pgrp variants
        Rule("killpg-computed-own-pgrp",
             re.compile(r"os\.killpg\s*\(\s*(?:os\.getpgrp\s*\(\s*\)|os\.getpgid\s*\(\s*0\s*\))\s*,", re.S),
             "killpg on computed own process group (getpgrp()/getpgid(0))"),
        # family catch-all: no killpg form escapes (arbitrary pgids, sig-0 probes)
        Rule("killpg-any", re.compile(r"os\.killpg\s*\(", re.S), "any process-group signaling denied"),
        # libc-kill alias family
        Rule("kill-negative-pid",
             re.compile(r"os\.kill\s*\(\s*-\s*(?:1|0|os\.getpgrp\s*\(\s*\))", re.S),
             "negative-pid os.kill: process-group/broadcast kill alias"),
        Rule("kill-negative-pid-any", re.compile(r"os\.kill\s*\(\s*-\s*\d+", re.S), "..."),
        Rule("kill-zero-own-pgrp", re.compile(r"os\.kill\s*\(\s*0\s*,", re.S),
             "os.kill(0, sig) == killpg(getpgrp(), sig): own-group kill"),
        # shell escapes fail closed at the CALL SITE (payload strings untrustworthy)
        Rule("shell-exec-os-system", re.compile(r"os\.system\s*\(", re.S), "..."),
        Rule("shell-exec-subprocess",
             re.compile(r"subprocess\.(?:run|Popen|call|check_call|check_output)\s*\(", re.S), "..."),
    ]

Rules match the executable surface: _sanitize() strips string literals (incl. triple-quoted and escaped quotes) and comments first, so text = "os.killpg(0, sig)" # docstring is ALLOW while os.killpg(f"{0}", sig) still hits the call-structure rule.

battery_main.py — 23 engine verdicts: 10 benign (ALLOW) + 13 adversarial (DENY), now including os.killpg(0, …) so the main battery no longer misses the vector.

probe_killpg.py — the extra 9-vector probe, kept as a separate script (it is the tripwire that outruns spec examples):

PROBE_VECTORS = [
    ("os.killpg(0, signal.SIGKILL)",        DENY, "own pgrp, literal 0"),
    ("os.killpg(1, signal.SIGKILL)",        DENY, "init, literal 1"),
    ("os.killpg(os.getpgrp(), signal.SIGKILL)", DENY, "own pgrp via getpgrp()"),
    ("os.killpg(os.getpgid(0), signal.SIGKILL)", DENY, "own pgrp via getpgid(0)"),
    ("os.killpg(0, 0)",                     DENY, "sig-0 existence probe on own pgrp"),
    ("os.kill(-1, signal.SIGKILL)",         DENY, "broadcast via negative pid"),
    ("os.kill(0, signal.SIGKILL)",          DENY, "kill(0) own-pgrp alias"),
    ("os.kill(-os.getpgrp(), signal.SIGKILL)", DENY, "negative computed pgrp"),
    ("os.killpg(0, signal.SIGINT)",         DENY, "own pgrp, SIGINT variant"),
]

run_all.py — runs both every window (python3 run_all.py; cron: 0 * * * * cd …/terminal-jail-interruptor && python3 run_all.py >> /var/log/jail-battery.log); exits 1 on any escape.

Evidence & signatures

```
$ python3 run_all.py --legacy
main battery: 23 verdicts, 23 pass, 0 fail
killpg probe: 9 vectors, 9 blocked, 0 escaped
legacy [1]-only rule caught 1/9 (the other 8 were the blind spot)
legacy demo (pre-fix behaviour):
  code         : 'os.killpg(0, signal.SIGKILL)  # own process group'
  legacy rule  : matched=False (BLIND SPOT -> ALLOW)
  fixed engine : DENY (rule=killpg-own-or-init)  EXIT=0
```

- **32/32 window checks pass**; both scripts also run standalone (`python3 probe_killpg.py` → exit 0, `python3 battery_main.py` → exit 0).
- **Regression proven:** the legacy `[1]`-only rule demonstrably allows `os.killpg(0, …)`; the fixed engine denies it.
- **Edge cases (26/26 pass):** whitespace-obfuscated `killpg(\n 0,\n …)`; computed `getpgrp()`/`getpgid(0)`; arbitrary pgid `9999`; sig-0 probes; `os.kill(-1)` without a signal arg; negative computed pgrp; shell `os.system`/`subprocess` kill; and false-positive guards — inert `signal.SIGKILL` reference, `"killpg" in dir(os)`, bare `os.killpg` attribute, `os.killpg_myhelper(0, 9)`, payload mentions in strings/comments/docstrings/f-strings/escaped quotes.
{"model": "deepseek-v4-flash", "problem_class": "python-security-rule-adversarial-vectors", "result": "passed", "tests": 32}

Answer 2

Root cause: the killpg rule encoded the spec constant literally — [1] (os.killpg(1, …), init) — but POSIX killpg(0, …) signals the calling process's own process group, which is the terminal-jail's own group. The rule never enumerated that sibling vector, so os.killpg(0, …) sailed through as ALLOW. Fix = enumerate the whole killpg family (not just the spec example), apply it in the engine, and keep the aggressive probe as a separate always-run tripwire.

Files (~/terminal-jail-interruptor/):

terminal_jail_interruptor.py — engine with the fixed rule set and a lexical sanitizer:

def build_rules() -> list[Rule]:
    return [
        # [1] extended to [01]: literal own-pgrp (0) or init (1)
        Rule("killpg-own-or-init",
             re.compile(r"os\.killpg\s*\(\s*[01]\s*,", re.S),
             "killpg targeting own process group (0) or init (1) ... [1] extended to [01]"),
        # computed own-pgrp variants
        Rule("killpg-computed-own-pgrp",
             re.compile(r"os\.killpg\s*\(\s*(?:os\.getpgrp\s*\(\s*\)|os\.getpgid\s*\(\s*0\s*\))\s*,", re.S),
             "killpg on computed own process group (getpgrp()/getpgid(0))"),
        # family catch-all: no killpg form escapes (arbitrary pgids, sig-0 probes)
        Rule("killpg-any", re.compile(r"os\.killpg\s*\(", re.S), "any process-group signaling denied"),
        # libc-kill alias family
        Rule("kill-negative-pid",
             re.compile(r"os\.kill\s*\(\s*-\s*(?:1|0|os\.getpgrp\s*\(\s*\))", re.S),
             "negative-pid os.kill: process-group/broadcast kill alias"),
        Rule("kill-negative-pid-any", re.compile(r"os\.kill\s*\(\s*-\s*\d+", re.S), "..."),
        Rule("kill-zero-own-pgrp", re.compile(r"os\.kill\s*\(\s*0\s*,", re.S),
             "os.kill(0, sig) == killpg(getpgrp(), sig): own-group kill"),
        # shell escapes fail closed at the CALL SITE (payload strings untrustworthy)
        Rule("shell-exec-os-system", re.compile(r"os\.system\s*\(", re.S), "..."),
        Rule("shell-exec-subprocess",
             re.compile(r"subprocess\.(?:run|Popen|call|check_call|check_output)\s*\(", re.S), "..."),
    ]

Rules match the executable surface: _sanitize() strips string literals (incl. triple-quoted and escaped quotes) and comments first, so text = "os.killpg(0, sig)" # docstring is ALLOW while os.killpg(f"{0}", sig) still hits the call-structure rule.

battery_main.py — 23 engine verdicts: 10 benign (ALLOW) + 13 adversarial (DENY), now including os.killpg(0, …) so the main battery no longer misses the vector.

probe_killpg.py — the extra 9-vector probe, kept as a separate script (it is the tripwire that outruns spec examples):

PROBE_VECTORS = [
    ("os.killpg(0, signal.SIGKILL)",        DENY, "own pgrp, literal 0"),
    ("os.killpg(1, signal.SIGKILL)",        DENY, "init, literal 1"),
    ("os.killpg(os.getpgrp(), signal.SIGKILL)", DENY, "own pgrp via getpgrp()"),
    ("os.killpg(os.getpgid(0), signal.SIGKILL)", DENY, "own pgrp via getpgid(0)"),
    ("os.killpg(0, 0)",                     DENY, "sig-0 existence probe on own pgrp"),
    ("os.kill(-1, signal.SIGKILL)",         DENY, "broadcast via negative pid"),
    ("os.kill(0, signal.SIGKILL)",          DENY, "kill(0) own-pgrp alias"),
    ("os.kill(-os.getpgrp(), signal.SIGKILL)", DENY, "negative computed pgrp"),
    ("os.killpg(0, signal.SIGINT)",         DENY, "own pgrp, SIGINT variant"),
]

run_all.py — runs both every window (python3 run_all.py; cron: 0 * * * * cd …/terminal-jail-interruptor && python3 run_all.py >> /var/log/jail-battery.log); exits 1 on any escape.

Evidence & signatures

```
$ python3 run_all.py --legacy
main battery: 23 verdicts, 23 pass, 0 fail
killpg probe: 9 vectors, 9 blocked, 0 escaped
legacy [1]-only rule caught 1/9 (the other 8 were the blind spot)
legacy demo (pre-fix behaviour):
  code         : 'os.killpg(0, signal.SIGKILL)  # own process group'
  legacy rule  : matched=False (BLIND SPOT -> ALLOW)
  fixed engine : DENY (rule=killpg-own-or-init)  EXIT=0
```

- **32/32 window checks pass**; both scripts also run standalone (`python3 probe_killpg.py` → exit 0, `python3 battery_main.py` → exit 0).
- **Regression proven:** the legacy `[1]`-only rule demonstrably allows `os.killpg(0, …)`; the fixed engine denies it.
- **Edge cases (26/26 pass):** whitespace-obfuscated `killpg(\n 0,\n …)`; computed `getpgrp()`/`getpgid(0)`; arbitrary pgid `9999`; sig-0 probes; `os.kill(-1)` without a signal arg; negative computed pgrp; shell `os.system`/`subprocess` kill; and false-positive guards — inert `signal.SIGKILL` reference, `"killpg" in dir(os)`, bare `os.killpg` attribute, `os.killpg_myhelper(0, 9)`, payload mentions in strings/comments/docstrings/f-strings/escaped quotes.
{"model": "deepseek-v4-flash", "problem_class": "python-security-rule-adversarial-vectors", "result": "passed", "tests": 32}
Generated from the verified corpus · MIT licensedBack to the catalog