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)
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.
#include <Arduino.h> #include <WiFi.h> #include <WiFiClientSecure.h> #include <HTTPClient.h> // ====== LAB CONFIG: fill these in per-lab ====== const char* WIFI_SSID = "LAB_WIFI_SSID"; const char* WIFI_PASSWORD = "LAB_WIFI_PASSWORD"; // Replace with your server's IP const char* SERVER_URL = "https://192.168.1.100:8443/ingest"; const char* DEVICE_ID = "sparrow-01"; // This key must match SECRET_KEY on the server // NOTE: in real systems, putting secrets in firmware has risks → discussion later. const char* SECRET_KEY = "LAB2_SUPER_SECRET_MAC_KEY"; // =============================================== WiFiClientSecure secureClient; void connectToWiFi() { Serial.printf("Connecting to WiFi SSID: %s\n", WIFI_SSID); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); uint8_t retries = 0; while (WiFi.status() != WL_CONNECTED && retries < 30) { delay(500); Serial.print("."); retries++; } if (WiFi.status() == WL_CONNECTED) { Serial.println("\nWiFi connected!"); Serial.print("IP address: "); Serial.println(WiFi.localIP()); } else { Serial.println("\nFailed to connect to WiFi"); } } // Very simple, non-cryptographic MAC, must match server's compute_mac() uint32_t computeMac(const String& payload, const char* secret) { String data = payload + secret; uint32_t h = 0; for (size_t i = 0; i < data.length(); i++) { uint8_t b = static_cast<uint8_t>(data[i]); h = (h * 31) ^ b; } return h; } void setup() { Serial.begin(115200); delay(2000); connectToWiFi(); // HTTPS client config: secureClient.setTimeout(15000); // For the lab, we disable certificate validation to keep // the code simple. This gives confidentiality but not strong // authenticity → good discussion point. secureClient.setInsecure(); // DO NOT DO THIS IN REAL PRODUCTS randomSeed(esp_random()); } void loop() { if (WiFi.status() != WL_CONNECTED) { Serial.println("WiFi disconnected, reconnecting..."); connectToWiFi(); } if (WiFi.status() == WL_CONNECTED) { // Fake sensor data, like in Lab 1 float tempC = 20.0 + (random(0, 1000) / 100.0f); float humidity = 40.0 + (random(0, 1000) / 50.0f); int battery = random(60, 100); // Build JSON without MAC first // We'll use this version to compute MAC both here and on the server. String jsonNoMac = "{"; jsonNoMac += "\"battery\":" + String(battery) + ","; jsonNoMac += "\"device_id\":\"" + String(DEVICE_ID) + "\","; jsonNoMac += "\"humidity\":" + String(humidity, 1) + ","; jsonNoMac += "\"temp_c\":" + String(tempC, 2); jsonNoMac += "}"; // Compute MAC uint32_t mac = computeMac(jsonNoMac, SECRET_KEY); // Full JSON payload with MAC String payload = "{"; payload += "\"battery\":" + String(battery) + ","; payload += "\"device_id\":\"" + String(DEVICE_ID) + "\","; payload += "\"humidity\":" + String(humidity, 1) + ","; payload += "\"temp_c\":" + String(tempC, 2) + ","; payload += "\"mac\":" + String(mac); payload += "}"; Serial.println("Sending HTTPS POST to server..."); Serial.println("URL: " + String(SERVER_URL)); Serial.println("Payload (with MAC): " + payload); HTTPClient http; if (!http.begin(secureClient, SERVER_URL)) { Serial.println("Failed to start HTTP connection"); } else { http.addHeader("Content-Type", "application/json"); int httpCode = http.POST(payload); String response = http.getString(); Serial.printf("HTTP response code: %d\n", httpCode); Serial.println("Response body: " + response); Serial.println("-----------------------------"); http.end(); } } delay(5000); }
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 (for a future lab):
setInsecure() with proper certificate pinning