IoT Security Testing Fundamentals: Protecting Connected Devices
IoT devices are deployed everywhere — homes, factories, hospitals, infrastructure — and they're notoriously insecure. Default passwords, unencrypted communications, and insecure OTA updates have led to massive botnets and data breaches. Security testing for IoT requires different techniques than web application testing.
This guide covers the fundamentals of IoT security testing, organized around the OWASP IoT Top 10.
The OWASP IoT Top 10
The OWASP IoT Top 10 defines the most critical IoT security risks:
- Weak, Guessable, or Hardcoded Passwords
- Insecure Network Services
- Insecure Ecosystem Interfaces
- Lack of Secure Update Mechanism
- Use of Insecure or Outdated Components
- Insufficient Privacy Protection
- Insecure Data Transfer and Storage
- Lack of Device Management
- Insecure Default Settings
- Lack of Physical Hardening
We'll write tests for the most testable categories.
1. Testing for Hardcoded Credentials
Risk: Devices shipped with default or hardcoded passwords.
Static analysis: Scan firmware binary for credential patterns:
# Extract strings from firmware
strings firmware.bin <span class="hljs-pipe">| grep -E <span class="hljs-string">"(password|passwd|secret|key|admin|root)" -i
<span class="hljs-comment"># Look for common default credentials
binwalk -e firmware.bin
grep -r <span class="hljs-string">"admin:admin\|root:root\|admin:1234\|default:default" extracted/Automated test:
import paramiko
import pytest
COMMON_CREDENTIALS = [
('admin', 'admin'),
('admin', 'password'),
('admin', '1234'),
('root', 'root'),
('root', ''),
('admin', ''),
('user', 'user'),
]
@pytest.mark.parametrize("username,password", COMMON_CREDENTIALS)
def test_no_default_credentials_accepted(device_ip, username, password):
"""Device should reject common default credentials."""
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect(device_ip, username=username, password=password, timeout=5)
ssh.close()
pytest.fail(f"Device accepted default credential {username}:{password}")
except paramiko.AuthenticationException:
pass # Good — credential rejected
except Exception:
pass # Connection refused or timeout is also acceptable2. Testing Network Services
Risk: Unnecessary services exposed on the network.
Port scan and service audit:
import nmap
import pytest
EXPECTED_OPEN_PORTS = {443} # Only HTTPS should be open on production
FORBIDDEN_PORTS = {23, 21, 80} # Telnet, FTP, HTTP
def test_only_expected_ports_open(device_ip):
nm = nmap.PortScanner()
nm.scan(device_ip, arguments='-sV -p 1-65535 --open')
open_ports = set()
for proto in nm[device_ip].all_protocols():
for port in nm[device_ip][proto].keys():
if nm[device_ip][proto][port]['state'] == 'open':
open_ports.add(port)
# Check no forbidden ports are open
for port in FORBIDDEN_PORTS:
assert port not in open_ports, \
f"Port {port} is open — should not be accessible"
# Verify expected ports match exactly
unexpected = open_ports - EXPECTED_OPEN_PORTS
assert not unexpected, f"Unexpected open ports: {unexpected}"
def test_telnet_is_disabled(device_ip):
"""Telnet sends passwords in plaintext — must be disabled."""
import socket
s = socket.socket()
s.settimeout(3)
result = s.connect_ex((device_ip, 23))
s.close()
assert result != 0, "Telnet port 23 is open — this is a security vulnerability"3. Testing API Security
Risk: Device management APIs with missing authentication or authorization.
def test_device_api_requires_authentication(device_ip):
"""All API endpoints should require authentication."""
import requests
# Try common unauthenticated paths
sensitive_paths = [
'/api/config',
'/api/users',
'/api/firmware',
'/api/logs',
'/admin',
'/api/v1/device/settings'
]
for path in sensitive_paths:
response = requests.get(f'https://{device_ip}{path}', verify=False, timeout=5)
assert response.status_code in (401, 403, 404), \
f"Path {path} accessible without authentication (status {response.status_code})"
def test_api_enforces_authorization(device_api_url, user_token, admin_token):
"""Regular users should not access admin endpoints."""
import requests
headers = {'Authorization': f'Bearer {user_token}'}
admin_endpoints = [
'/api/admin/users',
'/api/admin/firmware',
'/api/admin/factory-reset'
]
for endpoint in admin_endpoints:
response = requests.get(f'{device_api_url}{endpoint}', headers=headers)
assert response.status_code == 403, \
f"User accessed admin endpoint {endpoint} (status {response.status_code})"4. Testing OTA Update Security
Risk: Unsigned firmware updates allow malicious firmware installation.
def test_ota_rejects_unsigned_firmware(device_api_url, auth_token):
"""Device should reject firmware without valid signature."""
import requests
# Create a valid-looking but unsigned firmware file
malicious_firmware = b'\x7fELF' + b'\x00' * 1000 + b'BACKDOOR'
response = requests.post(
f'{device_api_url}/api/firmware/update',
headers={'Authorization': f'Bearer {auth_token}'},
files={'firmware': ('malicious.bin', malicious_firmware, 'application/octet-stream')}
)
assert response.status_code in (400, 403, 422), \
f"Device accepted unsigned firmware (status {response.status_code})"
def test_ota_rejects_downgrade_to_older_version(device_api_url, auth_token, signed_old_firmware):
"""Device should reject firmware older than current version (prevents rollback attacks)."""
import requests
response = requests.post(
f'{device_api_url}/api/firmware/update',
headers={'Authorization': f'Bearer {auth_token}'},
files={'firmware': signed_old_firmware}
)
assert response.status_code in (400, 409), \
"Device accepted downgrade firmware — rollback attacks possible"
assert 'version' in response.json().get('error', '').lower()
def test_ota_uses_tls_for_download(device_ip):
"""Firmware download should use TLS, not HTTP."""
# Intercept firmware download request (requires network interception setup)
# Verify the URL used for firmware download starts with https://
pass # Implementation depends on network capture capability5. Testing Encryption in Transit
Risk: Sensitive data transmitted without encryption.
def test_device_api_enforces_https(device_ip):
"""HTTP requests should redirect to HTTPS or be rejected."""
import requests
try:
response = requests.get(
f'http://{device_ip}/api/status',
allow_redirects=False,
timeout=5
)
if response.status_code == 200:
pytest.fail("HTTP returns data without TLS — this is insecure")
elif response.status_code in (301, 302, 307, 308):
assert response.headers['Location'].startswith('https://'), \
"HTTP redirects to non-HTTPS URL"
except requests.exceptions.ConnectionError:
pass # Connection refused is acceptable
def test_tls_version_is_current(device_ip):
"""Device should support TLS 1.2+ and reject older versions."""
import ssl
import socket
# Test that TLS 1.0 is rejected
context_tls10 = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context_tls10.minimum_version = ssl.TLSVersion.TLSv1
context_tls10.maximum_version = ssl.TLSVersion.TLSv1
context_tls10.check_hostname = False
context_tls10.verify_mode = ssl.CERT_NONE
try:
with socket.create_connection((device_ip, 443), timeout=5) as sock:
with context_tls10.wrap_socket(sock) as ssock:
pytest.fail("Device accepts TLS 1.0 — should require TLS 1.2+")
except ssl.SSLError:
pass # Good — TLS 1.0 rejected
def test_mqtt_uses_tls(device_ip):
"""MQTT communication should use TLS (port 8883)."""
import paho.mqtt.client as mqtt
# Plain MQTT port 1883 should be closed or rejected
plain_client = mqtt.Client()
result = plain_client.connect(device_ip, 1883, keepalive=5)
# Should fail or not connect
assert result != mqtt.MQTT_ERR_SUCCESS, \
"MQTT plain text port 1883 is accessible — use TLS on port 8883"6. Testing for Sensitive Data Exposure
Risk: Device logs, debug interfaces, or APIs expose sensitive data.
def test_logs_dont_contain_credentials(device_api_url, auth_token):
"""Log files should not contain passwords or tokens."""
import requests
import re
response = requests.get(
f'{device_api_url}/api/logs',
headers={'Authorization': f'Bearer {auth_token}'}
)
log_content = response.text
# Check for credential patterns
patterns = [
r'password["\s:=]+[^\s"]{4,}',
r'token["\s:=]+[A-Za-z0-9]{20,}',
r'secret["\s:=]+[^\s"]{4,}',
r'-----BEGIN.*PRIVATE KEY-----',
]
for pattern in patterns:
matches = re.findall(pattern, log_content, re.IGNORECASE)
assert not matches, f"Credential pattern found in logs: {matches[0][:50]}"
def test_error_responses_dont_expose_internals(device_api_url):
"""Error responses should not include stack traces or internal paths."""
import requests
# Trigger an error with invalid input
response = requests.get(
f'{device_api_url}/api/device/../../../etc/passwd',
verify=False
)
error_body = response.text.lower()
assert 'traceback' not in error_body, "Stack trace in error response"
assert '/usr/' not in error_body, "Internal file path in error response"
assert 'exception' not in error_body, "Exception details in error response"Building a Security Test Suite
Organize IoT security tests into a structured suite:
# conftest.py
import pytest
def pytest_configure(config):
config.addinivalue_line("markers", "security: IoT security tests")
config.addinivalue_line("markers", "critical: Critical security issues")
# Run with: pytest tests/security/ -m security -v# GitHub Actions — security tests on every PR
jobs:
security-tests:
runs-on: [self-hosted, device-lab]
steps:
- uses: actions/checkout@v4
- name: Run IoT security tests
run: |
pytest tests/security/ -v \
--device-ip=${{ secrets.TEST_DEVICE_IP }} \
--auth-token=${{ secrets.TEST_AUTH_TOKEN }} \
-m "security and not slow"
- name: Report results
if: failure()
uses: actions/upload-artifact@v4
with:
name: security-test-results
path: reports/Continuous Security Monitoring with HelpMeTest
Use HelpMeTest health checks to continuously monitor device security posture:
# Check that device is only exposing expected ports
<span class="hljs-comment"># Run hourly from a network scanner
*/60 * * * * nmap -p 1-1024 <span class="hljs-variable">$DEVICE_IP <span class="hljs-pipe">| grep open <span class="hljs-pipe">| <span class="hljs-built_in">wc -lCombine with Robot Framework tests for comprehensive API security monitoring:
*** Test Cases ***
Device Rejects Unauthenticated API Access
${response}= GET ${DEVICE_API}/api/config
Should Be Equal As Integers ${response.status_code} 401
Device Certificate Is Not Expired
${cert_info}= Get SSL Certificate ${DEVICE_IP} 443
${expiry}= Get From Dictionary ${cert_info} notAfter
Certificate Should Not Expire Within Days ${expiry} 30Summary
IoT security testing requires going beyond web application testing techniques. The key areas to cover:
- Credential testing — reject defaults, enforce complexity
- Network services — close unnecessary ports, disable Telnet/FTP
- API security — authenticate and authorize every endpoint
- OTA security — verify signatures, block downgrades
- Encryption — TLS 1.2+ for all communications
- Data exposure — no credentials or stack traces in logs/errors
Start with the OWASP IoT Top 10 as your baseline, automate the testable checks in CI, and run them continuously in production with monitoring. IoT security is not a one-time audit — it's an ongoing test discipline.