Skip to content
DevOps AI ToolKit
Newsletter

Kali Linux on Docker · Part 12 of 16

TLS and Certificate Troubleshooting From Kali Docker

Difficulty: Intermediate ~16 min Part 12/16
Prerequisites: Kali Linux fundamentalsBasic Docker knowledge
Series progress12 / 16
Series curriculum (16 lessons)

“It works on my machine” has a TLS cousin: “it works in my browser but the service call fails.” Both clients are hitting the same HTTPS endpoint, yet one trusts the certificate and the other rejects it. A disposable Kali container is the perfect place to find out why, because it gives you openssl and curl in a clean, reproducible environment with no local trust-store surprises to muddy the diagnosis.

This lesson teaches you to read a certificate the way TLS clients do: subject, Subject Alternative Names, issuer, validity dates, and — the part that trips up most teams — the certificate chain.

🛠️ DevOps Perspective — Almost every TLS incident is really an expected state vs observed state problem. You expect a valid, trusted, in-date certificate whose name matches the host, served with a complete chain. The tools below let you observe exactly what the server actually presents, so you can compare the two and fix the gap instead of guessing.

Only inspect, scan, or test systems you own or have explicit permission to assess. The examples here use your own local lab services and the public, inspection-friendly endpoint example.com.

Start a disposable Kali container

docker run --rm -it --name kali kali-devops-toolbox
  • --rm removes the container when you exit, so every investigation starts from a clean, reproducible state.
  • -i keeps STDIN open and -t allocates a TTY, giving you an interactive shell.
  • --name kali names the container for easy reference.
  • kali-devops-toolbox is the custom image built earlier in this series; substitute kali-rolling if you have not built it yet. If openssl or curl is missing, install with apt-get update && apt-get install -y --no-install-recommends openssl curl.

A fresh container matters here: its CA trust store is the stock Debian/Kali bundle with nothing hand-added, so a “trusted” result means the chain genuinely validates — not that someone once clicked “accept” on this laptop.

The TLS handshake, briefly

Before any HTTPS bytes flow, the client and server run a handshake:

  1. The client sends ClientHello, including the SNI (Server Name Indication) — the hostname it wants to talk to.
  2. The server picks a certificate for that hostname and sends it, ideally with any intermediate certificates needed to link it back to a trusted root.
  3. The client validates the chain against its trust store, checks the certificate is in-date, and checks the hostname matches the certificate’s SAN.
  4. Keys are agreed and the encrypted session begins.

Three of those steps are where things break: the server sends an incomplete chain (step 2), the cert is expired (step 3), or the hostname does not match (step 3). Everything below is about observing each one directly.

Command 1: openssl s_client — watch the handshake

openssl s_client is a raw TLS client. It performs the handshake and dumps everything the server presents, which makes it the single most useful TLS diagnostic you have.

openssl s_client -connect example.com:443 -servername example.com </dev/null
  • openssl s_client opens a TLS connection and prints handshake details.
  • -connect example.com:443 is the host and port to dial. TLS for HTTPS lives on 443.
  • -servername example.com sets the SNI hostname in the ClientHello. On shared infrastructure the server uses this to decide which certificate to hand back — omit it and you may get a default cert (or an error) instead of the one you actually want to inspect.
  • </dev/null feeds EOF on STDIN so s_client completes the handshake, prints its report, and exits instead of hanging waiting for you to type an HTTP request.

Two parts of the output matter most. First, the certificate chain near the top:

Certificate chain
 0 s:CN=example.com
   i:C=US, O=DigiCert Inc, CN=DigiCert Global G3 TLS ECC SHA384 2020 CA1
 1 s:C=US, O=DigiCert Inc, CN=DigiCert Global G3 TLS ECC SHA384 2020 CA1
   i:C=US, O=DigiCert Inc, CN=DigiCert Global Root G3

Each line pairs a subject (s:) with its issuer (i:). Entry 0 is the leaf (the server’s own certificate); each subsequent entry issued the one above it. A healthy chain reads like a ladder: leaf → intermediate → (root, which the client already trusts). If the ladder is missing a rung, some clients cannot climb it — hold that thought for the DevOps scenario below.

Second, the verification result at the bottom:

Verify return code: 0 (ok)
  • 0 (ok) means the chain validated against the container’s trust store.
  • 21 (unable to verify the first certificate) is the classic symptom of a missing intermediate — the server sent the leaf but not the intermediate that links it to a trusted root.
  • 10 (certificate has expired) speaks for itself.

🔎 Troubleshooting Tip — Read the verify code first, then the reason. unable to get local issuer certificate / unable to verify the first certificate almost always means a missing intermediate, not an untrustworthy CA. certificate has expired is a dates problem. Verify return code: 0 with a browser error usually points at a hostname mismatch — the cert is valid, just not for the name you asked for (see Command 2).

