Cryptography for IoT Hackers - Part 8
Series Index
| Part | Title | Status |
|---|---|---|
| 1 | What TLS Actually Does (And Why Your IoT Device Needs It) | |
| 2 | Certificates From Scratch The Trust Chain Nobody Explains | |
| 3 | Setting Up Your Own CA and Issuing Certs with OpenSSL | |
| 4 | ESP32 + TLS: Your First Secure MQTT Connection | |
| 5 | What the TLS Handshake Looks Like on the Wire (Wireshark Lab) | |
| 6 | Mutual TLS (mTLS) Explained: When the Server Also Verifies the Client | |
| 7 | MITM Attack on a TLS IoT Device What Breaks and What Doesn’t | |
| 8 - You are here | Cert Pinning The Fix, and How Attackers Bypass It | |
| 9 | Embedded Crypto Pitfalls: Hardcoded Keys and Weak RNG | |
| 10 | Breaking mTLS: Stolen Certs and Certificate Confusion | |
| 11 | Secure Provisioning How to Get Certs Onto Devices Safely | |
| 12 | Building a Hardened ESP32 TLS Client Checklist and Final Lab | |
| 13 | Secure OTA Updates Why Your Update Channel Is an Attack Surface |
Before You Read This
You need Parts 1 through 7 done before this one. Specifically:
- Part 3 - you have
rootCA.crt,server.crt,server.keyin~/my-ca - Part 4 - ESP32 talking to Mosquitto over TLS on port 8883
- Part 6 - mTLS working, broker checking ESP32’s certificate
- Part 7 - you broke everything with one line:
setInsecure()
If you have not done those, go back. This part builds directly on them.
Where We Left Off
In Part 7, we took the most locked-down setup from Part 6 mTLS, both sides verified and broke it completely with one line:
espClient.setInsecure();
The ESP32 stopped checking the broker’s certificate. A fake broker on port 8884 presented a self-signed cert that had nothing to do with our Root CA. The ESP32 accepted it without question. Message delivered to the attacker. Real broker never saw anything.
We watched it in Wireshark. The Certificate Request, the ESP32’s Certificate packet, the Certificate Verify all three gone. Handshake completed anyway.
The fix from Part 7 was: remove setInsecure(), put the three cert lines back. ESP32 becomes the gatekeeper again.
But that fix left one question unanswered. And that question is what Part 8 is about.
The Question Part 7 Left Open
Okay so we restore setCACert(rootCA). The ESP32 checks signatures again. Fake cert gets rejected. Good.
But here is a scarier scenario.
What if the attacker does not use a fake self-signed cert? What if they steal rootCA.key from the server the private key of your Root CA and use it to sign their own certificate?
Now they have evil.crt signed by your actual Root CA. The ESP32 calls setCACert(rootCA), checks the signature, and sees: valid. Signed by rootCA. Trusted. Connection allowed.
Attacker steals rootCA.key
Attacker creates evil.crt signed using rootCA.key
ESP32 checks: "was this signed by rootCA?"
Answer: YES - same key was used
ESP32: connects ← attacker wins
setCACert(rootCA) only asks one question: who signed this?
It does not ask: is this the exact certificate I was expecting?
That gap is what cert pinning closes.
What Is Cert Pinning
Honestly when I first heard “cert pinning” I thought it was some kind of attack. It is not. It is a defence.
Here is the simplest way to think about it:
Normal TLS is like a bouncer checking if your ID was issued by a government. Any government-issued ID gets you in.
Cert pinning is like the bouncer having a photo of one specific person. Only that exact face gets in. Does not matter who issued the ID.
In our case:
- Normal TLS: “was this cert signed by
rootCA.crt?” any cert signed by rootCA passes - Cert pinning: “is this cert exactly
server.crt?” only that one specific cert passes
The ESP32 stops trusting the CA. It trusts one specific certificate directly.
What Is a Fingerprint
When I first understood cert pinning I thought okay so we hardcode the entire server.crt file inside the sketch.
Not quite.
What gets hardcoded is the fingerprint of server.crt. A fingerprint is a SHA256 hash of the entire certificate file. You run the cert through a hash function and get a short unique string.
Run this on your machine to see it:
openssl x509 -in ~/my-ca/server.crt -fingerprint -sha256 -noout
What each part does:
x509- work with a certificate file-in ~/my-ca/server.crt- the certificate to inspect-fingerprint -sha256- calculate SHA256 hash of the whole cert-noout- don’t print the cert itself, just the fingerprint
Output:
sha256 Fingerprint=79:47:0B:01:61:8F:B3:BC:73:39:B6:E0:70:2C:B6:65:0C:E5:B3:A3:A9:C2:C5:57:3B:87:FA:61:FA:58:56:8E
That string is what gets hardcoded in the ESP32 sketch. Not the whole cert. Just that hash.
If even one byte of the certificate changes the fingerprint completely changes. Two different certificates will never have the same fingerprint.
Why the Attacker Gets Rejected
Back to the stolen rootCA.key scenario. Attacker creates evil.crt signed by your rootCA.
ESP32 hardcodes fingerprint: 79:47:0B:01... (server.crt's fingerprint)
Attacker sends: evil.crt
ESP32 calculates SHA256 of evil.crt → 24:31:EC:83...
ESP32 compares: 79:47:0B ≠ 24:31:EC
Result: ❌ rejected
evil.crt and server.crt are different files. Different content. Different fingerprint. Even though both were signed by the same rootCA.key the fingerprints are completely different.
The attacker cannot forge server.crt’s fingerprint without having server.key the server’s own private key. And if they have that, we have much bigger problems.
The Full Handshake Flow With Cert Pinning
1. ESP32 connects to broker on port 8883
2. Broker sends server.crt during TLS handshake
3. ESP32 calculates SHA256 hash of the received certificate
4. ESP32 compares that hash against the hardcoded fingerprint
5. Match → connection continues → MQTT connects
No match → connection dropped → attacker gets nothing
The check happens at step 3-4 before any MQTT data is sent. If the cert does not match, the ESP32 kills the connection immediately.
Setting Up the Lab
What You Need
- ESP32 DevKit
- Kali Linux VM (Bridged Adapter same network as ESP32)
~/my-cafolder withrootCA.crt,server.crt,server.keyfrom Part 3- Arduino IDE with ESP32 board package
Note on ESP32 Arduino Core Versions
This is where we hit our first wall during the lab worth explaining so you don’t waste time.
Older ESP32 Arduino core (1.x) had a method called setFingerprint() that did cert pinning directly. Clean, one line.
In core 2.x and 3.x that method was changed. The compiler will suggest getFingerprintSHA256() instead but that works differently. It does not set a fingerprint to check against. It retrieves the fingerprint of whatever cert the broker sent.
So on newer cores, we do the pinning manually:
- Connect with
setInsecure()- just to establish the TCP connection - Call
getFingerprintSHA256()- get the cert fingerprint from the broker - Compare it byte by byte with our hardcoded value
- Mismatch call
espClient.stop()- disconnect immediately
Yes, setInsecure() is in this sketch. That confused me too at first. But here it is used only to allow the initial connection so we can read the fingerprint. The actual pinning check is our own code right after. We are not trusting blindly we are checking manually.
Start the Real Broker
The /tmp folder gets wiped on reboot, so recreate the config if needed:
cat > /tmp/pin_test.conf << EOF
listener 8883
cafile /home/iotsec/my-ca/rootCA.crt
certfile /home/iotsec/my-ca/server.crt
keyfile /home/iotsec/my-ca/server.key
require_certificate false
allow_anonymous true
EOF
Why require_certificate false here we are not testing mTLS in this part. We are testing cert pinning on the ESP32 side only. The broker just needs to present its cert.
Fix permissions and start:
sudo chown mosquitto:mosquitto ~/my-ca/server.key
sudo chmod 640 ~/my-ca/server.key
sudo mosquitto -v -c /tmp/pin_test.conf
Expected output:
mosquitto version 2.0.22 starting
Config loaded from /tmp/pin_test.conf.
Opening ipv4 listen socket on port 8883.
Opening ipv6 listen socket on port 8883.
mosquitto version 2.0.22 running
Leave this terminal open. Broker is waiting.
The ESP32 Sketch - Cert Pinning
Get your fingerprint first:
openssl x509 -in ~/my-ca/server.crt -fingerprint -sha256 -noout
Copy that value. Now the sketch:
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* mqtt_server = "YOUR_BROKER_IP"; // Kali machine IP
const int mqtt_port = 8883;
// Cert pinning hardcoded fingerprint of server.crt
// This is the SHA256 hash of the exact certificate our broker uses
// Any other certificate even one signed by our own rootCA gets rejected
const char* expected_fingerprint = "79:47:0B:01:61:8F:B3:BC:73:39:B6:E0:70:2C:B6:65:0C:E5:B3:A3:A9:C2:C5:57:3B:87:FA:61:FA:58:56:8E";
WiFiClientSecure espClient;
PubSubClient client(espClient);
void connectWiFi() {
Serial.print("Connecting to WiFi");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi connected");
Serial.println(WiFi.localIP());
}
void setup() {
Serial.begin(115200);
connectWiFi();
// setInsecure() here does NOT mean we trust everything blindly
// It means: allow TCP connection so we can read the cert fingerprint manually
// The actual pinning check happens right after connect()
espClient.setInsecure();
Serial.print("Connecting to broker...");
if (espClient.connect(mqtt_server, mqtt_port)) {
Serial.println("TCP connected");
// Read the fingerprint of whatever cert the broker presented
uint8_t fp[32];
espClient.getFingerprintSHA256(fp);
// Convert the raw bytes to a readable XX:XX:XX string
char actual[100];
snprintf(actual, sizeof(actual),
"%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:"
"%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X",
fp[0],fp[1],fp[2],fp[3],fp[4],fp[5],fp[6],fp[7],
fp[8],fp[9],fp[10],fp[11],fp[12],fp[13],fp[14],fp[15],
fp[16],fp[17],fp[18],fp[19],fp[20],fp[21],fp[22],fp[23],
fp[24],fp[25],fp[26],fp[27],fp[28],fp[29],fp[30],fp[31]);
Serial.print("Broker fingerprint: ");
Serial.println(actual);
// Byte by byte comparison - this is the actual pinning check
if (strcmp(actual, expected_fingerprint) == 0) {
Serial.println("Fingerprint MATCH — cert pinning passed!");
// Safe to proceed with MQTT here
} else {
Serial.println("Fingerprint MISMATCH — possible MITM, disconnecting!");
espClient.stop(); // kill the connection immediately
}
} else {
Serial.println("TCP connection failed");
}
}
void loop() {}
What each section does:
expected_fingerprint- SHA256 hash ofserver.crt, hardcoded at compile time. This never changes unless you reflash.setInsecure()- allows TCP connection without built-in cert verification. We do our own verification next.espClient.connect()- establishes the TLS connection to the broker.getFingerprintSHA256(fp)- reads the SHA256 fingerprint of whatever cert the broker sent.snprintf()- converts the raw 32 bytes into a human-readableXX:XX:XX...string.strcmp()- compares byte by byte. Any difference = mismatch = disconnect.
Flash this sketch. Open Serial Monitor at 115200.
Test 1 - Real Broker on Port 8883
Expected Serial Monitor output:
.....
WiFi connected
192.168.28.75
Connecting to broker...TCP connected
Broker fingerprint: 79:47:0B:01:61:8F:B3:BC:73:39:B6:E0:70:2C:B6:65:0C:E5:B3:A3:A9:C2:C5:57:3B:87:FA:61:FA:58:56:8E
Fingerprint MATCH cert pinning passed!
![]()
Broker terminal:
New connection from 192.168.28.75:59230 on port 8883.
The fingerprint the ESP32 calculated matches the hardcoded one exactly. Cert pinning passed.
Test 2 - Fake Broker on Port 8884
This simulates the attacker scenario. Same assumption as Part 7 developer misconfiguration or attacker redirecting traffic to the fake broker.
Create the fake broker (same as Part 7 /tmp gets wiped on reboot so recreate):
# Generate attacker's fake cert NOT signed by our rootCA
openssl req -x509 -newkey rsa:2048 -keyout /tmp/fake.key \
-out /tmp/fake.crt -days 365 -nodes \
-subj "/CN=fakeBroker" \
-addext "subjectAltName=IP:192.168.28.71"
What this does:
-x509— self-signed cert, no CA involved-newkey rsa:2048generate a new 2048-bit RSA key-nodesno password on the key-subj "/CN=fakeBroker"— common name is fakeBroker, nothing to do with our setup-addext "subjectAltName=IP:..."required for modern TLS, adds the IP as a SAN
Create fake broker config:
cat > /tmp/fake_broker.conf << EOF
listener 8884
allow_anonymous true
cafile /tmp/fake.crt
certfile /tmp/fake.crt
keyfile /tmp/fake.key
EOF
Fix permissions and start:
sudo chown mosquitto:mosquitto /tmp/fake.key
sudo chmod 640 /tmp/fake.key
sudo mosquitto -v -c /tmp/fake_broker.conf
Expected:
mosquitto version 2.0.22 running
Opening ipv4 listen socket on port 8884.
Now in the sketch change one line:
const int mqtt_port = 8884; // was 8883
Flash and open Serial Monitor.
Expected Serial Monitor output:
WiFi connected
192.168.28.75
Connecting to broker...TCP connected
Broker fingerprint: 24:31:EC:83:30:36:94:0A:3A:C1:CB:6F:7C:7C:01:A6:BC:58:54:2D:5A:31:DA:61:BD:30:69:0A:49:95:DC:8E
Fingerprint MISMATCH disconnecting!
Fake broker terminal:
New connection from 192.168.28.75:58003 on port 8884.
OpenSSL Error[0]: error:0A000126:SSL routines::unexpected eof while reading
Client <unknown> closed its connection.
The fake broker’s cert has a completely different fingerprint 24:31:EC:83... vs our hardcoded 79:47:0B:01.... Mismatch detected. ESP32 disconnected. Attacker got nothing.
The Small Window Problem - A Real Security Concern
During this lab I noticed something and it is worth talking about honestly.
Look at the broker terminal output for the fake broker:
New connection from 192.168.28.75 on port 8884. ← connection WAS established
Client <unknown> closed its connection. ← then we disconnected
The TCP connection happened. TLS handshake completed. Then we checked the fingerprint and disconnected.
That means there is a small window between connection and the fingerprint check where the attacker knows a device tried to connect. In our sketch loop() is empty so no MQTT data flows during that window. But think about a real production sketch where connectMQTT() runs in loop() if it fires before the fingerprint check completes, data could leak to the attacker’s broker.
This is a real flaw in the manual fingerprint checking approach.
This is exactly why setFingerprint() existed in older ESP32 core versions it did the check inside the TLS handshake itself, before any application data could flow. No window. Atomic.
The lesson: the implementation of cert pinning matters as much as the concept. Pinning done wrong still has gaps. In production code, make sure the fingerprint check happens before any MQTT connection attempt, and if it fails, the device should go into a safe state not retry, not log, not send anything.
How Attackers Bypass Cert Pinning
Cert pinning is a strong defence. But it has three main bypass paths. None of them break the mathematics. They go around the implementation.
Bypass 1 - OTA Firmware Replacement
The fingerprint is hardcoded in the firmware. When OTA update happens, new firmware gets flashed.
If the attacker can intercept the OTA update channel MITM on the update itself they push modified firmware with a different fingerprint hardcoded. Device reboots with new firmware. Now it trusts the attacker’s cert.
Device running v1.0 → pinned to server.crt fingerprint
Attacker intercepts OTA channel
Attacker pushes v1.1-evil → pinned to evil.crt fingerprint
Device flashes, reboots
Device now trusts attacker's cert → MITM succeeds
The attacker did not break cert pinning. They replaced the firmware that contains the pinning. This is why Part 13 of this series is about securing OTA because OTA is the backdoor around everything.
Bypass 2 - Certificate Expiry and Developer Fatigue
Every certificate has an expiry date. Our server.crt was created with -days 365. One year from now it expires.
When it expires the device stops connecting. Every device in the field is offline until new firmware with the updated fingerprint gets pushed to every device.
In a real deployment thousands of devices, different firmware versions, different teams this operational overhead is massive. What happens in practice: teams get tired of it. They push a firmware update that switches from fingerprint checking back to setCACert() or worse, back to setInsecure(). Pinning is gone. And it never comes back.
The attacker does not need to do anything technical. They just wait for the certificate to expire and watch the team take the easy way out.
Bypass 3 - setInsecure() in the Codebase
We used setInsecure() in this very sketch but carefully, with manual fingerprint checking right after.
In real IoT codebases, setInsecure() gets added for debugging, stays in the code, and nobody notices. Or pinning gets added later by a different developer and nobody realises setInsecure() overrides everything.
espClient.setInsecure(); // this runs — skips ALL verification
espClient.setFingerprint(fp); // in older cores, this never executes properly
The pinning code is there. It just never runs. A firmware code audit that finds this in a production device is a valid security finding.
The Three Bypasses - Summary
| Bypass | What the Attacker Does | How Hard |
|---|---|---|
| OTA firmware replacement | MITM the update channel, push modified firmware | Medium |
| Certificate expiry fatigue | Wait for cert to expire, watch team remove pinning | Low just wait |
setInsecure() override |
Find debug code left in production firmware | Low code audit |
None of these break cert pinning mathematically. They go around the people and processes that implement it.
The Full Defence Picture
Everything from Parts 1 through 8 in one place:
| Defence | What it stops | What it does NOT stop |
|---|---|---|
| TLS (Part 4) | Passive sniffing | MITM with valid cert |
| mTLS (Part 6) | Unauthenticated clients | Stolen rootCA key |
| Cert pinning (Part 8) | Stolen rootCA key, rogue CAs | Firmware replacement via OTA |
| Secure OTA (Part 13) | Firmware replacement attacks |
Each part adds one layer. No single layer is enough on its own. That is the whole point of this series.
Common Errors
setFingerprint() has no member error
You are on ESP32 core 2.x or 3.x. setFingerprint() was removed. Use the manual getFingerprintSHA256() approach shown in this blog. Downgrading to core 2.0.17 does not help it was already removed there too.
Unable to load server key file Permission denied
Mosquitto cannot read server.key. Fix:
sudo chown mosquitto:mosquitto ~/my-ca/server.key
sudo chmod 640 ~/my-ca/server.key
Broker starts then immediately exits with no output
Run with 2>&1 to see the error:
sudo mosquitto -v -c /tmp/pin_test.conf 2>&1
Most likely a permission issue or a config path problem.
Fingerprint MISMATCH on real broker
Two possible causes:
- You copy-pasted the fingerprint wrong check for spaces or missing colons
server.crtwas regenerated after you copied the fingerprint run theopensslfingerprint command again and update the sketch
/tmp/fake_broker.conf not found after reboot
/tmp is cleared on reboot. Recreate the fake cert and config using the commands in this blog they are quick to run again.
What Just Happened - Full Picture
We started Part 8 with a question Part 7 left open: what if the attacker steals rootCA.key and signs their own certificate?
setCACert(rootCA) cannot catch that. It only checks signatures.
Cert pinning catches it because it checks the exact SHA256 fingerprint of server.crt. evil.crt has a different fingerprint even if signed by the same rootCA.key. No match, connection dropped.
We implemented it with getFingerprintSHA256() and manual comparison. Proved it passes on the real broker. Proved it rejects the fake broker different fingerprint, immediate disconnect.
We also caught a real implementation flaw: the small window between TCP connect and fingerprint check. In production, that window needs to be eliminated fingerprint check must happen before any data flows.
Then we looked at three bypass paths OTA interception, expiry fatigue, setInsecure() in production code. None break the math. They go around the people.
What Is Coming in Part 9
Part 8 covered cert pinning a defence that lives in the TLS verification layer.
Part 9 goes deeper into the firmware itself: Embedded Crypto Pitfalls Hardcoded Keys and Weak RNG.
We have hardcoded certificates and fingerprints throughout this series. Part 9 is about when hardcoding goes wrong hardcoded symmetric keys baked into firmware, weak random number generators on embedded devices, and why rand() on an ESP32 is not the same as cryptographically secure random. These are the mistakes that get real IoT products pwned in the field.



