IoT Security Testing Fundamentals: Protecting Connected Devices

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:

  1. Weak, Guessable, or Hardcoded Passwords
  2. Insecure Network Services
  3. Insecure Ecosystem Interfaces
  4. Lack of Secure Update Mechanism
  5. Use of Insecure or Outdated Components
  6. Insufficient Privacy Protection
  7. Insecure Data Transfer and Storage
  8. Lack of Device Management
  9. Insecure Default Settings
  10. 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 acceptable

2. 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 capability

5. 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 -l

Combine 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}    30

Summary

IoT security testing requires going beyond web application testing techniques. The key areas to cover:

  1. Credential testing — reject defaults, enforce complexity
  2. Network services — close unnecessary ports, disable Telnet/FTP
  3. API security — authenticate and authorize every endpoint
  4. OTA security — verify signatures, block downgrades
  5. Encryption — TLS 1.2+ for all communications
  6. 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.

Read more