Command 2: openssl x509 — read one certificate in detail

s_client shows the whole exchange; openssl x509 decodes a single certificate so you can read its fields precisely. Pipe one straight from s_client:

openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
  • The first line grabs the leaf certificate the server presents (2>/dev/null hides handshake chatter so only the PEM certificate flows down the pipe).
  • openssl x509 parses a certificate; -noout suppresses re-printing the raw PEM block so you get only the fields you asked for.
  • -subject prints the subject — historically the site identity lived in the subject’s Common Name (CN), e.g. CN=example.com.
  • -issuer prints who signed it — the CA. On a self-signed cert the subject and issuer are identical, an instant red flag for a “should be public” endpoint.
  • -dates prints notBefore and notAfter, the validity window. A cert used before notBefore or after notAfter is rejected.
  • -ext subjectAltName prints the SAN list. This is the field that actually decides hostname matching today.

💡 Note — Modern clients ignore the CN for hostname matching and check the SAN exclusively. A certificate can carry CN=example.com yet omit example.com from its SAN, and browsers will reject it. When you debug “name mismatch,” always read the SAN, never just the subject.

Typical output:

subject=CN=example.com
issuer=C=US, O=DigiCert Inc, CN=DigiCert Global G3 TLS ECC SHA384 2020 CA1
notBefore=Jan 15 00:00:00 2026 GMT
notAfter=Feb 14 23:59:59 2027 GMT
X509v3 Subject Alternative Name:
    DNS:example.com, DNS:www.example.com

Now you can answer three questions at a glance: Is it in date? (dates), Does it cover the hostname I use? (SAN), Who vouches for it? (issuer).

Command 3: curl -v — the client’s-eye view

curl -v shows how a real HTTP client experiences the same endpoint, verbose mode narrating the TLS negotiation as it happens.

curl -v https://example.com 2>&1 | head -n 30
  • curl -v makes an HTTPS request with verbose logging; lines beginning * are curl’s own notes about the connection and TLS.
  • https://example.com is the target. curl sets SNI from this hostname automatically.
  • 2>&1 | head -n 30 merges verbose output (which goes to STDERR) with STDOUT and shows the first 30 lines, which is where the TLS story lives.

Look for lines like:

* Server certificate:
*  subject: CN=example.com
*  start date: Jan 15 00:00:00 2026 GMT
*  expire date: Feb 14 23:59:59 2027 GMT
*  subjectAltName: host "example.com" matched cert's "example.com"
*  issuer: C=US, O=DigiCert Inc; CN=DigiCert Global G3 TLS ECC SHA384 2020 CA1
*  SSL certificate verify ok.

SSL certificate verify ok. is the goal. If instead you see SSL certificate problem: unable to get local issuer certificate, curl is telling you — from a real client’s perspective — that the chain does not validate. Because curl uses the same system trust store as your services, its verdict is a faithful proxy for how your application will behave.

🏭 Why This Matters in Production — Your app’s HTTP library, your reverse proxy, and curl all validate TLS the same way: full chain, in-date, hostname in SAN, trusted root. A browser is more forgiving — some browsers cache intermediates from previous sites and “repair” a broken chain, which is exactly why a page loads fine while a backend service call fails. curl -v from a clean Kali container reproduces the strict, non-forgiving path your production clients take.

DevOps scenario: works in one client, fails in another

You deploy a new HTTPS service. Your laptop browser loads it perfectly. Your monitoring check, written in Go, reports x509: certificate signed by unknown authority. Same URL, same certificate, opposite verdicts. Time to observe, not argue.

Assume a benign local lab service web serving HTTPS on 443. From inside Kali on the same network:

openssl s_client -connect web:443 -servername web </dev/null 2>/dev/null \
  | grep -A20 "Certificate chain"

You see only the leaf:

Certificate chain
 0 s:CN=web
   i:CN=Example Intermediate CA

There is a rung missing. The leaf was issued by Example Intermediate CA, but the server never sends that intermediate — so a strict client cannot link web back to a trusted root and fails with “unknown authority.” The browser succeeded only because it had cached that intermediate from an earlier visit and quietly filled the gap.

Confirm the diagnosis with the verify code:

openssl s_client -connect web:443 -servername web </dev/null 2>/dev/null | grep "Verify return code"
Verify return code: 21 (unable to verify the first certificate)

That code plus a one-entry chain is the fingerprint of a missing intermediate. The fix is on the server, not the clients: bundle the intermediate certificate(s) with the leaf so every client receives the full chain (for nginx, concatenate leaf + intermediate into the ssl_certificate file; most ACME tools call this the fullchain.pem). Redeploy, then re-run s_client and confirm the chain now shows leaf → intermediate and Verify return code: 0 (ok).

