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
    No fields configured for issues without a type.

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions