Objective
openssl s_client is the tool most engineers reach for and the tool fewest read carefully. Its
output mixes three separate things into one screen: what the server put on the wire, what the two
ends negotiated, and what your local client concluded about all of it. Confusing those three is how
an afternoon disappears into the wrong hypothesis.
This lab takes them apart. You will run a correctly configured service, which makes it a reference rather than a puzzle, and then read the transcript line by line: the chain and its ordering, the protocol version, the cipher suite and what its name actually encodes, and the verification verdict that depends entirely on your own trust configuration rather than on anything the server did.
Then you will find the seams. Server Name Indication selects which certificate the server offers,
and it goes out in clear text before any encryption exists. The certificate does not authenticate
anyone by itself; possession of the matching private key does, and you will watch a server refuse
to start when that possession is absent. Finally you will meet a refusal that looks like a server
policy and is not, which is the most quietly misleading result s_client produces.
Architecture
One nginx container listens on port 443, published to the host on 8450, with two server blocks sharing the socket. Each block holds a different certificate. Which one the server sends is decided by the name the client puts in the clear-text portion of its first message, before any key exchange has happened.
flowchart TD
C["openssl s_client\nClientHello, cleartext"] -- "server_name: app.lab.example" --> N["rbpki-web-10\none socket, two server blocks"]
C -- "server_name: api.lab.example" --> N
C -- "no server_name" --> N
N -- "app certificate" --> R1["chain, protocol, cipher"]
N -- "api certificate" --> R2["chain, protocol, cipher"]
N -- "default_server certificate" --> R3["chain, protocol, cipher"]
R1 --> V{"client applies\nits own trust"}
R2 --> V
R3 --> V
The diagram splits the handshake at the place where responsibility changes hands. Everything to the left of the server is a request in clear text. Everything the server returns is a fact about the server. The verdict at the bottom is a fact about the client, and it is the only part of the picture that changes when you edit your own trust store. Keeping those three regions separate in your head is most of what this lab is for.
Requirements
- OpenSSL 3.5.x, providing
openssl s_clientwith-showcerts,-servername,-noservername,-tls1_2and-tls1_3. - Docker with permission to run containers and publish two ports. The lab pulls
nginx:1.29-alpine. - curl built against OpenSSL.
- Host TCP ports 8450 and 8451 free. The second port is used by a container that is expected to fail to start.
- No out-of-band access requirement. This lab does not touch SSH, the firewall, the primary
interface, or
/etc/fstab. It writes nothing to the system trust store and nothing to/etc/hosts.
Labs 4 and 5 built the two-tier certificate authority and issued the leaf for app.lab.example,
and labs 6 to 9 deployed and broke it in various ways. This lab issues a second leaf so there is
something to choose between. If you still have root.crt, srv-ca.crt and srv-ca.key, copy them
into $LAB/pki and skip the authority half of Task 2.
Scenario
A colleague forwards a screenshot of an s_client run and asks whether the server is configured
correctly. The screenshot contains a chain, a protocol line, a cipher name and a verification
result, and the question cannot be answered from it, because two of those four lines describe the
person who ran the command rather than the server they ran it against.
You are going to build the reference that lets you answer questions like that properly: a known-good endpoint, a transcript you have read end to end, and a clear account of which lines would change if the server changed and which would change if only your laptop did.
Tasks
Task 1 β Prepare the lab directory and record the starting state
LAB="$HOME/rbpki-lab-10"
rm -rf "$LAB"
mkdir -p "$LAB/pki" "$LAB/site" "$LAB/bad" "$LAB/html"
cd "$LAB"
{
echo "--- containers before the lab"
docker ps -a --format '{{.Names}}'
} > "$LAB/state.pre-lab"
docker rm -f rbpki-web-10 rbpki-badkey-10 2>/dev/null || true
echo 'lab ok' > "$LAB/html/index.html"
cat "$LAB/state.pre-lab"
Two container names are removed rather than one, because this lab deliberately starts a second container that will exit with an error. A failed container still exists and still holds its name, so Cleanup has to remove it explicitly.
Task 2 β Build two certificates and put them behind one socket
LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out root.key
openssl req -x509 -new -key root.key -sha256 -days 3650 \
-subj "/O=RunBook Academy Lab/CN=RunBook Lab Root CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:1" \
-addext "keyUsage=critical,keyCertSign,cRLSign" -out root.crt
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out srv-ca.key
openssl req -new -key srv-ca.key -sha256 \
-subj "/O=RunBook Academy Lab/CN=RunBook Lab Server Issuing CA" -out srv-ca.csr
cat > srv-ca.ext <<'EOF'
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=critical,keyCertSign,cRLSign
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always
EOF
openssl x509 -req -in srv-ca.csr -CA root.crt -CAkey root.key -CAcreateserial \
-sha256 -days 1825 -extfile srv-ca.ext -out srv-ca.crt
# Two leaves, each naming exactly one host.
for NAME in app api; do
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$NAME.key"
openssl req -new -key "$NAME.key" -sha256 \
-subj "/CN=$NAME.lab.example" -out "$NAME.csr"
{
echo 'basicConstraints=critical,CA:FALSE'
echo 'keyUsage=critical,digitalSignature,keyEncipherment'
echo 'extendedKeyUsage=serverAuth'
echo "subjectAltName=DNS:$NAME.lab.example"
echo 'subjectKeyIdentifier=hash'
echo 'authorityKeyIdentifier=keyid:always'
} > "$NAME.ext"
openssl x509 -req -in "$NAME.csr" -CA srv-ca.crt -CAkey srv-ca.key -CAcreateserial \
-sha256 -days 90 -extfile "$NAME.ext" -out "$NAME.crt"
cat "$NAME.crt" srv-ca.crt > "$NAME-fullchain.pem"
done
# A spare key belonging to nothing, used in Task 6.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out orphan.key
chmod 600 root.key srv-ca.key app.key api.key orphan.key
ls -1 ./*.crt ./*-fullchain.pem
The orphan.key is generated now and used later. It is a perfectly ordinary private key that
happens to correspond to no certificate in the lab, which is exactly what makes it useful for
demonstrating what a certificate is worth without its key.
Task 3 β Deploy both server blocks and read the presented chain
LAB="$HOME/rbpki-lab-10"
cat > "$LAB/site/default.conf" <<'EOF'
server {
listen 443 ssl default_server;
server_name app.lab.example;
ssl_certificate /etc/nginx/certs/app-fullchain.pem;
ssl_certificate_key /etc/nginx/certs/app.key;
ssl_protocols TLSv1.2 TLSv1.3;
root /usr/share/nginx/html;
index index.html;
}
server {
listen 443 ssl;
server_name api.lab.example;
ssl_certificate /etc/nginx/certs/api-fullchain.pem;
ssl_certificate_key /etc/nginx/certs/api.key;
ssl_protocols TLSv1.2 TLSv1.3;
root /usr/share/nginx/html;
index index.html;
}
EOF
docker run -d --name rbpki-web-10 -p 8450:443 \
-v "$LAB/site:/etc/nginx/conf.d:ro" \
-v "$LAB/pki:/etc/nginx/certs:ro" \
-v "$LAB/html:/usr/share/nginx/html:ro" \
nginx:1.29-alpine
sleep 2
docker exec rbpki-web-10 nginx -t
cd "$LAB/pki"
openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example \
-CAfile root.crt -showcerts </dev/null 2>/dev/null \
| tee "$LAB/s_client-full.out" \
| awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' > "$LAB/served-chain.pem"
grep -E 'Certificate chain|^ [0-9] s:|^ i:' "$LAB/s_client-full.out"
openssl storeutl -noout -certs "$LAB/served-chain.pem"
$ openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example -CAfile root.crt -showcertsCertificate chain
0 s:CN=app.lab.example
i:O=RunBook Academy Lab, CN=RunBook Lab Server Issuing CA
1 s:O=RunBook Academy Lab, CN=RunBook Lab Server Issuing CA
i:O=RunBook Academy Lab, CN=RunBook Lab Root CAIllustrative output
The s: line is the subject of that certificate and the i: line is the subject of whatever signed
it. Reading downward, entry 0 is signed by entry 1, and entry 1 is signed by something called the
Root CA which does not appear anywhere in the list. That absence is correct and deliberate: the
server sends everything between its own certificate and a trust anchor, and stops.
Position 0 is the serverβs own identity. A chain whose entry 0 names a certificate authority means the bundle was concatenated in the wrong order, which is a fault the server will never mention.
Task 4 β Read the negotiated protocol and cipher
LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"
grep -E '^New,|^Protocol|Verify return code' "$LAB/s_client-full.out"
# The same endpoint, forced down to TLS 1.2, to compare suite naming.
openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example \
-CAfile root.crt -tls1_2 </dev/null 2>/dev/null \
| grep -E '^New,|^Protocol|Verify return code'
$ openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example -CAfile root.crtNew, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3
Verify return code: 0 (ok)Illustrative output
TLS_AES_256_GCM_SHA384 encodes two things and only two: the authenticated encryption algorithm
used for record protection, and the hash used by the key schedule. It says nothing about key
exchange and nothing about authentication, because in TLS 1.3 those are negotiated separately
through the supported groups and signature algorithms extensions. There are five suites in the whole
protocol version, and choosing between them is not a security decision of any consequence.
Now compare the TLS 1.2 run. A TLS 1.2 suite name packs four fields into one string: the key exchange, the authentication algorithm, the bulk cipher and the message authentication or hash. That is why TLS 1.2 configuration involved long ordered cipher lists and TLS 1.3 configuration does not, and why the two families of suite names cannot be mixed in one list. Read the suite your own run negotiated and name its four parts before moving on.
Task 5 β Watch Server Name Indication choose the certificate
LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"
{
echo "=== servername app.lab.example"
openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example </dev/null 2>/dev/null \
| grep -m1 '^ 0 s:'
echo "=== servername api.lab.example"
openssl s_client -connect 127.0.0.1:8450 -servername api.lab.example </dev/null 2>/dev/null \
| grep -m1 '^ 0 s:'
echo "=== no servername at all"
openssl s_client -connect 127.0.0.1:8450 -noservername </dev/null 2>/dev/null \
| grep -m1 '^ 0 s:'
} | tee "$LAB/sni-comparison.txt"
Three connections to the same address and the same port return two different certificates and a default. The server is not guessing from the destination address; there is only one. It is reading a name the client wrote into its very first message, in clear text, before any key exchange occurred.
Two consequences follow directly. First, omitting -servername when you debug a multi-tenant
endpoint gets you the default serverβs certificate and a mismatch error that describes a
certificate nobody intended you to see. Second, the name is observable: anyone on the network path
learns which service you are connecting to even though they cannot read a byte of the exchange.
Task 6 β Prove that the certificate alone authenticates nobody
LAB="$HOME/rbpki-lab-10"
# The public certificate anyone can obtain from the wire, paired with a
# private key that does not belong to it.
cat > "$LAB/bad/default.conf" <<'EOF'
server {
listen 443 ssl default_server;
server_name app.lab.example;
ssl_certificate /etc/nginx/certs/app-fullchain.pem;
ssl_certificate_key /etc/nginx/certs/orphan.key;
ssl_protocols TLSv1.2 TLSv1.3;
root /usr/share/nginx/html;
index index.html;
}
EOF
docker run -d --name rbpki-badkey-10 -p 8451:443 \
-v "$LAB/bad:/etc/nginx/conf.d:ro" \
-v "$LAB/pki:/etc/nginx/certs:ro" \
-v "$LAB/html:/usr/share/nginx/html:ro" \
nginx:1.29-alpine
sleep 3
docker ps -a --filter name=rbpki-badkey-10 --format '{{.Names}} {{.Status}}'
docker logs rbpki-badkey-10 2>&1 | tail -3
The container exits immediately. Read the log lines it left: nginx reports a failure loading the private key and names a mismatch between the key and the certificate. The server would not even have reached the point of offering a handshake, because it cannot sign anything with a key that does not correspond to the certificate it is holding.
That is the practical form of the authentication argument. The certificate for app.lab.example is
public, you extracted a copy of it from the wire two tasks ago, and holding it gets an attacker
precisely nowhere. What matters is the private key, which never appears in any handshake, and the
CertificateVerify signature that proves the server has it.
Task 7 β A refusal that looks like server policy and is not
LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"
# Ask this client to offer TLS 1.1.
openssl s_client -tls1_1 -connect 127.0.0.1:8450 \
-servername app.lab.example </dev/null 2>&1 | head -3
$ openssl s_client -tls1_1 -connect 127.0.0.1:8450 -servername app.lab.exampleerror:0A0000BF:SSL routines:tls_setup_handshake:no protocols availableIllustrative output
Read that string carefully, because it is the single most misinterpreted result in TLS debugging.
tls_setup_handshake is a local function. A modern OpenSSL build will not offer TLS 1.1 at its
default security level, so the request was refused before a packet left the machine. Nothing here
tells you anything at all about what the server would have done.
Presenting this output as proof that a server rejects TLS 1.1 is a false claim, and it is a claim
that appears in audit evidence regularly. If you need to know a serverβs floor, the client must
actually be capable of offering the version in question. Lowering the clientβs security level with
-cipher 'DEFAULT@SECLEVEL=0' alongside -tls1_1 makes this build offer it, and the answer then
arrives as a TLS alert from the server naming a protocol version problem, which is a genuine
server-side statement. That option weakens only the client process for the duration of one
diagnostic command, it must never appear in a server configuration, and what it disables is the
minimum-strength policy that normally stops the client negotiating obsolete parameters.
LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"
{
for VERSION in tls1_2 tls1_3; do
printf '%s -> ' "$VERSION"
openssl s_client "-$VERSION" -connect 127.0.0.1:8450 \
-servername app.lab.example -CAfile root.crt </dev/null 2>/dev/null \
| grep -m1 '^Protocol' || echo "no handshake"
done
echo "tls1_1 -> attempted below, read whether the refusal is local or remote"
openssl s_client -tls1_1 -connect 127.0.0.1:8450 \
-servername app.lab.example </dev/null 2>&1 | head -2
} | tee "$LAB/protocol-matrix.txt"
The matrix is the deliverable an auditor should be given, and it is honest about its own limits: two versions this client could offer and one it could not. Recording the distinction is the difference between an evidence file and a misleading one.
Task 8 β Capture the handshake report
LAB="$HOME/rbpki-lab-10"
cd "$LAB/pki"
{
echo "=== endpoint"
echo "127.0.0.1:8450 servername app.lab.example"
echo
echo "=== chain presented by the server"
grep -E 'Certificate chain|^ [0-9] s:|^ i:' "$LAB/s_client-full.out"
echo
echo "=== certificates counted on the wire"
openssl storeutl -noout -certs "$LAB/served-chain.pem" | tail -1
echo
echo "=== negotiated, and this client's verdict"
grep -E '^New,|^Protocol|Verify return code' "$LAB/s_client-full.out"
echo
echo "=== the same connection with no trust anchor supplied"
openssl s_client -connect 127.0.0.1:8450 -servername app.lab.example \
-no-CAfile -no-CApath -no-CAstore </dev/null 2>/dev/null \
| grep -E 'Verify return code'
} > "$LAB/handshake-report.txt"
cat "$LAB/handshake-report.txt"
ls -l "$LAB/handshake-report.txt" "$LAB/served-chain.pem" \
"$LAB/sni-comparison.txt" "$LAB/protocol-matrix.txt"
The last section of the report is the point of the whole lab. The same server, the same certificate
and the same negotiation produce a different Verify return code purely because the client was
told to use no trust anchors. Every other line in the report is a fact about the server; that one is
a fact about whoever ran the command.
Validation
openssl storeutl -noout -certs served-chain.pemreports two certificates, and the first entry underCertificate chainnamesCN=app.lab.examplerather than a certificate authority.- The transcript contains
Protocol: TLSv1.3and a cipher line naming a suite whose name beginsTLS_, and ends withVerify return code: 0 (ok). - The
-tls1_2run reportsProtocol: TLSv1.2and a suite name containing a key exchange and an authentication component, confirming the two naming conventions differ. sni-comparison.txtshowsCN=app.lab.examplefor the first name,CN=api.lab.examplefor the second, and the default serverβs certificate when no name is sent. Three connections to one socket must not return one certificate.docker ps -a --filter name=rbpki-badkey-10shows the container exited, and its logs name a key and certificate mismatch. A pass here is the container failing.- The
-tls1_1attempt printserror:0A0000BF:SSL routines:tls_setup_handshake:no protocols availableand produces noProtocol:line, andprotocol-matrix.txtrecords it as a local refusal rather than a server rejection. - The final section of
handshake-report.txtshows a non-zeroVerify return codefor the run with no anchors, against the same endpoint that returned zero earlier. - The four deliverables exist and are non-empty.
A failed validation is usually a missing redirect. If s_client appears to hang, it is waiting on
standard input; every command here redirects from /dev/null for that reason. If all three SNI
runs return the same certificate, the second server block was not loaded - docker exec rbpki-web-10 nginx -T prints the merged configuration and will show whether both blocks are present.
Expected Outcome
$HOME/rbpki-lab-10/
βββ bad/
β βββ default.conf
βββ html/
β βββ index.html
βββ pki/
β βββ api-fullchain.pem
β βββ api.crt
β βββ api.key
β βββ app-fullchain.pem
β βββ app.crt
β βββ app.key
β βββ orphan.key
β βββ root.crt
β βββ srv-ca.crt
β βββ srv-ca.key
βββ site/
β βββ default.conf
βββ handshake-report.txt
βββ protocol-matrix.txt
βββ s_client-full.out
βββ served-chain.pem
βββ sni-comparison.txt
βββ state.pre-lab
You can now read an s_client transcript and say, line by line, which parts describe the server and
which describe the machine you ran it on. That distinction is what makes the difference between
producing evidence and producing a screenshot.
Troubleshooting
s_client hangs after printing the certificate. It is waiting for input to send to the server.
Redirect from /dev/null or press Ctrl-D.
Every SNI run returns the same certificate. Only one server block loaded. Check with
docker exec rbpki-web-10 nginx -T, and confirm the second blockβs server_name and certificate
paths are correct.
Verify return code is 0 even with -no-CAfile. The other two suppression flags are also
needed; a default certificate directory or store is still being consulted. Pass -no-CAfile -no-CApath -no-CAstore together.
The -tls1_2 run fails with a handshake error rather than a protocol line. The serverβs
ssl_protocols no longer lists TLSv1.2. That is a genuine server-side refusal and looks quite
different from the local error in Task 7, which is the comparison the task is making.
rbpki-badkey-10 starts successfully. The configuration references a matching key rather than
orphan.key. Confirm the path inside bad/default.conf and remove the container before retrying,
since the name is already taken by the previous attempt.
Cleanup
LAB="$HOME/rbpki-lab-10"
# 1. Stop and forget both containers, including the one that never ran.
docker rm -f rbpki-web-10 rbpki-badkey-10 2>/dev/null || true
# 2. Compare against the inventory recorded in Task 1.
cat "$LAB/state.pre-lab"
echo "--- containers now"
docker ps -a --format '{{.Names}}'
# 3. Remove the lab directory, keys included.
rm -rf "$LAB"
# 4. Assert the host is as you found it.
docker ps -a --format '{{.Names}}' | grep -cE '^rbpki-(web|badkey)-10$' || echo "containers gone"
test -d "$LAB" && echo "LAB DIRECTORY STILL PRESENT" || echo "lab directory gone"
The two container inventories must differ only by the removal of rbpki-web-10 and
rbpki-badkey-10. The exited container is the one most often left behind, because it does not
appear in a plain docker ps and is easy to believe was never created. Both assertions in step 4
must report the resource gone.
Production notes
- Capture
s_clienttranscripts into files rather than screenshots, and always record the flags used. A transcript without its command line cannot be interpreted, because the trust flags change the conclusion. - Probe multi-tenant endpoints with the name you care about every single time. An SNI-less probe against shared ingress reports on a certificate nobody deployed for you.
- Version floors belong in the server configuration and are verified with a client capable of offering the version being tested. Anything else produces evidence about your workstation.
- Pair every handshake check with a key-possession check at deployment time. The handshake proves possession only for the pair currently loaded, and a mismatched pair sitting on disk is invisible until a restart.
What You Learned
- An
s_clienttranscript contains three different kinds of statement. What the server sent, what the two ends negotiated, and what your client concluded, and only the first two are facts about the server. - A TLS 1.3 suite name encodes the AEAD and the hash, nothing else. Key exchange and authentication are negotiated by separate extensions, which is why there are only five suites.
- Server Name Indication selects the certificate and is sent in clear text. Debugging without it produces a mismatch against a certificate that was never meant for you.
- The certificate is public and authenticates nobody on its own. Possession of the private key, demonstrated by the CertificateVerify signature over the handshake transcript, is what authentication means.
- Session keys come from an ephemeral exchange, not from the certificate. Recovering the serverβs long-term key later does not decrypt a recorded session.
no protocols availableis your own client refusing. Quoting it as proof of a serverβs policy is a false claim, and probing a version floor requires a client that can actually offer that version.