This is an old revision of the document!
By the end of this activity, you will be able to flash and run ESP32-C6 firmware that connects to Wi-Fi, periodically sends JSON data over plain HTTP to a server, and intentionally leaks an API key in the HTTP headers.
You will use Wireshark to capture this HTTP traffic and closely inspect the URL, payload, and exposed secrets in cleartext. In addition, you will write or modify a Python script that spoofs sensor data and sends it via HTTP POST to the same endpoint, giving you an understanding of how insecure data transmission can be observed and exploited.
Use this standard .ini file for your project:
[env:sparrow_http_lab] platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.20/platform-espressif32.zip board = esp32-c6-devkitm-1 framework = arduino monitor_speed = 115200 build_flags = -D ARDUINO_USB_MODE=1 -D ARDUINO_USB_CDC_ON_BOOT=1
Let's write a (horribly unsafe) but functional code that connects to a HTTP server and sends periodically sensor data. This is the “bad” example in which we are using plaintext messaging over an unsecured connection.
Get the code from here.
We'll create a simple HTTP server in Python that will display the packages received from the Sparrow node and their payload.
# server.py from flask import Flask, request app = Flask(__name__) @app.route("/ingest", methods=["POST"]) def ingest(): print("=== New request ===") print("Client IP:", request.remote_addr) print("Headers:") for k, v in request.headers.items(): print(f" {k}: {v}") print("Body:", request.data.decode("utf-8", errors="ignore")) print("===================") return "OK\n" if __name__ == "__main__": # Listen on all interfaces, port 8080 app.run(host="0.0.0.0", port=8080) #replace the IP address with the server's IP
Set your computer's IP address as host and run the script on your machine by:
pip install flask
python server.py
Use Wireshark to capture network traffic. Start Wireshark on the interface used for lab Wi-Fi/LAN and use a simple display filter such as: tcp.port == 8080 or filter by server IP: ip.addr == 192.168.1.100 && tcp.port == 8080
You should find a full HTTP request line: POST /ingest HTTP/1.1. with request headers: Host, User-Agent, Content-Type and
X-API-Key: SUPER_SECRET_API_KEY_123 – this is the “secret”.
Also, HTTP body = JSON payload, fully readable:
{"device_id":"sparrow-01","temp_c":27.43,"humidity":55.7,"battery":88}
By simply sniffing on network traffic you can get all sensor information and data. Next step is to spoof sensor readings by impersonating the ESP32 Sparrow node.
This is the “attack” part in which we use captured data to impersonate the node. We will run a python script on our machine that matches the API key and JSON formatting. Use the script below, modifying only the server IP.
# attack.py import requests import json SERVER_URL = "http://192.168.1.100:8080/ingest" # same as device API_KEY = "SUPER_SECRET_API_KEY_123" # copied from Wireshark DEVICE_ID = "sparrow-01" # same as real device def main(): fake_payload = { "device_id": DEVICE_ID, "temp_c": 999.9, # clearly fake, "overheating" "humidity": 0.0, "battery": 5 } headers = { "Content-Type": "application/json", "X-API-Key": API_KEY } print("Sending spoofed measurement...") print("POST", SERVER_URL) print("Headers:", headers) print("Body:", json.dumps(fake_payload)) r = requests.post(SERVER_URL, headers=headers, json=fake_payload) print("Status code:", r.status_code) print("Response:", r.text) if __name__ == "__main__": main()
To run the attack script:
pip install requests
python attack.py
Let's modify the device to use HTTPS instead of HTTP and add a shared secret + simple MAC (message authentication code) to detect tampering. This will prevent passive sniffers from reading the payload and there will also be a complete failure of injecting spoofed data to the server.
First, let's generate a self-signed cert for Flask. In the same folder you have the two previous python scripts, run:
openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt -days 365 -nodes \ -subj "/CN=iot-lab.local"
This gives you server.crt and server.key in the current directory.
Now, let's upgrade our server with HTTPS instead of HTTP and a check function for message authentication codes (MAC). The new server script is below:
# secure_server.py from flask import Flask, request, abort import json app = Flask(__name__) SECRET_KEY = "LAB2_SUPER_SECRET_MAC_KEY" # this is shared only between the node and the server def compute_mac(payload: str) -> int: """ Very simple (non-crypto!) MAC: hash(payload + SECRET_KEY) This is just for teaching integrity, not real security. """ data = (payload + SECRET_KEY).encode("utf-8", errors="ignore") h = 0 for b in data: h = (h * 31) ^ b h &= 0xFFFFFFFF # keep as 32-bit return h @app.route("/ingest", methods=["POST"]) def ingest(): raw_body = request.data.decode("utf-8", errors="ignore") print("=== New HTTPS request ===") print("Client IP:", request.remote_addr) print("Headers:") for k, v in request.headers.items(): print(f" {k}: {v}") print("Raw body:", raw_body) try: data = json.loads(raw_body) except json.JSONDecodeError: print("!! Invalid JSON, rejecting") abort(400, "Invalid JSON") # Expect fields: device_id, temp_c, humidity, battery, mac if "mac" not in data: print("!! Missing MAC, rejecting") abort(400, "Missing MAC") received_mac = int(data["mac"]) # Compute MAC over body without the mac field check_obj = dict(data) del check_obj["mac"] # Use a canonical payload string for MAC computation mac_payload = json.dumps(check_obj, sort_keys=True, separators=(",", ":")) expected_mac = compute_mac(mac_payload) print(f"Received MAC: {received_mac}") print(f"Expected MAC: {expected_mac}") if received_mac != expected_mac: print("!! MAC mismatch, possible tampering, rejecting") abort(403, "Invalid MAC") print("MAC OK, accepting data:", check_obj) print("=========================") return "OK\n" if __name__ == "__main__": # HTTPS on 8443, replace IP with your own server's address app.run( host="0.0.0.0", port=8443, ssl_context=("server.crt", "server.key") )
Run it and now the server has moved from http:…:8080/ingest and expects a MAC.
to https:…:8443/ingest
Now, let's upgrade also the code on the node itself. Key changes we will need to make are URL is now https://...:8443/ingest, we use WiFiClientSecure + setInsecure() (so: encrypted but not properly authenticated), and we add a mac field based on the same simple MAC function as the server.
Get the new main.cpp code from here.
Now, start a new capture while Sparrow sends to HTTPS. Filter: tcp.port == 8443 or ip.addr == <server-ip> && tcp.port == 8443
and look if you still see TCP + TLS records (Client Hello, Server Hello, etc). The key difference now is that we see no readable JSON or headers in the middle of the transport. There is no way of directly finding the secret API key now.
Let's now try to attack the server by injecting again false data.
mac: field in the attack script and give it a constant value, such as 1234. What you will see now is the server returning a mismatch → 403 “Invalid MAC”.This example shows why using HTTPS without certificate validation is dangerous. We will:
setInsecure() You should already have:
secure_server.py running on the lab server https://SERVER_IP:8443/ingest WiFiClientSecure secureClient;secureClient.setInsecure(); One extra machine will act as the attacker.
On the server machine:
python secure_server.py
192.168.0.104 On the attacker machine:
pip install mitmproxy
mitmproxy --mode reverse:https://192.168.0.104:8443 -p 8444
Adjust the IP to your real server IP.
What this does:
:8444 on the attacker machine We are pretending that the device was tricked into talking to the attacker instead of the real server (e.g. via DNS or ARP spoofing).
We are going to pretend we have been a victim of a MITM attack and the ESP32 connection to the server has been rerouted through ARP spoofing to an attacker machine. For this, on the ESP32 code (main.cpp), change only the URL:
// OLD // const char* SERVER_URL = "https://192.168.0.104:8443/ingest"; // NEW: attacker IP + mitmproxy port const char* SERVER_URL = "https://192.168.0.200:8444/ingest"; // example
secureClient.setInsecure(); // do NOT change this yet
Rebuild and flash the ESP32.
On the ESP32 serial monitor:
On the real Flask server:
On mitmproxy (attacker UI):
POST /ingest Content-Type: application/json etc. {"battery":83,"device_id":"sparrow-01","humidity":52.1,"temp_c":28.17,"mac":3171032033}
In mitmproxy:
/ingest “temp_c”:28.17 → “temp_c”:999.99 “mac” Forward the modified request.
On the Flask server:
This shows:
setInsecure(), they can sit in the middle: Next steps:
setInsecure() with proper certificate pinning At a high level, to stop MITM you need the ESP32 to trust only a specific certificate (or CA) and to check that the server it’s talking to presents that particular certificate.
That means we can't relay anymore on setInsecure() and load a CA / server cert into the firmware instead. We will the use WiFiClientSecure verify it.
Instead of a random self-signed server cert, you create a tiny lab CA and have it sign your server cert. On the server machine delete the old server.crt and server.key and generate the following:
# 1) Create CA key + cert openssl genrsa -out ca.key 2048 openssl req -x509 -new -key ca.key -out ca.crt -days 365 -subj "/CN=IoT Lab CA" # 2) Create server key + CSR openssl genrsa -out server.key 2048 openssl req -new -key server.key -out server.csr -subj "/CN=iot-lab.local" # 3) Sign server cert with CA openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365
Now you have the following files:
ca.crt – trust anchor you’ll embed in the ESP32server.key + server.crt – used by Flaskiot-lab.local (hostname you’ll use in the URL)Run Flask again:
python secure_server.py # same as before, just point it to server.crt/server.key
Get the new modified code from here and paste the contents of the ca.crt file into the main.cpp file.
Notice we are using a domain name instead of an IP address for the server and we are not relying anymore on setInsecure(), rather we're using HTTPS with CA cert verification.
Try to repeat your ARP poisoning + mitmproxy trick with this setup. ARP poisoning still works: packets still flow ESP32 → attacker → server, but when mitmproxy shows its own cert to the ESP32, WiFiClientSecure aborts the handshake. That is because the cert is not signed by your lab CA. Your HTTP POST never happens; your code will see a connection / TLS error.
In brief:
setInsecure(), MITM can see JSON + MAC and divert, alter or even drop packets entirely.setCACert() + correct hostname, MITM can still route traffic (DoS is possible), but they cannot terminate TLS in the middle; they get reduced to a dumb packet forwarder or DoS attacker.