251 lines
13 KiB
Python
251 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Execute the pinned scan method with poisoned locals at its SDK boundary."""
|
|
import configparser
|
|
import hashlib
|
|
import importlib.util
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FIXTURES = ROOT / "test/fixtures/esp32_wifi_scan"
|
|
SPEC = importlib.util.spec_from_file_location("wifi_scan_fix", ROOT / "scripts/esp32_wifi_scan_fix.py")
|
|
FIX = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(FIX)
|
|
RAW = (FIXTURES / "WiFiScan.cpp").read_text(encoding="utf-8")
|
|
PATCHED = FIX.patched_scan_source(RAW)
|
|
VERSION = ROOT / "test/fixtures/esp32_file_buffer/esp_arduino_version.h"
|
|
|
|
|
|
def scan_method(source):
|
|
start = source.index("int16_t WiFiScanClass::scanNetworks(")
|
|
opening = source.index("{", start)
|
|
depth = 0
|
|
for end in range(opening, len(source)):
|
|
depth += (source[end] == "{") - (source[end] == "}")
|
|
if depth == 0:
|
|
return source[start:end + 1]
|
|
raise AssertionError("unclosed scan method")
|
|
|
|
|
|
PREFIX = r'''
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include "wifi_scan_config.h"
|
|
static constexpr int ESP_OK = 0;
|
|
static constexpr int WIFI_SCAN_RUNNING = -1, WIFI_SCAN_FAILED = -2;
|
|
static constexpr uint32_t WIFI_SCANNING_BIT = 1, WIFI_SCAN_DONE_BIT = 2;
|
|
static uint32_t status_bits = 0;
|
|
static unsigned enabled_calls = 0, deleted_calls = 0, sdk_calls = 0;
|
|
static wifi_scan_config_t captured;
|
|
static uint32_t millis() { return 42; }
|
|
struct WiFiGenericClass {
|
|
static uint32_t getStatusBits() { return status_bits; }
|
|
static void clearStatusBits(uint32_t bits) { status_bits &= ~bits; }
|
|
static void setStatusBits(uint32_t bits) { status_bits |= bits; }
|
|
static uint32_t waitStatusBits(uint32_t bits, uint32_t) { return status_bits & bits; }
|
|
};
|
|
struct WiFiMock { void enableSTA(bool enabled) { assert(enabled); ++enabled_calls; } } WiFi;
|
|
struct WiFiScanClass {
|
|
static uint32_t _scanTimeout, _scanStarted;
|
|
static bool _scanAsync;
|
|
static uint16_t _scanCount;
|
|
static void scanDelete() { ++deleted_calls; }
|
|
int16_t scanNetworks(bool, bool, bool, uint32_t, uint8_t, const char*, const uint8_t*);
|
|
};
|
|
uint32_t WiFiScanClass::_scanTimeout = 0, WiFiScanClass::_scanStarted = 0;
|
|
bool WiFiScanClass::_scanAsync = false;
|
|
uint16_t WiFiScanClass::_scanCount = 0;
|
|
static int esp_wifi_scan_start(const wifi_scan_config_t* config, bool block) {
|
|
assert(!block);
|
|
captured = *config;
|
|
++sdk_calls;
|
|
return ESP_OK;
|
|
}
|
|
// GCC 11 has no automatic-local poisoning flag. The negative control alone
|
|
// models a poisoned prior stack in that case; production initialization stays
|
|
// unchanged. All fields interpreted at the SDK boundary are then overwritten
|
|
// by the original method or consist of unsigned integers.
|
|
static wifi_scan_config_t poisonedScanConfig() {
|
|
wifi_scan_config_t config;
|
|
std::memset(&config, 0xa5, sizeof(config));
|
|
return config;
|
|
}
|
|
'''
|
|
|
|
MAIN = r'''
|
|
int main() {
|
|
WiFiScanClass scanner;
|
|
const char ssid[] = "filter";
|
|
const uint8_t bssid[] = {1, 2, 3, 4, 5, 6};
|
|
for (bool passive : {false, true}) {
|
|
for (uint32_t dwell : {120u, 200u, 300u}) {
|
|
status_bits = 0;
|
|
assert(scanner.scanNetworks(true, true, passive, dwell, 6, ssid, bssid)
|
|
== WIFI_SCAN_RUNNING);
|
|
assert(captured.ssid == reinterpret_cast<const uint8_t*>(ssid));
|
|
assert(captured.bssid == bssid && captured.channel == 6 && captured.show_hidden);
|
|
assert(captured.scan_type == (passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE));
|
|
assert(WiFiScanClass::_scanTimeout == dwell * 20 && WiFiScanClass::_scanStarted == 42);
|
|
assert((status_bits & WIFI_SCANNING_BIT) && !(status_bits & WIFI_SCAN_DONE_BIT));
|
|
if (captured.home_chan_dwell_time != 0
|
|
|| (passive && (captured.scan_time.active.min != 0 || captured.scan_time.active.max != 0))
|
|
|| (!passive && captured.scan_time.passive != 0)) {
|
|
std::puts("scan defaults were not zeroed");
|
|
return 1;
|
|
}
|
|
if (passive) assert(captured.scan_time.passive == dwell);
|
|
else assert(captured.scan_time.active.min == 100 && captured.scan_time.active.max == dwell);
|
|
}
|
|
}
|
|
assert(enabled_calls == 6 && deleted_calls == 6 && sdk_calls == 6);
|
|
// An existing scan must return without starting or modifying another scan.
|
|
assert(scanner.scanNetworks(true, false, false, 200, 0, nullptr, nullptr) == WIFI_SCAN_RUNNING);
|
|
assert(sdk_calls == 6 && enabled_calls == 6 && deleted_calls == 6);
|
|
status_bits = 0;
|
|
assert(scanner.scanNetworks(true, false, false, 200, 0, nullptr, nullptr) == WIFI_SCAN_RUNNING);
|
|
assert(!captured.ssid && !captured.bssid && captured.channel == 0 && !captured.show_hidden);
|
|
std::puts("WiFi scan SDK configuration checks passed");
|
|
}
|
|
'''
|
|
|
|
|
|
class Node:
|
|
def __init__(self, path): self.path = path
|
|
def srcnode(self): return self
|
|
def get_abspath(self): return str(self.path)
|
|
|
|
|
|
class Env(dict):
|
|
def __init__(self, defines, build):
|
|
super().__init__(CPPDEFINES=defines)
|
|
self.build, self.paths, self.middleware = build, [], []
|
|
def subst(self, value):
|
|
assert value == "$BUILD_DIR"
|
|
return str(self.build)
|
|
def File(self, path): return path
|
|
def AppendUnique(self, **kwargs):
|
|
for path in kwargs["CPPPATH"]:
|
|
if path not in self.paths: self.paths.append(path)
|
|
def AddBuildMiddleware(self, callback, pattern): self.middleware.append((callback, pattern))
|
|
|
|
|
|
class WiFiScanFixTest(unittest.TestCase):
|
|
def test_actual_scan_method_zeroes_unused_fields_with_poisoned_negative_control(self):
|
|
compiler = os.environ.get("CXX") or shutil.which("c++") or shutil.which("clang++")
|
|
self.assertIsNotNone(compiler, "A host C++11 compiler is required")
|
|
probe = subprocess.run([compiler, "-x", "c++", "-std=c++11",
|
|
"-ftrivial-auto-var-init=pattern", "-fsyntax-only", "-"],
|
|
input="int main() {}\n", capture_output=True, text=True, timeout=30)
|
|
poison_flags = ["-ftrivial-auto-var-init=pattern"] if probe.returncode == 0 else []
|
|
with tempfile.TemporaryDirectory(prefix="mesh-wifi-scan-sdk-") as directory:
|
|
temp = Path(directory)
|
|
shutil.copyfile(FIXTURES / "wifi_scan_config.h", temp / "wifi_scan_config.h")
|
|
for source, expected in ((PATCHED, 0), (RAW, 1)):
|
|
with self.subTest(patched=expected == 0):
|
|
method = scan_method(source)
|
|
if expected and not poison_flags:
|
|
method = method.replace(FIX.ORIGINAL_DECLARATION,
|
|
"wifi_scan_config_t config = poisonedScanConfig();", 1)
|
|
generated = temp / "scan.cpp"
|
|
generated.write_text(PREFIX + "\n#include <initializer_list>\n"
|
|
+ method + MAIN, encoding="utf-8")
|
|
binary = temp / "scan.exe"
|
|
built = subprocess.run([compiler, "-std=c++11", "-O1", "-Wall", "-Wextra",
|
|
"-Werror", "-Wno-unused-function", *poison_flags,
|
|
str(generated), "-o", str(binary)], capture_output=True, text=True, timeout=30)
|
|
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
|
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
|
self.assertEqual(checked.returncode, expected, checked.stdout + checked.stderr)
|
|
self.assertIn("WiFi scan SDK configuration checks passed" if expected == 0
|
|
else "scan defaults were not zeroed", checked.stdout)
|
|
|
|
def test_exact_source_pin_transform_idempotence_and_changed_source_rejection(self):
|
|
self.assertEqual(hashlib.sha256((FIXTURES / "WiFiScan.cpp").read_bytes()).hexdigest(),
|
|
FIX.PINNED_SCAN_SHA256)
|
|
self.assertEqual(hashlib.sha256((FIXTURES / "wifi_scan_config.h").read_bytes()).hexdigest(),
|
|
"9b25412ea07a0a8fe295d02c9f0ddaee2bc984397483d5d56db7e54b5aacf2ce")
|
|
self.assertEqual(PATCHED, RAW.replace(FIX.ORIGINAL_DECLARATION, FIX.ZEROED_DECLARATION, 1))
|
|
self.assertEqual(FIX.patched_scan_source(RAW.replace("\n", "\r\n")), PATCHED)
|
|
self.assertEqual(FIX.patched_scan_source(PATCHED), PATCHED)
|
|
for changed in (RAW + "\n", PATCHED + "\n", RAW.replace("active.min = 100", "active.min = 50")):
|
|
with self.assertRaises(RuntimeError): FIX.patched_scan_source(changed)
|
|
for version in ((2, 0, 18), (3, 1, 3), (3, 3, 11), (3, 3, 12)):
|
|
with self.assertRaises(RuntimeError): FIX.patched_scan_source(RAW, version)
|
|
|
|
def test_private_copy_preserves_shared_framework_and_is_idempotent(self):
|
|
with tempfile.TemporaryDirectory(prefix="mesh-wifi-scan-copy-") as directory:
|
|
temp = Path(directory)
|
|
source = temp / "sdk/libraries/WiFi/src/WiFiScan.cpp"
|
|
source.parent.mkdir(parents=True)
|
|
source.write_bytes((FIXTURES / "WiFiScan.cpp").read_bytes())
|
|
header = temp / "sdk/cores/esp32/esp_arduino_version.h"
|
|
header.parent.mkdir(parents=True)
|
|
header.write_bytes(VERSION.read_bytes())
|
|
node = Node(source)
|
|
for defines in ([], ["RP2040_PLATFORM"], [("ESP32_PLATFORM", 0)]):
|
|
env = Env(defines, temp / "untouched")
|
|
self.assertIs(FIX.replace_scan_source(env, node), node)
|
|
self.assertFalse(env.build.exists())
|
|
for index, defines in enumerate((["ESP32_PLATFORM"], [("ESP32", 1)],
|
|
["ESP32_PLATFORM", ("ARDUINO_USB_MODE", 0)],
|
|
["ESP32_PLATFORM", ("ARDUINO_USB_MODE", 1)])):
|
|
env = Env(defines, temp / ("build" + str(index)))
|
|
result = Path(FIX.replace_scan_source(env, node))
|
|
self.assertEqual(result.read_text(encoding="utf-8"), PATCHED)
|
|
self.assertEqual(result, env.build / "patched-esp32-wifi/WiFiScan.cpp")
|
|
self.assertEqual(env.paths, [str(source.parent)])
|
|
self.assertEqual(source.read_bytes(), (FIXTURES / "WiFiScan.cpp").read_bytes())
|
|
self.assertEqual(header.read_bytes(), VERSION.read_bytes())
|
|
before = result.stat().st_mtime_ns
|
|
self.assertEqual(Path(FIX.replace_scan_source(env, node)).stat().st_mtime_ns, before)
|
|
env = Env(["ESP32"], temp / "registration")
|
|
unrelated = Node(temp / "libraries/WiFi/src/Other.cpp")
|
|
self.assertIs(FIX.replace_scan_source(env, unrelated), unrelated)
|
|
FIX.install(env)
|
|
self.assertEqual(env.middleware,
|
|
[(FIX.replace_scan_source, "*libraries*WiFi*src*WiFiScan.cpp")])
|
|
|
|
def test_known_arduino3_is_unchanged_unknown_or_malformed_framework_fails_closed(self):
|
|
with tempfile.TemporaryDirectory(prefix="mesh-wifi-scan-version-") as directory:
|
|
temp = Path(directory)
|
|
source = temp / "sdk/libraries/WiFi/src/WiFiScan.cpp"
|
|
source.parent.mkdir(parents=True)
|
|
source.write_text("Arduino 3 source is outside this narrow patch", encoding="utf-8")
|
|
header = temp / "sdk/cores/esp32/esp_arduino_version.h"
|
|
header.parent.mkdir(parents=True)
|
|
env, node = Env(["ESP32_PLATFORM"], temp / "build"), Node(source)
|
|
def version_text(version):
|
|
return "".join(f"#define ESP_ARDUINO_VERSION_{key} {value}\n"
|
|
for key, value in zip(("MAJOR", "MINOR", "PATCH"), version))
|
|
for version in ((3, 1, 3), (3, 3, 11)):
|
|
header.write_text(version_text(version))
|
|
self.assertEqual(FIX.require_known_framework(source), version)
|
|
self.assertIs(FIX.replace_scan_source(env, node), node)
|
|
self.assertFalse(env.build.exists())
|
|
for changed in ("", version_text((2, 0, 18)), version_text((3, 3, 12)),
|
|
version_text((2, 0, 17)) + "#define ESP_ARDUINO_VERSION_MAJOR 2\n"):
|
|
header.write_text(changed)
|
|
with self.assertRaises(RuntimeError): FIX.replace_scan_source(env, node)
|
|
self.assertFalse(env.build.exists())
|
|
header.unlink()
|
|
with self.assertRaises(RuntimeError): FIX.replace_scan_source(env, node)
|
|
header.write_bytes(VERSION.read_bytes())
|
|
with self.assertRaises(RuntimeError): FIX.replace_scan_source(env, node)
|
|
self.assertFalse(env.build.exists())
|
|
|
|
def test_common_esp32_registration(self):
|
|
config = configparser.ConfigParser(interpolation=None)
|
|
config.read(ROOT / "platformio.ini")
|
|
self.assertIn("pre:scripts/esp32_wifi_scan_fix.py", config["esp32_base"]["extra_scripts"].split())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|