🔐 Security Note — The tempting “fix” is to make the failing client skip verification (curl -k, InsecureSkipVerify: true, verify=False). Never do this to make an error go away. Disabling verification does not repair the chain — it blinds the client to all certificate problems, including a genuine man-in-the-middle attacker swapping in their own certificate. You would trade a clear, one-server configuration fix for a silent, permanent security hole across every deployment that copies the workaround. Fix the chain at the server; keep verification on everywhere.

Try It Yourself

🧪 Try It — Inspect a known-good endpoint end to end, then practice reading a broken chain.

  1. Start Kali: docker run --rm -it --name kali kali-devops-toolbox.
  2. Handshake and read the verify code against a public endpoint: openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | grep "Verify return code". Expect 0 (ok).
  3. Read the leaf’s identity: pipe s_client into openssl x509 -noout -subject -issuer -dates -ext subjectAltName. Confirm the SAN includes example.com and notAfter is in the future.
  4. See the client view: curl -v https://example.com 2>&1 | grep -Ei 'subject|expire|verify|subjectAltName'. Confirm SSL certificate verify ok.
  5. Deliberately break trust to see a failure: curl -v https://self-signed.badssl.com 2>&1 | grep -i 'certificate problem' — observe how a strict client reports an untrusted chain (use only public test endpoints designed for this, or your own lab).

Expected state: example.com validates cleanly; the SAN matches; dates are current. If observed state differs, work through Common Problems below.

Common Problems

Symptom: curl fails with unable to get local issuer certificate but the site loads in a browser.

The server is sending an incomplete chain (missing intermediate); the browser is masking it with a cached intermediate.

  • Diagnose: openssl s_client -connect host:443 -servername host </dev/null 2>/dev/null | grep -A20 "Certificate chain". A chain with only entry 0 confirms it.
  • Cross-check the verify code — 21 (unable to verify the first certificate) is the tell.
  • Fix: serve the full chain (leaf + intermediates) from the server. This is a server config change, not a client change.

Symptom: verify error: certificate has expired or curl reports certificate has expired.

The certificate is outside its validity window.

  • Diagnose: openssl s_client ... | openssl x509 -noout -dates and compare notAfter to the container clock (date -u). Also confirm the container’s clock is correct — a wildly wrong host/container time makes a valid cert look expired or not-yet-valid.
  • Fix: renew and redeploy the certificate; automate renewal so it does not recur.

Symptom: curl reports SSL: no alternative certificate subject name matches target host name.

Classic hostname mismatch: the certificate is valid and trusted, but not issued for the name you are requesting.

  • Diagnose: openssl s_client ... | openssl x509 -noout -ext subjectAltName and check whether your hostname is actually in the SAN list. A cert can be “valid for www.example.com, not example.com,” and that one-word gap is the whole failure.
  • Fix: request the hostname the cert covers, or reissue the cert with the correct SAN entries.

Symptom: the certificate you get back is not the one you expected on shared hosting.

You likely omitted SNI, so the server returned a default certificate.

  • Diagnose: re-run with -servername <host> and compare. If the subject/SAN changes, SNI was the issue.
  • Fix: always pass -servername in s_client; real clients and curl set it automatically from the URL.

🔎 Troubleshooting Tip — Keep the three failure fingerprints straight: missing intermediate = short chain + verify code 21; expired = code 10 / “has expired” + a past notAfter; hostname mismatch = verify code 0 but “subject name matches target host name” error. Reading the verify code and the SAN first sorts almost every TLS ticket in seconds.

Where to go next

What You Learned

  • How openssl s_client -connect host:443 -servername host performs a full TLS handshake and prints the certificate chain plus a Verify return code you can read at a glance.
  • How to decode a single certificate with openssl x509 -noout -subject -issuer -dates -ext subjectAltName, and why modern clients match hostnames against the SAN, not the CN.
  • How curl -v reproduces a strict, real-client view of TLS using the system trust store — a faithful proxy for how your production services behave.
  • Why a service can work in a forgiving browser yet fail in another client: a missing intermediate leaves the chain incomplete, and browsers sometimes paper over it with cached intermediates.
  • The three failure fingerprints — missing intermediate (verify code 21), expired cert (code 10), and hostname mismatch (valid but wrong SAN) — and how to tell them apart fast.
  • Why you must never “fix” a TLS error by disabling verification: it hides real chain problems and opens the door to man-in-the-middle attacks. Repair the chain at the server instead.

Affiliate Disclosure: Some links on this page are affiliate links. If you purchase through one of these links, DevOps AI Toolkit may earn a commission at no additional cost to you. See our affiliate disclosure.

← Back to Kali Linux on Docker Back to Kali Linux

Related on DevOps AI Toolkit