Skip to content

Docker Host Flag Precedence Bypass #2496

@fnando

Description

@fnando

001: Docker Host Flag Precedence Bypass

Date: 2026-04-17
Severity: Medium
Impact: Docker API endpoint hijack
Subsystem: container
Final review by: gpt-5.4, high

Summary

stellar container accepts --docker-host as an explicit override, but its TCP/HTTP connection path drops that resolved value and calls Bollard's environment-driven default helper instead. When both --docker-host and DOCKER_HOST are set, start, stop, and logs can be silently redirected to the environment's Docker API endpoint rather than the operator's command-line target.

Root Cause

Args::connect_to_docker() correctly resolves the effective host into a local host variable, but the TCP/HTTP match arm uses Docker::connect_with_http_defaults() rather than Docker::connect_with_http(&h, ...). In Bollard 0.20.2, connect_with_http_defaults() re-reads DOCKER_HOST from the environment, so the explicit CLI flag's precedence is lost after argument parsing.

Reproduction

During normal operation, a user can invoke any stellar container subcommand with an explicit TCP --docker-host while inheriting a different DOCKER_HOST value from their shell, wrapper script, CI job, or other launch environment. The CLI then sends Docker API traffic to the environment-selected endpoint, even though its fallback warning still prints the explicit flag value.

Affected Code

  • stellar-cli/cmd/soroban-cli/src/commands/container/shared.rs:52-105 — resolves host, then ignores it for TCP/HTTP by calling Docker::connect_with_http_defaults()
  • stellar-cli/cmd/soroban-cli/src/commands/container/start.rs:88-92container start uses the shared connection helper
  • stellar-cli/cmd/soroban-cli/src/commands/container/stop.rs:39container stop uses the shared connection helper
  • stellar-cli/cmd/soroban-cli/src/commands/container/logs.rs:34container logs uses the shared connection helper
  • /root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/bollard-0.20.2/src/docker.rs:567-590 — the default HTTP helper re-reads DOCKER_HOST

PoC

  • Target test file: stellar-cli/poc/docker-host-flag-bypass
  • Test name: docker-host-flag-bypass
  • Test language: bash
  • How to run:
    1. Run cargo build from the repo root.
    2. Copy the script below to poc/docker-host-flag-bypass.
    3. Run: bash poc/docker-host-flag-bypass

Test Body

#!/usr/bin/env bash
set -uo pipefail

STELLAR="$(pwd)/target/debug/stellar"

FLAG_PORT=19999
ENVVAR_PORT=29999

echo "=== PoC: Docker Host Flag Precedence Bypass ==="
echo "DOCKER_HOST env var: tcp://127.0.0.1:$ENVVAR_PORT"
echo "--docker-host flag:  tcp://127.0.0.1:$FLAG_PORT"
echo ""

TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"; kill $FLAG_PID $ENVVAR_PID 2>/dev/null' EXIT

python3 -c "
import socket, sys
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', int(sys.argv[1])))
s.listen(1)
s.settimeout(10)
try:
    conn, addr = s.accept()
    data = conn.recv(4096)
    with open(sys.argv[2], 'wb') as f:
        f.write(data)
    conn.close()
except socket.timeout:
    pass
s.close()
" $FLAG_PORT "$TMPDIR/flag_conn.log" &
FLAG_PID=$!

python3 -c "
import socket, sys
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', int(sys.argv[1])))
s.listen(1)
s.settimeout(10)
try:
    conn, addr = s.accept()
    data = conn.recv(4096)
    with open(sys.argv[2], 'wb') as f:
        f.write(data)
    conn.close()
except socket.timeout:
    pass
s.close()
" $ENVVAR_PORT "$TMPDIR/envvar_conn.log" &
ENVVAR_PID=$!

sleep 1

echo "Running: DOCKER_HOST=tcp://127.0.0.1:$ENVVAR_PORT stellar container start local --docker-host tcp://127.0.0.1:$FLAG_PORT"
echo ""

DOCKER_HOST="tcp://127.0.0.1:$ENVVAR_PORT" \
  "$STELLAR" container start local --docker-host "tcp://127.0.0.1:$FLAG_PORT" 2>&1 || true

sleep 2

echo ""
echo "=== Connection Results ==="

FLAG_DATA=""
ENVVAR_DATA=""

if [ -f "$TMPDIR/flag_conn.log" ] && [ -s "$TMPDIR/flag_conn.log" ]; then
    FLAG_DATA=$(cat "$TMPDIR/flag_conn.log" | head -c 200)
    echo "Flag port ($FLAG_PORT) received data: YES"
    echo "  Data: $FLAG_DATA"
else
    echo "Flag port ($FLAG_PORT) received data: NO"
fi

if [ -f "$TMPDIR/envvar_conn.log" ] && [ -s "$TMPDIR/envvar_conn.log" ]; then
    ENVVAR_DATA=$(cat "$TMPDIR/envvar_conn.log" | head -c 200)
    echo "Env var port ($ENVVAR_PORT) received data: YES"
    echo "  Data: $ENVVAR_DATA"
else
    echo "Env var port ($ENVVAR_PORT) received data: NO"
fi

echo ""

if [ -n "$ENVVAR_DATA" ] && [ -z "$FLAG_DATA" ]; then
    echo "=== FINDING CONFIRMED ==="
    echo "The CLI connected to port $ENVVAR_PORT (DOCKER_HOST env var) instead of"
    echo "port $FLAG_PORT (--docker-host flag). The explicit flag is ignored for TCP."
    exit 0
elif [ -n "$FLAG_DATA" ] && [ -z "$ENVVAR_DATA" ]; then
    echo "=== FINDING NOT CONFIRMED ==="
    echo "The CLI correctly used the --docker-host flag (port $FLAG_PORT)."
    exit 1
elif [ -n "$FLAG_DATA" ] && [ -n "$ENVVAR_DATA" ]; then
    echo "=== BOTH PORTS CONTACTED ==="
    echo "Both ports received connections."
    exit 2
else
    echo "=== INCONCLUSIVE ==="
    echo "Neither port received a connection."
    exit 2
fi

Expected vs Actual Behavior

  • Expected: A TCP/HTTP --docker-host flag should override any inherited DOCKER_HOST value, so Docker API traffic goes only to the operator-selected endpoint.
  • Actual: The CLI sends Docker API traffic to the DOCKER_HOST environment endpoint while user-facing diagnostics still refer to the explicit flag value.

Adversarial Review

  1. Exercises claimed bug: YES — the reproduced request hit only the environment-selected listener (127.0.0.1:29999) and not the flag-selected listener (127.0.0.1:19999).
  2. Realistic preconditions: YES — inherited environment variables are common in shells, wrapper scripts, and CI; the PoC uses only the public CLI surface.
  3. Bug vs by-design: BUG — the help text describes --docker-host as an override, and the Unix/named-pipe branches already honor the resolved explicit value.
  4. Final severity: Medium — this can silently redirect container lifecycle actions to an attacker-chosen Docker API endpoint, but it still requires control of the launched process environment.
  5. In scope: YES — no privileged machine access is required.
  6. Test correctness: CORRECT — separate listeners on distinct ports make the destination unambiguous, and the observed GET /version request is the real Docker connectivity probe from check_docker_connection().
  7. Alternative explanations: NONE
  8. Novelty: NOVEL

Suggested Fix

Replace Docker::connect_with_http_defaults() with Docker::connect_with_http(&h, DEFAULT_TIMEOUT, API_DEFAULT_VERSION) in the TCP/HTTP branch so the already-resolved host value is passed through consistently.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Backlog (Not Ready)

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions