Skip to content

[BUG / possible BUG] ipv6-proxy-support-broken-by-check-proxy-typo #3095

Description

@ericcgu

General remark

Summary. If you point testssl at an HTTP proxy and the proxy turns out to be IPv6, the scan silently fails. You get no useful error; just an openssl connection error that looks like the proxy is down or the target is unreachable. Nothing in the error mentions IPv6, the proxy, or testssl.

Root cause. check_proxy() mangles the OpenSSL version comparison for IPv6 proxies with a stray $ inside the parameter expansion. At testssl.sh:23437 and testssl.sh:23450 the script writes ${OSSL_VER_MAJOR$}${OSSL_VER_MINOR} instead of ${OSSL_VER_MAJOR}${OSSL_VER_MINOR}. Bash reports bad substitution on stderr and; because the fault is a parse-time expansion error inside a function body; aborts the rest of check_proxy() at that line. Neither the PROXYIP="[$PROXYNODE]" branch nor the fatal_cmd_line "OpenSSL version >= 1.1.0 required..." branch executes, and the trailing PROXY="-proxy $PROXYIP:$PROXYPORT" assignment on line 23457 also never runs. The function returns with PROXYIP unset and PROXY still holding the raw [addr]:port string from line 23422 rather than a valid -proxy [addr]:port argument. Downstream openssl calls then fail with an argument-parse error whose message doesn't mention proxies, so the operator has no signal that testssl silently gave up on their --proxy flag.

The [[ -z "$PROXYIP" ]] guard at line 23455 does not save this case: it lives in the DNS-hostname else branch, not the literal-IPv6 branch, so it can't backstop this exit path even in principle.

This bites two groups of users:

  • People who typed an IPv6 proxy address on purpose. Example: --proxy='[2001:db8::1]:8080'. Uncommon today, but growing as networks go IPv6-only.
  • People who typed a hostname proxy that happens to resolve to IPv6. Example: --proxy=proxy.company.example:8080 where that hostname has no IPv4 (A) record and only an IPv6 (AAAA) record. These users don't think they're using IPv6 at all. testssl silently prefers the AAAA record when the A record is missing, and lands them in the same broken code path. Easy to hit accidentally during an IPv4-to-IPv6 migration or on IPv6-only cloud networks.

Because the resulting openssl error mentions neither IPv6 nor the proxy, operators blame their proxy team or the scan target and file tickets there. The report almost never reaches the testssl maintainers, which is why this bug is hard to catch.

Impact --proxy. --proxy=host:port routes all testssl outbound scans through an HTTP CONNECT proxy instead of connecting directly (see doc/testssl.1.md:130). The literal-IPv6 form this bug breaks is much rarer but is becoming more common. When someone does hit it with ipv6 only, they hit it every time.

Before you open an issue please check which version you are running and whether it is the latest in stable / dev branch

  • I am running version (git log | head -1 ) from the git repo; 3.3dev, current HEAD
  • I am running testssl.sh -v | grep from; 3.3dev

Before you open an issue please consult the FAQ and check whether this is a known problem by searching the issues

  • Is related to ...
  • couldn't find anything

Command line to reproduce (or docker command)

./testssl.sh --proxy='[2001:db8::1]:8080' example.com

Exact source at testssl.sh:23431-23457:

          elif is_ipv6addr "$PROXYNODE"; then
               # Maybe an option like --proxy6 is better for purists
               if [[ "$OSSL_NAME" =~ LibreSSL ]]; then
                    PROXYIP="$PROXYNODE"
               else
                    # This was tested with vanilla OpenSSL versions
                    if [[ ${OSSL_VER_MAJOR$}${OSSL_VER_MINOR} -ge 11 ]]; then    # <-- line 23437: stray $
                         PROXYIP="[$PROXYNODE]"
                    else
                         fatal_cmd_line "OpenSSL version >= 1.1.0 required for IPv6 proxy support" $ERR_OSSLBIN
                    fi
               fi
          else
               # We check now preferred whether there was an IPv4 proxy via DNS specified
               ...
               PROXYIP="$(get_a_record "$PROXYNODE" 2>/dev/null | grep -v alias | sed 's/^.*address //')"
               if [[ -z "$PROXYIP" ]]; then
                    PROXYIP="$(get_aaaa_record "$PROXYNODE" 2>/dev/null | grep -v alias | sed 's/^.*address //')"
                    if [[ -n "$PROXYIP" ]]; then
                         if [[ ${OSSL_VER_MAJOR$}${OSSL_VER_MINOR} -lt 11 ]]; then   # <-- line 23450: stray $
                              fatal_cmd_line "OpenSSL version >= 1.1.0 required for IPv6 proxy support" $ERR_OSSLBIN
                         fi
                    fi
               fi
               [[ -z "$PROXYIP" ]] && fatal "Proxy IP cannot be determined from \"$PROXYNODE\"" $ERR_CMDLINE
          fi
          PROXY="-proxy $PROXYIP:$PROXYPORT"

The intent; visible elsewhere in the file, e.g. testssl.sh:4793, testssl.sh:7327, testssl.sh:21460, testssl.sh:21590; is to compare against a version threshold. Everywhere else the codebase uses $OSSL_VER_MAJOR.$OSSL_VER_MINOR (dotted string, matched by glob) or plain $OSSL_VER_MAJOR. These two lines are the only ones that try to concatenate the digits into "11" for a numeric -ge 11; and both mistype the expansion.

Fix; lines 23437 and 23450:

                    if [[ ${OSSL_VER_MAJOR}${OSSL_VER_MINOR} -ge 11 ]]; then
                         if [[ ${OSSL_VER_MAJOR}${OSSL_VER_MINOR} -lt 11 ]]; then

Or, matching the idiom used everywhere else in the file for OpenSSL >= 1.1 gates (see lines 4793-4794, 7327, 21460):

                    if [[ $OSSL_VER_MAJOR -ge 3 ]] || [[ "$OSSL_VER_MAJOR.$OSSL_VER_MINOR" == 1.1.* ]]; then

The concatenation trick ${OSSL_VER_MAJOR}${OSSL_VER_MINOR} -ge 11 was invented locally at these two lines and typoed on both. Nowhere else in testssl.sh does that pattern appear; adopting the file-wide dotted-glob idiom eliminates the ad-hoc arithmetic (which quietly assumes OSSL_VER_MINOR is a single digit) and makes future greps for OpenSSL-version checks find these two sites too.

Reproduction (isolated)

cat > /tmp/repro.sh <<'EOF'
#!/usr/bin/env bash
OSSL_VER_MAJOR=1
OSSL_VER_MINOR=1
OSSL_NAME="OpenSSL"

# Verbatim: PROXY parsing from check_proxy()
PROXY="[2001:db8::1]:8080"
PROXYPORT="${PROXY##*:}"
PROXYNODE="${PROXY%:*}"
PROXYNODE="${PROXYNODE/\[/}"
PROXYNODE="${PROXYNODE/\]/}"

is_ipv4addr() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; }
is_ipv6addr() { [[ "$1" == *:* ]]; }

if is_ipv4addr "$PROXYNODE"; then
    PROXYIP="$PROXYNODE"
elif is_ipv6addr "$PROXYNODE"; then
    if [[ "$OSSL_NAME" =~ LibreSSL ]]; then
        PROXYIP="$PROXYNODE"
    else
        # Verbatim from testssl.sh:23437
        if [[ ${OSSL_VER_MAJOR$}${OSSL_VER_MINOR} -ge 11 ]]; then
            PROXYIP="[$PROXYNODE]"
        else
            echo "FATAL: openssl >= 1.1 required" >&2; exit 1
        fi
    fi
fi
PROXY="-proxy $PROXYIP:$PROXYPORT"
echo "PROXY=[$PROXY]"
echo "PROXYIP=[$PROXYIP]"
EOF
bash /tmp/repro.sh

Output (bash 3.2, top level):

/tmp/repro.sh: line 23: ${OSSL_VER_MAJOR$}${OSSL_VER_MINOR}: bad substitution
PROXY=[-proxy :8080]
PROXYIP=[]

Note: at top level, execution does continue past the broken if and the final PROXY= assignment runs with an empty PROXYIP. Inside a function body (as check_proxy() actually is in testssl.sh) bash 3.2 aborts the whole function at the bad-substitution line, so the final PROXY="-proxy $PROXYIP:$PROXYPORT" line at 23457 never runs at all; PROXY retains the raw [addr]:port form set at 23422. Either way, no valid -proxy argument reaches openssl; the exact failure openssl reports differs by version but is never the intended "OpenSSL >= 1.1.0 required" fatal.

To reproduce the in-function behavior, wrap the reproducer in check_proxy() { ...; }; check_proxy.

Expected behavior

With OpenSSL >= 1.1 and --proxy='[2001:db8::1]:8080', check_proxy should set PROXYIP="[2001:db8::1]" and PROXY="-proxy [2001:db8::1]:8080". Instead the [[ ]] version test triggers bad substitution, which aborts the rest of check_proxy() at line 23437; no branch of the version-check runs, and the PROXY="-proxy ..." rebuild at line 23457 also never runs. The function returns leaving PROXY in the raw [addr]:port form from earlier parsing. Downstream openssl calls then fail with an argument-parse error that mentions neither IPv6 nor proxies, so operators see something that looks like a connection failure rather than a testssl bug. On a truly-old OpenSSL (< 1.1), the intended "OpenSSL version >= 1.1.0 required" fatal never fires either; same abort mechanism.

Your system (please complete the following information):
; OS: macOS 26.5.1 (Build 25F80)
; Platform: Darwin 25.5.0 arm64
; OpenSSL + bash: OpenSSL 3.6.2 (7 Apr 2026) / GNU bash 3.2.57(1)-release (arm64-apple-darwin25)

Additional context

Also affects the DNS-resolved IPv6 proxy branch at testssl.sh:23450. There the fault is less damaging (only suppresses the "OpenSSL too old" fatal on a truly old OpenSSL), but it is the same typo and should be fixed together.

Why this bug survived. The test suite at t/*.t has no coverage for --proxy at all; grep -rn '\-\-proxy' t/ returns zero matches, so check_proxy() has never been exercised by CI. The only IPv6 test file, 11_baseline_ipv6_http.t, is disabled (renamed to .DISABLED so the runner skips it), which means the two overlapping preconditions to reach this bug; IPv6 addressing and a --proxy argument; have zero coverage between them. Manual users who do trigger the bug get one bash: line 23437: bad substitution line on stderr, easily lost among testssl's banner and rating output (and often silently dropped by CI wrappers that only capture stdout), and typically attribute the resulting openssl failure to their proxy or target rather than to a shell typo in testssl. But the bad expansion fires unconditionally on every --proxy=[IPv6]:port invocation on non-LibreSSL, so anyone using that feature hits it 100% of the time.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions