app: listen to what the segment says unprompted
Four passive collectors join long mode: SSDP (passive NOTIFY plus paced M-SEARCH from the capture socket, so unicast replies land in the same capture), LLMNR, NetBIOS-NS and WS-Discovery. All are periodic and sparse by nature, which is exactly why they belong to a window rather than a probe - a thirty-second run mostly hears silence and would report an empty network as confidently as a quiet one. NetBIOS reports unsupported on the app tier and says why: UDP 137 is privileged. The decoder and evidence shape are tested and waiting for the Shizuku tier; a recorded reason beats a missing test. Silence without a multicast lock or a group join is PARTIAL, never OK - that case is a fact about this app, not about the network. Hostnames, banners and device UUIDs are classified in core-privacy so the anonymizer treats them like every other identifier rather than letting a neighbour's device model ride out in an upload. No active WSD Probe and no NBSTAT sweep: the app listens to what a network broadcasts, it does not announce itself to strangers or interrogate its neighbours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
93125a4b1d
commit
9ee7d554a6
@@ -1399,6 +1399,20 @@ plus periodic M-SEARCH), LLMNR, NetBIOS-NS and WS-Discovery — all IP multicast
|
|||||||
sockets an app may open. The security reading matters as much as the inventory: LLMNR and
|
sockets an app may open. The security reading matters as much as the inventory: LLMNR and
|
||||||
NetBIOS-NS being live on a segment is a finding in itself, since both are trivially spoofable.
|
NetBIOS-NS being live on a segment is a finding in itself, since both are trivially spoofable.
|
||||||
|
|
||||||
|
**One of those four cannot run at app tier either, and says so.** `local.netbios_inventory`
|
||||||
|
reports `unsupported` with the bind error attached: UDP 137 is below 1024, and Android reserves
|
||||||
|
privileged ports exactly like any other Linux. The decoder, the evidence shape and the registry
|
||||||
|
id are built and tested, waiting for the Shizuku tier to supply a socket. Recorded as a result
|
||||||
|
rather than dropped, so nobody later reads its absence as an oversight.
|
||||||
|
|
||||||
|
Two deliberate restraints in that work, both worth keeping: no active WS-Discovery Probe (an
|
||||||
|
M-SEARCH is traffic every SSDP device expects constantly, whereas a WSD Probe from an unknown
|
||||||
|
host announces *this* device to the segment), and no NBSTAT sweep (that is host scanning, not
|
||||||
|
measurement — the app listens to what a network broadcasts, it does not interrogate its
|
||||||
|
neighbours). The parsers are also hardened against the input they will actually meet: a DNS
|
||||||
|
compression pointer is refused rather than followed (the classic parser hang), and the WSD
|
||||||
|
extractor is string-based on purpose, tested against an entity bomb and 20 000-deep nesting.
|
||||||
|
|
||||||
## Design note: what BLE between two devices is actually for (2026-08-02, not built)
|
## Design note: what BLE between two devices is actually for (2026-08-02, not built)
|
||||||
|
|
||||||
Two or more phones running Echolot, talking over Bluetooth LE. The schema already anticipates
|
Two or more phones running Echolot, talking over Bluetooth LE. The schema already anticipates
|
||||||
|
|||||||
@@ -497,6 +497,22 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
listenMs = (windowMs - 2_000).coerceAtLeast(10_000)
|
listenMs = (windowMs - 2_000).coerceAtLeast(10_000)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
// The rest of what a segment says about itself unprompted. All passive listeners,
|
||||||
|
// which is exactly why they belong to long mode: the announcements are periodic
|
||||||
|
// and sparse, so a thirty-second run mostly hears silence and would report an
|
||||||
|
// empty network as confidently as a quiet one.
|
||||||
|
//
|
||||||
|
// Searches are paced across the window rather than fired at the start: a device
|
||||||
|
// that was asleep for the first minute still gets asked. NetBIOS is included
|
||||||
|
// knowing it will usually report `unsupported` — UDP 137 is privileged, so the
|
||||||
|
// app tier cannot bind it — because a recorded reason beats an absent test, and
|
||||||
|
// the decoder is ready for the Shizuku tier.
|
||||||
|
app.echo_lot.probe.SsdpCollector(
|
||||||
|
searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000),
|
||||||
|
),
|
||||||
|
app.echo_lot.probe.LlmnrCollector(),
|
||||||
|
app.echo_lot.probe.NetbiosCollector(),
|
||||||
|
app.echo_lot.probe.WsdCollector(),
|
||||||
)
|
)
|
||||||
activeCollectors = collectors
|
activeCollectors = collectors
|
||||||
for (c in collectors) runCatching { c.start(ctx, ids) }
|
for (c in collectors) runCatching { c.start(ctx, ids) }
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ object Classification {
|
|||||||
listOf(
|
listOf(
|
||||||
"addr", "address", "gateway", "dst", "src", "servers", "server", "resolver",
|
"addr", "address", "gateway", "dst", "src", "servers", "server", "resolver",
|
||||||
"next_hop", "via", "public_ip", "observed_ip",
|
"next_hop", "via", "public_ip", "observed_ip",
|
||||||
|
// Who sent each passive-discovery announcement (SSDP / LLMNR / NetBIOS / WS-Discovery).
|
||||||
|
// Family-agnostic on purpose: the same field carries a dotted quad from a v4 group and
|
||||||
|
// a link-local from ff02::c, and the v6 transform hands dotted quads to the v4 path.
|
||||||
|
"source_ip",
|
||||||
).forEach { put(it, LogicalType.IP6) }
|
).forEach { put(it, LogicalType.IP6) }
|
||||||
|
|
||||||
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
|
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
|
||||||
@@ -52,13 +56,28 @@ object Classification {
|
|||||||
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
|
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
|
||||||
// Plural and prefixed variants the models actually use.
|
// Plural and prefixed variants the models actually use.
|
||||||
"search_domains", "private_dns_hostname", "domains", "hostnames",
|
"search_domains", "private_dns_hostname", "domains", "hostnames",
|
||||||
|
// Names a device shouts at the whole segment. An LLMNR question and a NetBIOS
|
||||||
|
// registration are hostnames in every sense that matters here — they name a machine on
|
||||||
|
// somebody's home network — so they get the same per-label treatment as any other.
|
||||||
|
"netbios_name",
|
||||||
).forEach { put(it, LogicalType.FQDN) }
|
).forEach { put(it, LogicalType.FQDN) }
|
||||||
|
|
||||||
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
|
listOf(
|
||||||
.forEach { put(it, LogicalType.OPAQUE_ID) }
|
"session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid",
|
||||||
|
// Discovery identities. A UPnP USN and a WS-Discovery endpoint UUID are stable,
|
||||||
|
// globally unique per device and frequently derived from a serial number — exactly the
|
||||||
|
// thing that lets two uploads be recognised as the same household.
|
||||||
|
"usn", "device_uuid",
|
||||||
|
).forEach { put(it, LogicalType.OPAQUE_ID) }
|
||||||
|
|
||||||
listOf("notes", "detail", "raw", "excerpt", "location", "model_description")
|
listOf(
|
||||||
.forEach { put(it, LogicalType.FREETEXT) }
|
"notes", "detail", "raw", "excerpt", "location", "model_description",
|
||||||
|
// The make/model a device volunteers, and the URLs it points at. `server_banner` is
|
||||||
|
// deliberately not named `server`, which the family-agnostic address block already
|
||||||
|
// claims — a SERVER header run through the address transform would be mangled into
|
||||||
|
// nonsense while protecting nothing.
|
||||||
|
"server_banner", "product_hint", "wsd_types", "wsd_xaddrs",
|
||||||
|
).forEach { put(it, LogicalType.FREETEXT) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +98,13 @@ object Classification {
|
|||||||
private val droppedKeys = setOf(
|
private val droppedKeys = setOf(
|
||||||
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
|
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
|
||||||
"nearby_networks", "peers", "raw_dump", "dumpsys",
|
"nearby_networks", "peers", "raw_dump", "dumpsys",
|
||||||
|
// The long run's passive-discovery inventories. Same argument as `ssdp_responders`, only
|
||||||
|
// more so: these are minutes of everything the segment said about itself — device models,
|
||||||
|
// hostnames, printers, who is looking for whom. Even fully pseudonymized the *shape* of a
|
||||||
|
// household is a fingerprint, no metric depends on the list (the counts live in `metrics`,
|
||||||
|
// which survives), and the collectors' own status fields stay behind to say the capture
|
||||||
|
// worked. Dropping beats mangling.
|
||||||
|
"ssdp_devices", "llmnr_queries", "netbios_names", "wsd_devices",
|
||||||
)
|
)
|
||||||
|
|
||||||
fun typeOf(key: String, path: List<String>): LogicalType? {
|
fun typeOf(key: String, path: List<String>): LogicalType? {
|
||||||
|
|||||||
@@ -28,4 +28,9 @@ dependencies {
|
|||||||
implementation(project(":core-measurement"))
|
implementation(project(":core-measurement"))
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
// JVM unit tests for the pure wire-format decoders (DiscoveryParsers.kt) against realistic and
|
||||||
|
// deliberately malformed payloads — no device, no Android runtime. Same setup as core-shizuku's
|
||||||
|
// dump-parser tests: JUnit 4, because that is what AGP's unit-test source set runs.
|
||||||
|
testImplementation(libs.kotlin.test.junit)
|
||||||
|
testImplementation(libs.junit4)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire-format decoders for the passive discovery collectors (SSDP, LLMNR, NetBIOS-NS, WS-Discovery).
|
||||||
|
*
|
||||||
|
* No Android imports on purpose: every byte these see was broadcast by an unidentified device on
|
||||||
|
* someone else's network, so they are the part of the collectors that most needs to be exercised
|
||||||
|
* against malformed input — and that is only cheap to do if it runs on a plain JVM. See
|
||||||
|
* DiscoveryParsersTest.
|
||||||
|
*
|
||||||
|
* The universal contract here is **return null, never throw**. A collector that dies on one
|
||||||
|
* malformed datagram loses the whole window's inventory, and a device that emits garbage is a
|
||||||
|
* device we still want counted. Every decoder therefore bounds-checks by hand rather than relying
|
||||||
|
* on an exception, and treats "this is not the protocol I parse" and "this is the protocol but it
|
||||||
|
* is broken" as the same answer: nothing to record.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- shared -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a decoded string safe to embed in a JSON document.
|
||||||
|
*
|
||||||
|
* Names arrive as arbitrary bytes. Control characters would survive JSON encoding as escapes and
|
||||||
|
* turn up in a terminal that interprets them, and an unbounded length is a memory cost decided by
|
||||||
|
* whoever is shouting on the segment — so both are capped here rather than at each call site.
|
||||||
|
*/
|
||||||
|
internal fun sanitizeText(s: String, max: Int = 255): String {
|
||||||
|
val sb = StringBuilder(minOf(s.length, max))
|
||||||
|
for (c in s) {
|
||||||
|
if (sb.length >= max) break
|
||||||
|
sb.append(if (c.isISOControl() || c == '�') '?' else c)
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun u8(b: ByteArray, i: Int) = b[i].toInt() and 0xFF
|
||||||
|
private fun u16(b: ByteArray, i: Int) = (u8(b, i) shl 8) or u8(b, i + 1)
|
||||||
|
|
||||||
|
// ---- SSDP ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum class SsdpKind { ALIVE, BYEBYE, UPDATE, RESPONSE, SEARCH, OTHER }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One SSDP message. [target] is NT for announcements and ST for search responses — they name the
|
||||||
|
* same thing (which service is being talked about) from the two sides of the conversation, so they
|
||||||
|
* collapse into one field.
|
||||||
|
*/
|
||||||
|
data class SsdpMessage(
|
||||||
|
val kind: SsdpKind,
|
||||||
|
val target: String?,
|
||||||
|
val usn: String?,
|
||||||
|
val serverBanner: String?,
|
||||||
|
val location: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
object SsdpParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSDP is HTTP-shaped but is not HTTP: there is no framing, no content length, and vendors
|
||||||
|
* disagree about line endings. Parsing it as "a start line plus colon-separated headers, be
|
||||||
|
* liberal about the rest" is the whole job — running it through an HTTP client would reject
|
||||||
|
* messages that real devices send and that we want to count.
|
||||||
|
*
|
||||||
|
* ISO-8859-1 decoding because the headers are byte-oriented and this mapping is total: no byte
|
||||||
|
* sequence can fail to decode, so a device with a Latin-1 model name in its SERVER banner is
|
||||||
|
* recorded rather than replaced by question marks.
|
||||||
|
*/
|
||||||
|
fun parse(bytes: ByteArray, len: Int): SsdpMessage? {
|
||||||
|
if (len <= 0 || len > bytes.size) return null
|
||||||
|
val text = String(bytes, 0, len, Charsets.ISO_8859_1)
|
||||||
|
val lines = text.split('\n')
|
||||||
|
val start = lines.firstOrNull()?.trim().orEmpty()
|
||||||
|
if (start.isEmpty()) return null
|
||||||
|
|
||||||
|
val headers = HashMap<String, String>()
|
||||||
|
for (i in 1 until lines.size) {
|
||||||
|
val line = lines[i].trimEnd('\r')
|
||||||
|
if (line.isBlank()) break
|
||||||
|
val c = line.indexOf(':')
|
||||||
|
if (c <= 0) continue
|
||||||
|
val key = line.substring(0, c).trim().uppercase()
|
||||||
|
// First occurrence wins: a duplicated header is a device bug, and taking the first
|
||||||
|
// matches what every SSDP implementation in the wild does.
|
||||||
|
if (key !in headers) headers[key] = sanitizeText(line.substring(c + 1).trim(), 512)
|
||||||
|
}
|
||||||
|
|
||||||
|
val kind = when {
|
||||||
|
start.startsWith("NOTIFY", ignoreCase = true) -> when (headers["NTS"]?.lowercase()) {
|
||||||
|
"ssdp:alive" -> SsdpKind.ALIVE
|
||||||
|
"ssdp:byebye" -> SsdpKind.BYEBYE
|
||||||
|
"ssdp:update" -> SsdpKind.UPDATE
|
||||||
|
else -> SsdpKind.OTHER
|
||||||
|
}
|
||||||
|
start.startsWith("M-SEARCH", ignoreCase = true) -> SsdpKind.SEARCH
|
||||||
|
start.startsWith("HTTP/", ignoreCase = true) -> SsdpKind.RESPONSE
|
||||||
|
else -> return null // not SSDP at all
|
||||||
|
}
|
||||||
|
|
||||||
|
return SsdpMessage(
|
||||||
|
kind = kind,
|
||||||
|
target = headers["NT"] ?: headers["ST"],
|
||||||
|
usn = headers["USN"],
|
||||||
|
serverBanner = headers["SERVER"],
|
||||||
|
location = headers["LOCATION"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The make/model inside a SERVER banner, as a hint.
|
||||||
|
*
|
||||||
|
* A banner reads `Linux/4.4 UPnP/1.0 Synology/DSM-7.3` or `FRITZ!Box 7590 UPnP/1.0`: the
|
||||||
|
* interesting token is whichever one is not the OS and not the protocol version, and there is
|
||||||
|
* no grammar that says which. Dropping the known-boilerplate tokens and keeping the rest is
|
||||||
|
* therefore a heuristic and is reported as such — [SsdpMessage.serverBanner] is kept verbatim
|
||||||
|
* beside it so nobody has to trust this to read the evidence.
|
||||||
|
*/
|
||||||
|
fun productHint(serverBanner: String?): String? {
|
||||||
|
val banner = serverBanner?.trim().orEmpty()
|
||||||
|
if (banner.isEmpty()) return null
|
||||||
|
val kept = banner.split(' ', '\t')
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() && it.substringBefore('/').lowercase() !in BOILERPLATE }
|
||||||
|
.distinct()
|
||||||
|
return kept.joinToString(" ").takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val BOILERPLATE = setOf(
|
||||||
|
"upnp", "http", "dlnadoc", "linux", "unix", "windows", "darwin", "posix", "sdk",
|
||||||
|
"upnp-device-host", "microsoft-windows", "mono.upnp", "webos", "android",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DNS-format questions (LLMNR, and the shape NetBIOS-NS borrows) -------------------------
|
||||||
|
|
||||||
|
/** One question from a DNS-format packet. [isQuery] separates "who is asking" from "who answered". */
|
||||||
|
data class DnsQuestion(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val qtype: Int,
|
||||||
|
val qclass: Int,
|
||||||
|
val isQuery: Boolean,
|
||||||
|
val opcode: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
object LlmnrParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the first question of an LLMNR packet, which is DNS wire format with a different
|
||||||
|
* transport.
|
||||||
|
*
|
||||||
|
* Name compression is rejected rather than followed. RFC 4795 forbids it in LLMNR, so a pointer
|
||||||
|
* here is either a broken sender or someone hoping the parser will chase it — and a pointer
|
||||||
|
* loop is the classic way to hang a DNS decoder. Refusing costs nothing real and removes the
|
||||||
|
* only unbounded loop this decoder could have had.
|
||||||
|
*/
|
||||||
|
fun parse(bytes: ByteArray, len: Int): DnsQuestion? {
|
||||||
|
if (len < HEADER + 5 || len > bytes.size) return null
|
||||||
|
val flags = u16(bytes, 2)
|
||||||
|
if (u16(bytes, 4) < 1) return null // no question section
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
var i = HEADER
|
||||||
|
var labels = 0
|
||||||
|
while (true) {
|
||||||
|
if (i >= len) return null
|
||||||
|
val l = u8(bytes, i)
|
||||||
|
if (l == 0) { i++; break }
|
||||||
|
if (l and 0xC0 != 0) return null // compression pointer / reserved length
|
||||||
|
i++
|
||||||
|
if (i + l > len) return null
|
||||||
|
if (++labels > MAX_LABELS || sb.length + l > MAX_NAME) return null
|
||||||
|
if (sb.isNotEmpty()) sb.append('.')
|
||||||
|
sb.append(String(bytes, i, l, Charsets.UTF_8))
|
||||||
|
i += l
|
||||||
|
}
|
||||||
|
if (sb.isEmpty()) return null
|
||||||
|
if (i + 4 > len) return null
|
||||||
|
|
||||||
|
return DnsQuestion(
|
||||||
|
id = u16(bytes, 0),
|
||||||
|
name = sanitizeText(sb.toString()),
|
||||||
|
qtype = u16(bytes, i),
|
||||||
|
qclass = u16(bytes, i + 2),
|
||||||
|
isQuery = (flags and 0x8000) == 0,
|
||||||
|
opcode = (flags shr 11) and 0x0F,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The record types worth naming in evidence; anything else is reported as its number. */
|
||||||
|
fun qtypeName(qtype: Int): String = when (qtype) {
|
||||||
|
1 -> "A"
|
||||||
|
28 -> "AAAA"
|
||||||
|
12 -> "PTR"
|
||||||
|
33 -> "SRV"
|
||||||
|
255 -> "ANY"
|
||||||
|
else -> qtype.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val HEADER = 12
|
||||||
|
private const val MAX_LABELS = 64
|
||||||
|
private const val MAX_NAME = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NetBIOS name service ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A decoded NetBIOS name-service question.
|
||||||
|
*
|
||||||
|
* [suffix] is the sixteenth byte of the name and is the part an engineer reads first: it says what
|
||||||
|
* the announcement is *for* (a workstation, a file server, a browser election) rather than who is
|
||||||
|
* making it.
|
||||||
|
*/
|
||||||
|
data class NetbiosName(
|
||||||
|
val name: String,
|
||||||
|
val suffix: Int,
|
||||||
|
val role: String,
|
||||||
|
val isResponse: Boolean,
|
||||||
|
val opcode: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
object NetbiosParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the question name from an NBNS packet (RFC 1002 §4.2).
|
||||||
|
*
|
||||||
|
* The header is DNS-shaped, but the name is not: NetBIOS first-level encoding splits each of
|
||||||
|
* the 16 name bytes into two nibbles and adds 'A' to each, so a 16-byte name is always exactly
|
||||||
|
* 32 characters drawn from A-P. That fixed shape is also the validity check — anything outside
|
||||||
|
* A-P means this is not an NBNS name, and it is cheaper and safer to reject the packet than to
|
||||||
|
* guess at what a half-decodable name was supposed to say.
|
||||||
|
*/
|
||||||
|
fun parse(bytes: ByteArray, len: Int): NetbiosName? {
|
||||||
|
if (len < HEADER + 34 || len > bytes.size) return null
|
||||||
|
if (u16(bytes, 4) < 1) return null // no question section
|
||||||
|
if (u8(bytes, HEADER) != ENCODED_LEN) return null
|
||||||
|
|
||||||
|
val raw = ByteArray(16)
|
||||||
|
for (j in 0 until 16) {
|
||||||
|
val hi = u8(bytes, HEADER + 1 + j * 2) - 'A'.code
|
||||||
|
val lo = u8(bytes, HEADER + 2 + j * 2) - 'A'.code
|
||||||
|
if (hi !in 0..15 || lo !in 0..15) return null
|
||||||
|
raw[j] = ((hi shl 4) or lo).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first 15 bytes are the name, space-padded; the 16th is the suffix.
|
||||||
|
val padded = String(raw, 0, 15, Charsets.ISO_8859_1)
|
||||||
|
val name = sanitizeText(padded.trimEnd { it == ' ' || it.isISOControl() }, 15)
|
||||||
|
if (name.isEmpty()) return null
|
||||||
|
val suffix = raw[15].toInt() and 0xFF
|
||||||
|
val flags = u16(bytes, 2)
|
||||||
|
return NetbiosName(
|
||||||
|
name = name,
|
||||||
|
suffix = suffix,
|
||||||
|
role = roleOf(suffix),
|
||||||
|
isResponse = (flags and 0x8000) != 0,
|
||||||
|
opcode = (flags shr 11) and 0x0F,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 1001 §15 / the Microsoft suffix assignments people actually see on a LAN. */
|
||||||
|
fun roleOf(suffix: Int): String = when (suffix) {
|
||||||
|
0x00 -> "workstation"
|
||||||
|
0x03 -> "messenger"
|
||||||
|
0x1B -> "domain_master_browser"
|
||||||
|
0x1C -> "domain_controllers"
|
||||||
|
0x1D -> "master_browser"
|
||||||
|
0x1E -> "browser_elections"
|
||||||
|
0x20 -> "file_server"
|
||||||
|
else -> "suffix_0x%02X".format(suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NBNS opcodes: what the sender is doing, not just that it is talking. */
|
||||||
|
fun opcodeName(opcode: Int): String = when (opcode) {
|
||||||
|
0 -> "query"
|
||||||
|
5 -> "registration"
|
||||||
|
6 -> "release"
|
||||||
|
7 -> "wack"
|
||||||
|
8 -> "refresh"
|
||||||
|
else -> "opcode_$opcode"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val HEADER = 12
|
||||||
|
private const val ENCODED_LEN = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WS-Discovery --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The four things a WS-Discovery datagram is worth reading for. */
|
||||||
|
data class WsdMessage(
|
||||||
|
val action: String?,
|
||||||
|
val deviceUuid: String?,
|
||||||
|
val types: String?,
|
||||||
|
val xaddrs: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
object WsdParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls four leaf values out of SOAP-over-UDP by targeted matching, deliberately **without an
|
||||||
|
* XML parser**.
|
||||||
|
*
|
||||||
|
* The input is unauthenticated, unsolicited, and written by whatever is on the segment. Handing
|
||||||
|
* that to a real XML parser buys namespace correctness and pays for it with the whole XML
|
||||||
|
* attack surface — entity expansion (a 700-byte datagram that allocates gigabytes), DTDs that
|
||||||
|
* fetch external resources, and nesting deep enough to exhaust the stack — all inside a probe
|
||||||
|
* whose contract is that it never throws. None of that surface is needed to read four leaf
|
||||||
|
* elements out of a message we are only ever going to count and quote.
|
||||||
|
*
|
||||||
|
* So: cap the text, then match `<[prefix:]Tag ...>value<` with a character class that cannot
|
||||||
|
* backtrack. The worst case is a value we fail to extract, which is recorded as an absence.
|
||||||
|
*/
|
||||||
|
fun parse(bytes: ByteArray, len: Int): WsdMessage? {
|
||||||
|
if (len <= 0 || len > bytes.size) return null
|
||||||
|
val text = String(bytes, 0, minOf(len, MAX_TEXT), Charsets.UTF_8)
|
||||||
|
if (!text.contains("Envelope", ignoreCase = true)) return null
|
||||||
|
|
||||||
|
// The action tail is the message type — Hello, Bye, Probe, ProbeMatches, ResolveMatches.
|
||||||
|
val action = leaf(text, "Action")?.substringAfterLast('/')?.takeIf { it.isNotEmpty() }
|
||||||
|
// The device's stable identity is an EndpointReference/Address holding a urn:uuid. Other
|
||||||
|
// Address elements exist (wsa:To names the discovery group), so the uuid shape picks the
|
||||||
|
// right one rather than the first one.
|
||||||
|
val uuid = leaves(text, "Address").firstOrNull { it.contains("uuid:", ignoreCase = true) }
|
||||||
|
val msg = WsdMessage(
|
||||||
|
action = action?.let { sanitizeText(it, 64) },
|
||||||
|
deviceUuid = uuid?.let { sanitizeText(it, 128) },
|
||||||
|
types = leaf(text, "Types")?.let { sanitizeText(it, 200) },
|
||||||
|
xaddrs = leaf(text, "XAddrs")?.let { sanitizeText(it, 300) },
|
||||||
|
)
|
||||||
|
val empty = msg.action == null && msg.deviceUuid == null &&
|
||||||
|
msg.types == null && msg.xaddrs == null
|
||||||
|
return if (empty) null else msg
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun leaf(xml: String, tag: String): String? = leaves(xml, tag).firstOrNull()
|
||||||
|
|
||||||
|
private fun leaves(xml: String, tag: String): List<String> {
|
||||||
|
val re = TAGS[tag] ?: return emptyList()
|
||||||
|
return re.findAll(xml).map { it.groupValues[1].trim() }.filter { it.isNotEmpty() }.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `[^<]{0,512}` rather than a lazy `.*?`: it can only ever match forward, so there is no input
|
||||||
|
* that makes this regex expensive. WS-Discovery leaves hold no markup, so nothing is lost.
|
||||||
|
*
|
||||||
|
* Built once, eagerly, because the alternative is a memoizing map touched from the capture
|
||||||
|
* thread — a data race for the sake of four Regex allocations.
|
||||||
|
*/
|
||||||
|
private val TAGS: Map<String, Regex> =
|
||||||
|
listOf("Action", "Address", "Types", "XAddrs").associateWith { tag ->
|
||||||
|
Regex(
|
||||||
|
"""<(?:[A-Za-z0-9_.\-]{1,32}:)?$tag\b[^>]{0,256}>([^<]{0,512})<""",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_TEXT = 16 * 1024
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestError
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
/**
|
||||||
|
* local.llmnr_inventory — who is resolving names with LLMNR on this segment, and what for.
|
||||||
|
*
|
||||||
|
* The measurement is twofold, and the second half is the one people underestimate:
|
||||||
|
*
|
||||||
|
* 1. **Which hostnames are being looked up.** An LLMNR query is a device saying out loud, to
|
||||||
|
* everyone, the name of something it wants to reach. Over a window that is a map of who talks
|
||||||
|
* to whom — and, when the names are things like `wpad` or a server that no longer exists, a map
|
||||||
|
* of what is failing to resolve through DNS and falling back.
|
||||||
|
* 2. **That LLMNR is in use at all.** LLMNR (and its NetBIOS sibling) is a name-resolution
|
||||||
|
* fallback that trusts whoever answers first, which is the mechanism behind the standard
|
||||||
|
* credential-relay attack on Windows networks. Its mere presence on a segment is a finding
|
||||||
|
* independent of any individual query — which is why this earns a test id rather than folding
|
||||||
|
* into the mDNS inventory.
|
||||||
|
*
|
||||||
|
* Purely passive: queries are broadcast to the group, so listening is the entire measurement, and
|
||||||
|
* answering or querying would make this device a participant in exactly the trust relationship the
|
||||||
|
* measurement is about.
|
||||||
|
*/
|
||||||
|
class LlmnrCollector : BaseCollector() {
|
||||||
|
|
||||||
|
override val type = TestType.LOCAL_LLMNR_INVENTORY
|
||||||
|
override val tier = Tier.APP
|
||||||
|
|
||||||
|
private val capture = MulticastCapture(
|
||||||
|
label = "llmnr",
|
||||||
|
port = PORT,
|
||||||
|
group4 = GROUP4,
|
||||||
|
// ff02::1:3 is the IPv6 LLMNR group. Joined where IPv6 exists; a v4-only network simply
|
||||||
|
// records an empty joined_v6 rather than an error, because that is not one.
|
||||||
|
group6 = GROUP6,
|
||||||
|
)
|
||||||
|
|
||||||
|
private var startedAtMonoNs = 0L
|
||||||
|
|
||||||
|
override suspend fun start(ctx: Context, ids: ProbeIds) {
|
||||||
|
begin(ids)
|
||||||
|
startedAtMonoNs = ids.monoNs()
|
||||||
|
capture.acquireLock(ctx)
|
||||||
|
capture.start(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stop(): Test {
|
||||||
|
val packets = capture.stop()
|
||||||
|
|
||||||
|
val table = DiscoveryTable()
|
||||||
|
var queries = 0
|
||||||
|
var responses = 0
|
||||||
|
var undecodable = 0
|
||||||
|
|
||||||
|
for (p in packets) {
|
||||||
|
val q = LlmnrParser.parse(p.data, p.data.size)
|
||||||
|
if (q == null) {
|
||||||
|
undecodable++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (q.isQuery) queries++ else responses++
|
||||||
|
// Responses are normally unicast back to the querier, so what lands here is
|
||||||
|
// overwhelmingly queries — but a response that does reach the group is still a device
|
||||||
|
// claiming a name, which is worth the same row.
|
||||||
|
table.observe(p.sourceIp, "${q.name}/${q.qtype}", p.atMonoNs) {
|
||||||
|
buildJsonObject {
|
||||||
|
put("query_name", q.name)
|
||||||
|
put("qtype", LlmnrParser.qtypeName(q.qtype))
|
||||||
|
put("kind", if (q.isQuery) "query" else "response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("capture", capture.statusJson())
|
||||||
|
put("llmnr_queries", table.toJson())
|
||||||
|
put("evidence_truncated", capture.truncated || table.overflowed)
|
||||||
|
}
|
||||||
|
val metrics: JsonObject = buildJsonObject {
|
||||||
|
put("distinct_sources", table.distinctSources)
|
||||||
|
put("distinct_names", table.size)
|
||||||
|
put("packets", capture.packetsSeen)
|
||||||
|
put("queries", queries)
|
||||||
|
put("responses", responses)
|
||||||
|
put("undecodable_packets", undecodable)
|
||||||
|
// The headline: whether this protocol is live here at all. A boolean rather than an
|
||||||
|
// inference from a count, so a reader (or a future finding rule) never has to decide
|
||||||
|
// what "zero packets" meant — the capture's own status says whether zero is trustworthy.
|
||||||
|
put("llmnr_in_use", table.distinctSources > 0)
|
||||||
|
put("truncated", capture.truncated || table.overflowed)
|
||||||
|
}
|
||||||
|
val saw = !table.isEmpty
|
||||||
|
return build(
|
||||||
|
capture.outcome(saw),
|
||||||
|
evidence = evidence,
|
||||||
|
metrics = metrics,
|
||||||
|
params = params(),
|
||||||
|
error = capture.reason(saw)?.let { TestError("listen_incomplete", it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun params(): JsonObject = buildJsonObject {
|
||||||
|
put("mode", "passive")
|
||||||
|
put("group_v4", "$GROUP4:$PORT")
|
||||||
|
put("group_v6", "[$GROUP6]:$PORT")
|
||||||
|
put("started_mono_ns", startedAtMonoNs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PORT = 5355
|
||||||
|
const val GROUP4 = "224.0.0.252"
|
||||||
|
const val GROUP6 = "ff02::1:3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonArray
|
||||||
|
import kotlinx.serialization.json.add
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.MulticastSocket
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.net.SocketAddress
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listening half of every passive-discovery collector: hold a multicast lock, bind a
|
||||||
|
* well-known UDP port, join a group on every interface that will take it, and retain the datagrams
|
||||||
|
* that arrive until the window closes.
|
||||||
|
*
|
||||||
|
* Four things here are the difference between a working listener and one that silently reports an
|
||||||
|
* empty network, and each of them has cost somebody a day:
|
||||||
|
*
|
||||||
|
* - **The MulticastLock.** Android's wifi stack filters out frames not addressed to this device
|
||||||
|
* unless an app holds one. Without it every collector built on this returns "nothing on this
|
||||||
|
* network" on a network full of chatter — the single most common reason a listener like this
|
||||||
|
* appears to work and measures nothing. Whether it was actually held is therefore reported, not
|
||||||
|
* assumed: an unheld lock plus silence is not a clean result and [outcome] refuses to call it one.
|
||||||
|
* - **SO_REUSEADDR before bind.** The well-known discovery ports are shared by construction — the
|
||||||
|
* system's own mDNS/SSDP responders and any other app doing this are already there — so binding
|
||||||
|
* exclusively fails on exactly the networks worth measuring.
|
||||||
|
* - **Joining per interface.** The group must be joined on the interface the traffic arrives on,
|
||||||
|
* which is not necessarily the default route: a phone with wifi plus a VPN plus cellular has
|
||||||
|
* three, and the LAN chatter is on the one that is not carrying the default route.
|
||||||
|
* - **A bound buffer, bounded per source too.** A chatty network must not decide how much memory
|
||||||
|
* this uses, and one device announcing every two seconds must not be able to fill the buffer and
|
||||||
|
* hide the twenty quieter devices behind it. Both caps set [truncated] rather than being silent.
|
||||||
|
*
|
||||||
|
* Nothing here throws. Every failure lands in [failure] (the capture is dead) or [degraded] (it is
|
||||||
|
* running but cannot see everything), which the owning collector turns into a recorded status.
|
||||||
|
*/
|
||||||
|
internal class MulticastCapture(
|
||||||
|
/** Names the MulticastLock, so a `dumpsys wifi` during a run says which listener holds what. */
|
||||||
|
private val label: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val group4: String? = null,
|
||||||
|
private val group6: String? = null,
|
||||||
|
private val maxPackets: Int = 400,
|
||||||
|
private val maxBytesRetained: Int = 128 * 1024,
|
||||||
|
private val maxPerSource: Int = 24,
|
||||||
|
/**
|
||||||
|
* Whether to fall back to an ephemeral port when the well-known one cannot be bound.
|
||||||
|
*
|
||||||
|
* Only useful for a protocol with an active half: replies to our own searches come back to
|
||||||
|
* whatever port we sent from, so an ephemeral socket still collects those — but it can never
|
||||||
|
* see the unsolicited announcements, which is why it is [degraded] and not normal operation.
|
||||||
|
*/
|
||||||
|
private val allowEphemeralFallback: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** One retained datagram. Not a data class: the payload is a ByteArray, and structural equality
|
||||||
|
* over it would be both wrong and expensive. */
|
||||||
|
class Packet(val sourceIp: String, val atMonoNs: Long, val data: ByteArray)
|
||||||
|
|
||||||
|
/** Set when the capture could not be brought up at all; the collector reports `unsupported`. */
|
||||||
|
var failure: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Set when the capture is running but blind to part of what it exists to see. */
|
||||||
|
var degraded: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
var lockHeld: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
var boundPort: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
var packetsSeen: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
var truncated: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
val joined4 = ArrayList<String>()
|
||||||
|
val joined6 = ArrayList<String>()
|
||||||
|
private val joinErrors = ArrayList<String>()
|
||||||
|
|
||||||
|
/** Whether this protocol needs a group join at all — NetBIOS is broadcast, not multicast. */
|
||||||
|
private val expectsGroup = group4 != null || group6 != null
|
||||||
|
|
||||||
|
private val packets = ArrayList<Packet>()
|
||||||
|
private val perSource = HashMap<String, Int>()
|
||||||
|
private var retainedBytes = 0
|
||||||
|
|
||||||
|
@Volatile private var running = false
|
||||||
|
private var socket: MulticastSocket? = null
|
||||||
|
private var lock: WifiManager.MulticastLock? = null
|
||||||
|
private var scope: CoroutineScope? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings the listener up and returns whether it is capturing. Setup is a handful of syscalls,
|
||||||
|
* so it finishes in milliseconds and the [Collector.start] promptness contract holds; only the
|
||||||
|
* receive loop is handed to a background scope.
|
||||||
|
*/
|
||||||
|
suspend fun start(ids: ProbeIds): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val s = bind() ?: return@withContext false
|
||||||
|
socket = s
|
||||||
|
running = true
|
||||||
|
// A degraded (ephemeral-port) socket is deliberately not joined to the group: it would be
|
||||||
|
// joining for a port nothing sends to, and the resulting "joined wlan0" in the evidence
|
||||||
|
// would claim a capability the capture does not have.
|
||||||
|
if (expectsGroup && degraded == null) joinGroups(s)
|
||||||
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { it.launch { pump(s, ids) } }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acquires the multicast lock. Separate from [start] because it needs a Context and the rest
|
||||||
|
* does not, and because whether it succeeded is itself evidence. */
|
||||||
|
fun acquireLock(ctx: Context) {
|
||||||
|
val wifi = runCatching { ctx.applicationContext.getSystemService(WifiManager::class.java) }
|
||||||
|
.getOrNull()
|
||||||
|
lock = runCatching {
|
||||||
|
wifi?.createMulticastLock("echolot-$label")?.apply {
|
||||||
|
setReferenceCounted(false)
|
||||||
|
acquire()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
lockHeld = runCatching { lock?.isHeld == true }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends from the capture socket, so replies land back in this capture rather than on a second
|
||||||
|
* socket nobody is reading. Returns whether the datagram left the device. */
|
||||||
|
fun send(payload: ByteArray, host: String, toPort: Int): Boolean = runCatching {
|
||||||
|
val s = socket ?: return false
|
||||||
|
s.send(DatagramPacket(payload, payload.size, InetSocketAddress(host, toPort)))
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
/** Stops listening and hands back what was retained. Safe after a failed [start], and never
|
||||||
|
* throws — a cancelled run still owes the caller the packets it did see. */
|
||||||
|
fun stop(): List<Packet> {
|
||||||
|
running = false
|
||||||
|
// Closed before the coroutine is cancelled: a blocking receive() does not notice
|
||||||
|
// cancellation, and closing the socket is what makes it return.
|
||||||
|
runCatching { socket?.close() }
|
||||||
|
socket = null
|
||||||
|
scope?.coroutineContext?.get(Job)?.cancel()
|
||||||
|
scope = null
|
||||||
|
runCatching { lock?.release() }
|
||||||
|
lock = null
|
||||||
|
// lockHeld deliberately survives the release: it records whether the capture *could* hear
|
||||||
|
// multicast while it ran, which is what [outcome] needs to decide whether silence is a
|
||||||
|
// fact about the network. Clearing it here would make every quiet network report that the
|
||||||
|
// lock was missing.
|
||||||
|
return synchronized(packets) { packets.toList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- status ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status this capture's Test deserves, given whether anything was decoded.
|
||||||
|
*
|
||||||
|
* The interesting case is the last one. Silence on a network is a legitimate and useful
|
||||||
|
* finding — but only when we know the listener could have heard something. Without the
|
||||||
|
* multicast lock, or without a single successful group join, "nothing was seen" describes this
|
||||||
|
* app and not the network, and reporting it as `ok` would be the collector lying by omission.
|
||||||
|
*/
|
||||||
|
fun outcome(sawAnything: Boolean): TestStatus = when {
|
||||||
|
failure != null -> TestStatus.UNSUPPORTED
|
||||||
|
degraded != null -> TestStatus.PARTIAL
|
||||||
|
sawAnything -> TestStatus.OK
|
||||||
|
expectsGroup && joined4.isEmpty() && joined6.isEmpty() -> TestStatus.PARTIAL
|
||||||
|
!lockHeld -> TestStatus.PARTIAL
|
||||||
|
else -> TestStatus.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why [outcome] was not OK, in words, or null when it was. */
|
||||||
|
fun reason(sawAnything: Boolean): String? = when {
|
||||||
|
failure != null -> failure
|
||||||
|
degraded != null -> degraded
|
||||||
|
sawAnything -> null
|
||||||
|
expectsGroup && joined4.isEmpty() && joined6.isEmpty() ->
|
||||||
|
"no interface accepted the group join, so silence here says nothing about the network"
|
||||||
|
!lockHeld ->
|
||||||
|
"the wifi multicast lock was not held, so silence here says nothing about the network"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The capture's own facts, for the collector's evidence. Every collector reports these
|
||||||
|
* identically so that "saw nothing" can always be told apart from "could not listen". */
|
||||||
|
fun statusJson(): JsonObject = buildJsonObject {
|
||||||
|
put("multicast_lock", lockHeld)
|
||||||
|
put("bound_port", boundPort)
|
||||||
|
putJsonArray("joined_v4") { for (n in joined4) add(n) }
|
||||||
|
putJsonArray("joined_v6") { for (n in joined6) add(n) }
|
||||||
|
if (joinErrors.isNotEmpty()) {
|
||||||
|
put("join_errors", joinErrors.take(6).joinToString("; ").take(400))
|
||||||
|
}
|
||||||
|
degraded?.let { put("degraded", it) }
|
||||||
|
failure?.let { put("failure", it) }
|
||||||
|
put("packets_seen", packetsSeen)
|
||||||
|
put("packets_retained", synchronized(packets) { packets.size })
|
||||||
|
put("evidence_truncated", truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internals ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun bind(): MulticastSocket? {
|
||||||
|
// Unbound first, so SO_REUSEADDR is set *before* bind — setting it afterwards has no
|
||||||
|
// effect, and these ports are always already in use by something.
|
||||||
|
runCatching {
|
||||||
|
val s = MulticastSocket(null as SocketAddress?)
|
||||||
|
s.reuseAddress = true
|
||||||
|
s.bind(InetSocketAddress(port))
|
||||||
|
s.soTimeout = SO_TIMEOUT_MS
|
||||||
|
boundPort = port
|
||||||
|
return s
|
||||||
|
}.onFailure { first ->
|
||||||
|
val why = describe(first)
|
||||||
|
if (!allowEphemeralFallback) {
|
||||||
|
// Ports below 1024 are privileged on Android as on any Linux, so this is the
|
||||||
|
// expected outcome for NetBIOS and is a finding rather than a bug — see
|
||||||
|
// NetbiosCollector.
|
||||||
|
failure = "could not bind UDP $port: $why"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val s = MulticastSocket(null as SocketAddress?)
|
||||||
|
s.reuseAddress = true
|
||||||
|
s.bind(InetSocketAddress(0))
|
||||||
|
s.soTimeout = SO_TIMEOUT_MS
|
||||||
|
boundPort = s.localPort
|
||||||
|
degraded = "UDP $port could not be bound ($why); listening on an ephemeral port " +
|
||||||
|
"instead, so only replies to our own searches are visible and unsolicited " +
|
||||||
|
"announcements are not"
|
||||||
|
return s
|
||||||
|
}.onFailure { failure = "could not bind UDP $port ($why) or any ephemeral port" }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun joinGroups(s: MulticastSocket) {
|
||||||
|
val ifaces = runCatching { NetworkInterface.getNetworkInterfaces()?.toList() }
|
||||||
|
.getOrNull().orEmpty()
|
||||||
|
.filter {
|
||||||
|
runCatching { it.isUp && !it.isLoopback && it.supportsMulticast() }
|
||||||
|
.getOrDefault(false)
|
||||||
|
}
|
||||||
|
val g4 = group4?.let { runCatching { InetAddress.getByName(it) }.getOrNull() }
|
||||||
|
val g6 = group6?.let { runCatching { InetAddress.getByName(it) }.getOrNull() }
|
||||||
|
|
||||||
|
for (ni in ifaces) {
|
||||||
|
val addrs = runCatching { ni.inetAddresses.toList() }.getOrNull().orEmpty()
|
||||||
|
if (g4 != null && addrs.any { it is Inet4Address }) {
|
||||||
|
join(s, g4, ni)?.let { joined4.add(it) }
|
||||||
|
}
|
||||||
|
// A network with no IPv6 address is not a failure to report as an error — it is a
|
||||||
|
// v4-only network, which is most of them. Only interfaces that could have joined are
|
||||||
|
// asked to.
|
||||||
|
if (g6 != null && addrs.any { it is Inet6Address }) {
|
||||||
|
join(s, g6, ni)?.let { joined6.add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: let the kernel pick the interface. Some vendor builds refuse the explicit
|
||||||
|
// form on the very interface that carries the traffic, and a default-interface join is
|
||||||
|
// better than no listener at all.
|
||||||
|
if (joined4.isEmpty() && joined6.isEmpty()) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
(g4 ?: g6)?.let { g ->
|
||||||
|
runCatching { s.joinGroup(g) }
|
||||||
|
.onSuccess { (if (g is Inet4Address) joined4 else joined6).add("(default)") }
|
||||||
|
.onFailure { joinErrors.add("default: ${describe(it)}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun join(s: MulticastSocket, group: InetAddress, ni: NetworkInterface): String? =
|
||||||
|
runCatching {
|
||||||
|
s.joinGroup(InetSocketAddress(group, boundPort), ni)
|
||||||
|
ni.name
|
||||||
|
}.onFailure {
|
||||||
|
joinErrors.add("${ni.name}/${if (group is Inet4Address) "v4" else "v6"}: ${describe(it)}")
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun pump(s: MulticastSocket, ids: ProbeIds) {
|
||||||
|
val buf = ByteArray(READ_BUFFER)
|
||||||
|
while (running) {
|
||||||
|
val p = DatagramPacket(buf, buf.size)
|
||||||
|
try {
|
||||||
|
s.receive(p)
|
||||||
|
} catch (t: SocketTimeoutException) {
|
||||||
|
// The timeout exists only so this loop notices `running` going false if the socket
|
||||||
|
// close somehow does not wake it. Nothing to record.
|
||||||
|
continue
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// The normal exit: stop() closed the socket underneath us. It is also what a
|
||||||
|
// vanishing interface looks like, and neither is worth a status of its own — the
|
||||||
|
// packets gathered so far are still the measurement.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
packetsSeen++
|
||||||
|
val ip = p.address?.hostAddress ?: continue
|
||||||
|
val len = p.length
|
||||||
|
if (len <= 0) continue
|
||||||
|
synchronized(packets) {
|
||||||
|
val fromThis = perSource[ip] ?: 0
|
||||||
|
if (packets.size >= maxPackets ||
|
||||||
|
retainedBytes + len > maxBytesRetained ||
|
||||||
|
fromThis >= maxPerSource
|
||||||
|
) {
|
||||||
|
truncated = true
|
||||||
|
} else {
|
||||||
|
packets.add(Packet(ip, ids.monoNs(), p.data.copyOf(len)))
|
||||||
|
perSource[ip] = fromThis + 1
|
||||||
|
retainedBytes += len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SO_TIMEOUT_MS = 1_000
|
||||||
|
|
||||||
|
|
||||||
|
/** Larger than any discovery datagram anyone sends; oversized ones are truncated by the
|
||||||
|
* kernel, which the parsers survive by design. */
|
||||||
|
const val READ_BUFFER = 4_096
|
||||||
|
|
||||||
|
fun describe(t: Throwable): String =
|
||||||
|
(t.message ?: t.javaClass.simpleName).take(160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other half of what every discovery collector does: fold a stream of decoded messages into a
|
||||||
|
* deduplicated inventory of *things*, not packets.
|
||||||
|
*
|
||||||
|
* Deduplication is by source **and** identity, never by identity alone. Two devices announcing the
|
||||||
|
* same UPnP service type are two devices, and collapsing them would turn the one measurement worth
|
||||||
|
* having (how many things are on this segment) into a count of protocols. Conversely one device
|
||||||
|
* re-announcing every thirty seconds must not appear ten times — that is what [count] is for, and
|
||||||
|
* the repetition rate is itself readable from count over the window length.
|
||||||
|
*
|
||||||
|
* First-seen is kept on the monotonic clock per the two-clock rule: it answers "was this device
|
||||||
|
* here from the start, or did it appear four minutes in", which is exactly the question a long run
|
||||||
|
* exists to answer and the one a wall-clock stamp cannot be trusted for.
|
||||||
|
*/
|
||||||
|
internal class DiscoveryTable(private val maxEntries: Int = MAX_ENTRIES) {
|
||||||
|
|
||||||
|
private class Row(val firstSeenMonoNs: Long, val fields: JsonObject) {
|
||||||
|
var count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private val rows = LinkedHashMap<Pair<String, String>, Row>()
|
||||||
|
private val sources = HashSet<String>()
|
||||||
|
|
||||||
|
/** True when a network was busy enough that entries had to be dropped — reported, never hidden. */
|
||||||
|
var overflowed = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [fields] is a lambda so the JSON for a repeat sighting is never built: on a chatty segment
|
||||||
|
* the overwhelming majority of packets are the same device saying the same thing again.
|
||||||
|
*/
|
||||||
|
fun observe(sourceIp: String, identity: String, atMonoNs: Long, fields: () -> JsonObject) {
|
||||||
|
sources.add(sourceIp)
|
||||||
|
val key = sourceIp to identity
|
||||||
|
val existing = rows[key]
|
||||||
|
if (existing != null) {
|
||||||
|
existing.count++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (rows.size >= maxEntries) {
|
||||||
|
overflowed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val built = buildJsonObject {
|
||||||
|
put("source_ip", sourceIp)
|
||||||
|
for ((k, v) in fields()) put(k, v)
|
||||||
|
}
|
||||||
|
rows[key] = Row(atMonoNs, built).also { it.count = 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
val distinctSources: Int get() = sources.size
|
||||||
|
val size: Int get() = rows.size
|
||||||
|
val isEmpty: Boolean get() = rows.isEmpty()
|
||||||
|
|
||||||
|
fun toJson(): kotlinx.serialization.json.JsonArray = kotlinx.serialization.json.JsonArray(
|
||||||
|
rows.values.map { r ->
|
||||||
|
JsonObject(
|
||||||
|
r.fields + mapOf(
|
||||||
|
"first_seen_mono_ns" to kotlinx.serialization.json.JsonPrimitive(r.firstSeenMonoNs),
|
||||||
|
"count" to kotlinx.serialization.json.JsonPrimitive(r.count),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Generous enough for any real segment, small enough that a broadcast storm cannot make
|
||||||
|
* one test dominate the document. */
|
||||||
|
const val MAX_ENTRIES = 200
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestError
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
/**
|
||||||
|
* local.netbios_inventory — NetBIOS name-service chatter (UDP 137) on this segment.
|
||||||
|
*
|
||||||
|
* NetBIOS name registration and query traffic is broadcast, not multicast, so there is no group to
|
||||||
|
* join: a socket bound to 137 sees it by being on the segment. Every packet carries a
|
||||||
|
* first-level-encoded name plus a suffix byte saying what the name is *for* — a workstation, a file
|
||||||
|
* server, a master browser — which makes a passive window over it a Windows-side inventory of the
|
||||||
|
* LAN, and, like LLMNR, a security observation in its own right: NBT-NS is the other half of the
|
||||||
|
* classic name-resolution spoofing surface.
|
||||||
|
*
|
||||||
|
* **Expect this to report `unsupported` on the app tier, and read that as a result rather than a
|
||||||
|
* bug.** 137 is below 1024, and Linux — Android included — reserves those ports for privileged
|
||||||
|
* processes; an unprivileged app UID cannot bind one. So the honest measurement here is usually
|
||||||
|
* "this tier cannot observe NetBIOS on this device", recorded with the exact bind error, rather
|
||||||
|
* than a silent absence that reads as a quiet network. The observation belongs to the Shizuku tier,
|
||||||
|
* whose shell UID can bind it; the collector is written now so that the decoder, the evidence shape
|
||||||
|
* and the registry id are settled and tested by the time that lands.
|
||||||
|
*
|
||||||
|
* An active alternative exists — send an NBSTAT query from an ephemeral port and read the unicast
|
||||||
|
* replies — and is deliberately not taken: it is a host sweep, which is scanning rather than
|
||||||
|
* measuring, and it would change what the run does to the network it is observing.
|
||||||
|
*/
|
||||||
|
class NetbiosCollector : BaseCollector() {
|
||||||
|
|
||||||
|
override val type = TestType.LOCAL_NETBIOS_INVENTORY
|
||||||
|
override val tier = Tier.APP
|
||||||
|
|
||||||
|
private val capture = MulticastCapture(
|
||||||
|
label = "netbios",
|
||||||
|
port = PORT,
|
||||||
|
// No group: NBT-NS is subnet broadcast. The multicast lock is still taken, because
|
||||||
|
// Android's wifi firmware filters broadcast as well as multicast under power save.
|
||||||
|
group4 = null,
|
||||||
|
group6 = null,
|
||||||
|
// Registration bursts repeat the same name several times a second; the per-source cap is
|
||||||
|
// what keeps one noisy Windows box from filling the buffer.
|
||||||
|
maxPerSource = 12,
|
||||||
|
)
|
||||||
|
|
||||||
|
private var startedAtMonoNs = 0L
|
||||||
|
|
||||||
|
override suspend fun start(ctx: Context, ids: ProbeIds) {
|
||||||
|
begin(ids)
|
||||||
|
startedAtMonoNs = ids.monoNs()
|
||||||
|
capture.acquireLock(ctx)
|
||||||
|
capture.start(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stop(): Test {
|
||||||
|
val packets = capture.stop()
|
||||||
|
|
||||||
|
val table = DiscoveryTable()
|
||||||
|
var queries = 0
|
||||||
|
var registrations = 0
|
||||||
|
var undecodable = 0
|
||||||
|
|
||||||
|
for (p in packets) {
|
||||||
|
val n = NetbiosParser.parse(p.data, p.data.size)
|
||||||
|
if (n == null) {
|
||||||
|
undecodable++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when (n.opcode) {
|
||||||
|
0 -> queries++
|
||||||
|
5, 8 -> registrations++
|
||||||
|
}
|
||||||
|
// Identity is name + suffix, not the name alone: one host registers the same name
|
||||||
|
// several times with different suffixes, and those are different facts about it.
|
||||||
|
table.observe(p.sourceIp, n.name + "#" + "%02X".format(n.suffix), p.atMonoNs) {
|
||||||
|
buildJsonObject {
|
||||||
|
put("netbios_name", n.name)
|
||||||
|
put("suffix", "0x%02X".format(n.suffix))
|
||||||
|
put("role", n.role)
|
||||||
|
put("operation", NetbiosParser.opcodeName(n.opcode))
|
||||||
|
put("kind", if (n.isResponse) "response" else "request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("capture", capture.statusJson())
|
||||||
|
put("netbios_names", table.toJson())
|
||||||
|
put("evidence_truncated", capture.truncated || table.overflowed)
|
||||||
|
}
|
||||||
|
val metrics: JsonObject = buildJsonObject {
|
||||||
|
put("distinct_sources", table.distinctSources)
|
||||||
|
put("distinct_names", table.size)
|
||||||
|
put("packets", capture.packetsSeen)
|
||||||
|
put("name_queries", queries)
|
||||||
|
put("name_registrations", registrations)
|
||||||
|
put("undecodable_packets", undecodable)
|
||||||
|
put("netbios_in_use", table.distinctSources > 0)
|
||||||
|
put("truncated", capture.truncated || table.overflowed)
|
||||||
|
}
|
||||||
|
val saw = !table.isEmpty
|
||||||
|
return build(
|
||||||
|
capture.outcome(saw),
|
||||||
|
evidence = evidence,
|
||||||
|
metrics = metrics,
|
||||||
|
params = params(),
|
||||||
|
error = capture.reason(saw)?.let {
|
||||||
|
TestError(if (capture.failure != null) "port_unavailable" else "listen_incomplete", it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun params(): JsonObject = buildJsonObject {
|
||||||
|
put("mode", "passive")
|
||||||
|
put("port", PORT)
|
||||||
|
put("transport", "udp broadcast")
|
||||||
|
put("started_mono_ns", startedAtMonoNs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PORT = 137
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestError
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
/**
|
||||||
|
* local.ssdp_inventory — what UPnP/SSDP devices are on this segment, gathered over the whole window.
|
||||||
|
*
|
||||||
|
* **Both halves are needed, and neither is sufficient.** Passive listening catches ssdp:alive and
|
||||||
|
* ssdp:byebye announcements, which is the only way to see a device that ignores searches (plenty
|
||||||
|
* do, deliberately) and the only way to see one leave. But announcements are periodic and sparse —
|
||||||
|
* a device re-announces on its own cache-control interval, commonly 30 minutes — so a five-minute
|
||||||
|
* passive window silently misses most of the segment. An M-SEARCH provokes an immediate reply from
|
||||||
|
* everything that is listening, which is most things, and catches the quiet ones. Running only the
|
||||||
|
* active half is what [RouterIdentityProbe] already does in the battery, and it is why that probe
|
||||||
|
* cannot tell you that a device disappeared halfway through the run.
|
||||||
|
*
|
||||||
|
* The searches are paced, not flooded: [maxSearches] of them spread [searchIntervalMs] apart. A
|
||||||
|
* repeat catches devices that joined the network after the run started or were asleep at t=0, while
|
||||||
|
* staying orders of magnitude below a rate that would itself perturb the network being measured —
|
||||||
|
* the tool must not become the fault it is looking for. `upnp:rootdevice` rather than `ssdp:all`
|
||||||
|
* for the same reason: one reply per device instead of one per service.
|
||||||
|
*
|
||||||
|
* The searches go out from the capture socket bound to 1900, so unicast replies land in the same
|
||||||
|
* capture as the multicast announcements rather than needing a second socket nobody is reading.
|
||||||
|
*/
|
||||||
|
class SsdpCollector(
|
||||||
|
private val searchIntervalMs: Long = 60_000,
|
||||||
|
private val maxSearches: Int = 5,
|
||||||
|
) : BaseCollector() {
|
||||||
|
|
||||||
|
override val type = TestType.LOCAL_SSDP_INVENTORY
|
||||||
|
override val tier = Tier.APP
|
||||||
|
|
||||||
|
private val capture = MulticastCapture(
|
||||||
|
label = "ssdp",
|
||||||
|
port = PORT,
|
||||||
|
group4 = GROUP4,
|
||||||
|
// The IPv6 link-local SSDP group. Free to join where IPv6 exists and skipped where it does
|
||||||
|
// not, so a v4-only network costs nothing and a v6-only device is not invisible.
|
||||||
|
group6 = GROUP6,
|
||||||
|
// SSDP is the chattiest of the four; a device announcing every service it hosts can emit a
|
||||||
|
// dozen NOTIFYs per cycle, so the per-source cap does most of the work here.
|
||||||
|
maxPackets = 600,
|
||||||
|
// The only one of the four with an active half worth keeping when 1900 is taken.
|
||||||
|
allowEphemeralFallback = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
private var scope: CoroutineScope? = null
|
||||||
|
private var searchesSent = 0
|
||||||
|
private var startedAtMonoNs = 0L
|
||||||
|
|
||||||
|
override suspend fun start(ctx: Context, ids: ProbeIds) {
|
||||||
|
begin(ids)
|
||||||
|
startedAtMonoNs = ids.monoNs()
|
||||||
|
capture.acquireLock(ctx)
|
||||||
|
if (!capture.start(ids)) return
|
||||||
|
|
||||||
|
val s = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scope = it }
|
||||||
|
s.launch {
|
||||||
|
while (isActive && searchesSent < maxSearches) {
|
||||||
|
if (capture.send(MSEARCH, GROUP4, PORT)) searchesSent++ else break
|
||||||
|
delay(searchIntervalMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stop(): Test {
|
||||||
|
scope?.coroutineContext?.get(Job)?.cancel()
|
||||||
|
scope = null
|
||||||
|
val packets = capture.stop()
|
||||||
|
|
||||||
|
val table = DiscoveryTable()
|
||||||
|
var alive = 0
|
||||||
|
var byebye = 0
|
||||||
|
var responses = 0
|
||||||
|
var undecodable = 0
|
||||||
|
|
||||||
|
for (p in packets) {
|
||||||
|
val msg = SsdpParser.parse(p.data, p.data.size)
|
||||||
|
if (msg == null) {
|
||||||
|
undecodable++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when (msg.kind) {
|
||||||
|
SsdpKind.ALIVE -> alive++
|
||||||
|
SsdpKind.BYEBYE -> byebye++
|
||||||
|
SsdpKind.RESPONSE -> responses++
|
||||||
|
// Our own M-SEARCH comes back to us through the group; counting it as a device
|
||||||
|
// would inventory the phone doing the measuring.
|
||||||
|
SsdpKind.SEARCH -> continue
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
// USN is the device+service identity SSDP itself uses; NT/ST is the fallback for
|
||||||
|
// devices that omit it, and the source IP already separates two devices offering the
|
||||||
|
// same service type.
|
||||||
|
val identity = msg.usn ?: msg.target ?: "(unidentified)"
|
||||||
|
table.observe(p.sourceIp, identity, p.atMonoNs) {
|
||||||
|
buildJsonObject {
|
||||||
|
put("usn", identity)
|
||||||
|
msg.target?.let { put("target", it) }
|
||||||
|
msg.serverBanner?.let { put("server_banner", it) }
|
||||||
|
SsdpParser.productHint(msg.serverBanner)?.let { put("product_hint", it) }
|
||||||
|
msg.location?.let { put("location", it) }
|
||||||
|
put("kind", msg.kind.name.lowercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("capture", capture.statusJson())
|
||||||
|
put("ssdp_devices", table.toJson())
|
||||||
|
// Two truncation sources, reported apart: the capture dropping datagrams and the
|
||||||
|
// inventory dropping distinct entries mean different things about the network.
|
||||||
|
put("evidence_truncated", capture.truncated || table.overflowed)
|
||||||
|
}
|
||||||
|
val metrics: JsonObject = buildJsonObject {
|
||||||
|
put("distinct_sources", table.distinctSources)
|
||||||
|
put("distinct_advertisements", table.size)
|
||||||
|
put("packets", capture.packetsSeen)
|
||||||
|
put("alive", alive)
|
||||||
|
put("byebye", byebye)
|
||||||
|
put("search_responses", responses)
|
||||||
|
put("undecodable_packets", undecodable)
|
||||||
|
put("searches_sent", searchesSent)
|
||||||
|
put("truncated", capture.truncated || table.overflowed)
|
||||||
|
}
|
||||||
|
val status = capture.outcome(sawAnything = !table.isEmpty)
|
||||||
|
val reason = capture.reason(sawAnything = !table.isEmpty)
|
||||||
|
return build(
|
||||||
|
status,
|
||||||
|
evidence = evidence,
|
||||||
|
metrics = metrics,
|
||||||
|
params = params(),
|
||||||
|
error = reason?.let { TestError("listen_incomplete", it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun params(): JsonObject = buildJsonObject {
|
||||||
|
put("mode", "passive+msearch")
|
||||||
|
put("group_v4", "$GROUP4:$PORT")
|
||||||
|
put("group_v6", "[$GROUP6]:$PORT")
|
||||||
|
put("search_target", SEARCH_TARGET)
|
||||||
|
put("search_interval_ms", searchIntervalMs)
|
||||||
|
put("max_searches", maxSearches)
|
||||||
|
put("started_mono_ns", startedAtMonoNs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PORT = 1900
|
||||||
|
const val GROUP4 = "239.255.255.250"
|
||||||
|
const val GROUP6 = "ff02::c"
|
||||||
|
const val SEARCH_TARGET = "upnp:rootdevice"
|
||||||
|
|
||||||
|
/** MX is the maximum random delay a responder waits, in seconds. 3 spreads the replies
|
||||||
|
* enough that a segment full of devices does not answer in one burst we then drop. */
|
||||||
|
val MSEARCH: ByteArray = (
|
||||||
|
"M-SEARCH * HTTP/1.1\r\n" +
|
||||||
|
"HOST: $GROUP4:$PORT\r\n" +
|
||||||
|
"MAN: \"ssdp:discover\"\r\n" +
|
||||||
|
"MX: 3\r\n" +
|
||||||
|
"ST: $SEARCH_TARGET\r\n\r\n"
|
||||||
|
).toByteArray(Charsets.ISO_8859_1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestError
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
/**
|
||||||
|
* local.wsd_inventory — WS-Discovery (SOAP-over-UDP, 3702) Hello / Bye / Probe / ProbeMatches.
|
||||||
|
*
|
||||||
|
* This is the protocol Windows and modern printers use to find each other, and it inventories a
|
||||||
|
* class of device the other three miss: network printers, scanners and IP cameras announce here and
|
||||||
|
* frequently nowhere else. A `Hello` is a device arriving, a `Bye` is one leaving, and a `Probe`
|
||||||
|
* from a workstation names what it is hunting for — so a window over 3702 shows both the equipment
|
||||||
|
* on the segment and which machines are looking for it.
|
||||||
|
*
|
||||||
|
* Passive only. WS-Discovery's active half is a Probe multicast, which would make this app a
|
||||||
|
* participant announcing itself to every device on the segment; SSDP's M-SEARCH is a single small
|
||||||
|
* request that devices expect constantly, whereas a WSD Probe from an unknown host is the kind of
|
||||||
|
* thing that shows up in someone's security log. Listening costs the network nothing.
|
||||||
|
*
|
||||||
|
* Payloads are decoded by [WsdParser], which does targeted extraction rather than XML parsing —
|
||||||
|
* see its documentation for why that is the right call for unauthenticated broadcast input.
|
||||||
|
*/
|
||||||
|
class WsdCollector : BaseCollector() {
|
||||||
|
|
||||||
|
override val type = TestType.LOCAL_WSD_INVENTORY
|
||||||
|
override val tier = Tier.APP
|
||||||
|
|
||||||
|
private val capture = MulticastCapture(
|
||||||
|
label = "wsd",
|
||||||
|
port = PORT,
|
||||||
|
group4 = GROUP4,
|
||||||
|
group6 = GROUP6,
|
||||||
|
// SOAP envelopes are an order of magnitude larger than the other three protocols'
|
||||||
|
// datagrams, so the byte ceiling binds before the packet count does. Both are set
|
||||||
|
// explicitly rather than left to the default, which was chosen for 200-byte packets.
|
||||||
|
maxPackets = 250,
|
||||||
|
maxBytesRetained = 192 * 1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
private var startedAtMonoNs = 0L
|
||||||
|
|
||||||
|
override suspend fun start(ctx: Context, ids: ProbeIds) {
|
||||||
|
begin(ids)
|
||||||
|
startedAtMonoNs = ids.monoNs()
|
||||||
|
capture.acquireLock(ctx)
|
||||||
|
capture.start(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stop(): Test {
|
||||||
|
val packets = capture.stop()
|
||||||
|
|
||||||
|
val table = DiscoveryTable()
|
||||||
|
var hello = 0
|
||||||
|
var bye = 0
|
||||||
|
var probes = 0
|
||||||
|
var matches = 0
|
||||||
|
var undecodable = 0
|
||||||
|
|
||||||
|
for (p in packets) {
|
||||||
|
val m = WsdParser.parse(p.data, p.data.size)
|
||||||
|
if (m == null) {
|
||||||
|
undecodable++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when (m.action?.lowercase()) {
|
||||||
|
"hello" -> hello++
|
||||||
|
"bye" -> bye++
|
||||||
|
"probe" -> probes++
|
||||||
|
"probematches", "resolvematches" -> matches++
|
||||||
|
}
|
||||||
|
// The device UUID is WS-Discovery's own stable identity and survives address changes,
|
||||||
|
// so it is the identity where present; a Probe carries none (it names what it wants,
|
||||||
|
// not who it is), and there the action plus the types is what distinguishes one
|
||||||
|
// observation from a repeat of it.
|
||||||
|
val identity = m.deviceUuid ?: (m.action.orEmpty() + "/" + m.types.orEmpty())
|
||||||
|
table.observe(p.sourceIp, identity.ifEmpty { "(unidentified)" }, p.atMonoNs) {
|
||||||
|
buildJsonObject {
|
||||||
|
m.deviceUuid?.let { put("device_uuid", it) }
|
||||||
|
m.action?.let { put("action", it) }
|
||||||
|
m.types?.let { put("wsd_types", it) }
|
||||||
|
m.xaddrs?.let { put("wsd_xaddrs", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("capture", capture.statusJson())
|
||||||
|
put("wsd_devices", table.toJson())
|
||||||
|
put("evidence_truncated", capture.truncated || table.overflowed)
|
||||||
|
}
|
||||||
|
val metrics: JsonObject = buildJsonObject {
|
||||||
|
put("distinct_sources", table.distinctSources)
|
||||||
|
put("distinct_devices", table.size)
|
||||||
|
put("packets", capture.packetsSeen)
|
||||||
|
put("hello", hello)
|
||||||
|
put("bye", bye)
|
||||||
|
put("probes", probes)
|
||||||
|
put("probe_matches", matches)
|
||||||
|
put("undecodable_packets", undecodable)
|
||||||
|
put("truncated", capture.truncated || table.overflowed)
|
||||||
|
}
|
||||||
|
val saw = !table.isEmpty
|
||||||
|
return build(
|
||||||
|
capture.outcome(saw),
|
||||||
|
evidence = evidence,
|
||||||
|
metrics = metrics,
|
||||||
|
params = params(),
|
||||||
|
error = capture.reason(saw)?.let { TestError("listen_incomplete", it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun params(): JsonObject = buildJsonObject {
|
||||||
|
put("mode", "passive")
|
||||||
|
put("group_v4", "$GROUP4:$PORT")
|
||||||
|
put("group_v6", "[$GROUP6]:$PORT")
|
||||||
|
put("started_mono_ns", startedAtMonoNs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PORT = 3702
|
||||||
|
const val GROUP4 = "239.255.255.250"
|
||||||
|
const val GROUP6 = "ff02::c"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decoders in DiscoveryParsers.kt, against payloads shaped like the ones real devices emit and
|
||||||
|
* against the ones a broken or hostile device emits.
|
||||||
|
*
|
||||||
|
* The malformed cases are the point. These four decoders are the only place in the app where bytes
|
||||||
|
* from an unidentified third party on the local segment are interpreted; they run inside a
|
||||||
|
* collector whose contract is that it never throws, and every one of them is reachable by anyone
|
||||||
|
* who can put a frame on the wire. So each protocol is fed truncation, junk, and the specific abuse
|
||||||
|
* its format invites — a DNS compression pointer, a NetBIOS name outside the A-P alphabet, an XML
|
||||||
|
* entity bomb — and the assertion is always the same pair: no exception, and no invented data.
|
||||||
|
*/
|
||||||
|
class DiscoveryParsersTest {
|
||||||
|
|
||||||
|
private fun bytes(s: String) = s.toByteArray(Charsets.ISO_8859_1)
|
||||||
|
|
||||||
|
// ---- SSDP --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private val notifyAlive = bytes(
|
||||||
|
"NOTIFY * HTTP/1.1\r\n" +
|
||||||
|
"HOST: 239.255.255.250:1900\r\n" +
|
||||||
|
"CACHE-CONTROL: max-age=1800\r\n" +
|
||||||
|
"LOCATION: http://192.168.1.44:8060/\r\n" +
|
||||||
|
"NT: upnp:rootdevice\r\n" +
|
||||||
|
"NTS: ssdp:alive\r\n" +
|
||||||
|
"SERVER: Roku/12.5.5 UPnP/1.0 Roku/12.5.5\r\n" +
|
||||||
|
"USN: uuid:roku:ecp:YH00E1234567::upnp:rootdevice\r\n\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val searchResponse = bytes(
|
||||||
|
"HTTP/1.1 200 OK\r\n" +
|
||||||
|
"CACHE-CONTROL: max-age=1800\r\n" +
|
||||||
|
"EXT:\r\n" +
|
||||||
|
"LOCATION: http://192.168.1.1:49000/rootDesc.xml\r\n" +
|
||||||
|
"SERVER: FRITZ!Box 7590 UPnP/1.0 AVM FRITZ!Box 7590 154.07.57\r\n" +
|
||||||
|
"ST: upnp:rootdevice\r\n" +
|
||||||
|
"USN: uuid:75802409-bccb-40e7-8e6c-c0ff33445566::upnp:rootdevice\r\n\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ssdpAliveAnnouncementYieldsIdentityAndLocation() {
|
||||||
|
val m = assertNotNull(SsdpParser.parse(notifyAlive, notifyAlive.size))
|
||||||
|
assertEquals(SsdpKind.ALIVE, m.kind)
|
||||||
|
assertEquals("upnp:rootdevice", m.target)
|
||||||
|
assertEquals("uuid:roku:ecp:YH00E1234567::upnp:rootdevice", m.usn)
|
||||||
|
assertEquals("http://192.168.1.44:8060/", m.location)
|
||||||
|
assertEquals("Roku/12.5.5 UPnP/1.0 Roku/12.5.5", m.serverBanner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ssdpByebyeIsDistinguishedFromAlive() {
|
||||||
|
val byebye = bytes(
|
||||||
|
"NOTIFY * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n" +
|
||||||
|
"NT: urn:schemas-upnp-org:device:MediaRenderer:1\r\nNTS: ssdp:byebye\r\n" +
|
||||||
|
"USN: uuid:aabbccdd::urn:schemas-upnp-org:device:MediaRenderer:1\r\n\r\n"
|
||||||
|
)
|
||||||
|
val m = assertNotNull(SsdpParser.parse(byebye, byebye.size))
|
||||||
|
assertEquals(SsdpKind.BYEBYE, m.kind)
|
||||||
|
assertEquals("urn:schemas-upnp-org:device:MediaRenderer:1", m.target)
|
||||||
|
assertNull(m.location)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ssdpSearchResponseReadsStAsTheTarget() {
|
||||||
|
val m = assertNotNull(SsdpParser.parse(searchResponse, searchResponse.size))
|
||||||
|
assertEquals(SsdpKind.RESPONSE, m.kind)
|
||||||
|
assertEquals("upnp:rootdevice", m.target)
|
||||||
|
assertEquals("http://192.168.1.1:49000/rootDesc.xml", m.location)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Our own M-SEARCH comes back through the group; it must be recognisable so the collector
|
||||||
|
* does not inventory the phone doing the measuring. */
|
||||||
|
@Test
|
||||||
|
fun ssdpOwnSearchIsRecognisable() {
|
||||||
|
val search = bytes(
|
||||||
|
"M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n" +
|
||||||
|
"MAN: \"ssdp:discover\"\r\nMX: 3\r\nST: upnp:rootdevice\r\n\r\n"
|
||||||
|
)
|
||||||
|
assertEquals(SsdpKind.SEARCH, assertNotNull(SsdpParser.parse(search, search.size)).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ssdpProductHintDropsBoilerplateAndKeepsTheModel() {
|
||||||
|
assertEquals("Roku/12.5.5", SsdpParser.productHint("Roku/12.5.5 UPnP/1.0 Roku/12.5.5"))
|
||||||
|
assertEquals("Synology/DSM-7.3", SsdpParser.productHint("Linux/4.4 UPnP/1.0 Synology/DSM-7.3"))
|
||||||
|
val fritz = assertNotNull(SsdpParser.productHint("FRITZ!Box 7590 UPnP/1.0 AVM FRITZ!Box 7590"))
|
||||||
|
assertTrue(fritz.contains("FRITZ!Box"))
|
||||||
|
assertFalse(fritz.contains("UPnP"), "protocol boilerplate leaked into the model hint")
|
||||||
|
assertNull(SsdpParser.productHint(null))
|
||||||
|
assertNull(SsdpParser.productHint(" "))
|
||||||
|
// A banner that is nothing but boilerplate reveals no model and must say so, rather than
|
||||||
|
// returning an empty string that reads as a name nobody could see.
|
||||||
|
assertNull(SsdpParser.productHint("Linux/4.4 UPnP/1.0"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ssdpMalformedIsRejectedWithoutThrowing() {
|
||||||
|
// Truncated mid-header: the headers that did arrive are still usable, and the missing NTS
|
||||||
|
// makes the kind unknown rather than making the packet a lie.
|
||||||
|
val cut = notifyAlive.copyOf(70)
|
||||||
|
assertEquals(SsdpKind.OTHER, assertNotNull(SsdpParser.parse(cut, cut.size)).kind)
|
||||||
|
|
||||||
|
assertNull(SsdpParser.parse(ByteArray(0), 0))
|
||||||
|
val blank = bytes("\r\n\r\n")
|
||||||
|
assertNull(SsdpParser.parse(blank, blank.size))
|
||||||
|
val http = bytes("GET / HTTP/1.0\r\n\r\n")
|
||||||
|
assertNull(SsdpParser.parse(http, http.size), "not an SSDP verb")
|
||||||
|
// Arbitrary binary, including the high bytes ISO-8859-1 must not choke on.
|
||||||
|
val junk = ByteArray(256) { it.toByte() }
|
||||||
|
assertNull(SsdpParser.parse(junk, junk.size))
|
||||||
|
// A declared length longer than the buffer must be refused, not read past.
|
||||||
|
assertNull(SsdpParser.parse(notifyAlive, notifyAlive.size + 100))
|
||||||
|
// Header lines with no colon are skipped rather than fatal.
|
||||||
|
val noColon = bytes("NOTIFY * HTTP/1.1\r\ngarbage line\r\nNTS: ssdp:alive\r\n\r\n")
|
||||||
|
assertEquals(SsdpKind.ALIVE, assertNotNull(SsdpParser.parse(noColon, noColon.size)).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- LLMNR -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Builds a DNS-format packet: header, one question, nothing else. */
|
||||||
|
private fun dnsQuery(
|
||||||
|
name: String,
|
||||||
|
qtype: Int = 1,
|
||||||
|
id: Int = 0x1234,
|
||||||
|
flags: Int = 0x0000,
|
||||||
|
qdcount: Int = 1,
|
||||||
|
): ByteArray {
|
||||||
|
val out = ArrayList<Byte>()
|
||||||
|
fun u16(v: Int) { out.add((v shr 8).toByte()); out.add((v and 0xFF).toByte()) }
|
||||||
|
u16(id); u16(flags); u16(qdcount); u16(0); u16(0); u16(0)
|
||||||
|
for (label in name.split('.')) {
|
||||||
|
val b = label.toByteArray(Charsets.UTF_8)
|
||||||
|
out.add(b.size.toByte())
|
||||||
|
b.forEach { out.add(it) }
|
||||||
|
}
|
||||||
|
out.add(0)
|
||||||
|
u16(qtype); u16(1)
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun llmnrQueryYieldsTheNameAWorkstationIsHuntingFor() {
|
||||||
|
val p = dnsQuery("wpad")
|
||||||
|
val q = assertNotNull(LlmnrParser.parse(p, p.size))
|
||||||
|
assertEquals("wpad", q.name)
|
||||||
|
assertEquals(1, q.qtype)
|
||||||
|
assertEquals("A", LlmnrParser.qtypeName(q.qtype))
|
||||||
|
assertTrue(q.isQuery)
|
||||||
|
assertEquals(0, q.opcode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun llmnrMultiLabelNamesAndAaaaSurviveIntact() {
|
||||||
|
val p = dnsQuery("nas-backup.local", qtype = 28)
|
||||||
|
val q = assertNotNull(LlmnrParser.parse(p, p.size))
|
||||||
|
assertEquals("nas-backup.local", q.name)
|
||||||
|
assertEquals("AAAA", LlmnrParser.qtypeName(q.qtype))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun llmnrResponsesAreSeparatedFromQueries() {
|
||||||
|
val p = dnsQuery("DESKTOP-A1B2C3", flags = 0x8000)
|
||||||
|
assertFalse(assertNotNull(LlmnrParser.parse(p, p.size)).isQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun llmnrMalformedIsRejectedWithoutThrowing() {
|
||||||
|
val good = dnsQuery("printer")
|
||||||
|
|
||||||
|
assertNull(LlmnrParser.parse(ByteArray(0), 0))
|
||||||
|
assertNull(LlmnrParser.parse(good, 8), "a header-length prefix is not a question")
|
||||||
|
assertNull(LlmnrParser.parse(good, good.size + 50), "declared length past the buffer")
|
||||||
|
|
||||||
|
val noQuestion = dnsQuery("x", qdcount = 0)
|
||||||
|
assertNull(LlmnrParser.parse(noQuestion, noQuestion.size))
|
||||||
|
|
||||||
|
// A label length that runs off the end of the datagram — the classic truncation.
|
||||||
|
val overrun = good.copyOf(good.size - 6)
|
||||||
|
assertNull(LlmnrParser.parse(overrun, overrun.size))
|
||||||
|
|
||||||
|
// A compression pointer: legal DNS, forbidden in LLMNR, and the shape that makes a naive
|
||||||
|
// decoder loop forever. It must be refused rather than followed.
|
||||||
|
val pointer = good.copyOf(20)
|
||||||
|
pointer[12] = 0xC0.toByte()
|
||||||
|
pointer[13] = 0x0C
|
||||||
|
assertNull(LlmnrParser.parse(pointer, pointer.size))
|
||||||
|
|
||||||
|
// Random bytes behind a plausible header: whatever comes back, it is not an exception.
|
||||||
|
val junk = ByteArray(64) { (it * 37).toByte() }
|
||||||
|
junk[4] = 0; junk[5] = 1
|
||||||
|
LlmnrParser.parse(junk, junk.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NetBIOS -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** First-level encoding, the same transform the decoder has to undo. */
|
||||||
|
private fun nbnsPacket(name: String, suffix: Int, flags: Int = 0x2810): ByteArray {
|
||||||
|
val raw = ByteArray(16) { ' '.code.toByte() }
|
||||||
|
name.forEachIndexed { i, c -> if (i < 15) raw[i] = c.code.toByte() }
|
||||||
|
raw[15] = suffix.toByte()
|
||||||
|
|
||||||
|
val out = ArrayList<Byte>()
|
||||||
|
fun u16(v: Int) { out.add((v shr 8).toByte()); out.add((v and 0xFF).toByte()) }
|
||||||
|
u16(0x8001); u16(flags); u16(1); u16(0); u16(0); u16(1)
|
||||||
|
out.add(32)
|
||||||
|
for (b in raw) {
|
||||||
|
val v = b.toInt() and 0xFF
|
||||||
|
out.add(('A'.code + (v shr 4)).toByte())
|
||||||
|
out.add(('A'.code + (v and 0x0F)).toByte())
|
||||||
|
}
|
||||||
|
out.add(0)
|
||||||
|
u16(0x0020); u16(0x0001)
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun netbiosNameDecodesWithItsSuffixAndRole() {
|
||||||
|
val p = nbnsPacket("DESKTOP-A1B2C3", 0x20)
|
||||||
|
val n = assertNotNull(NetbiosParser.parse(p, p.size))
|
||||||
|
assertEquals("DESKTOP-A1B2C3", n.name)
|
||||||
|
assertEquals(0x20, n.suffix)
|
||||||
|
assertEquals("file_server", n.role)
|
||||||
|
assertFalse(n.isResponse)
|
||||||
|
assertEquals("registration", NetbiosParser.opcodeName(n.opcode))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun netbiosPaddingIsStrippedAndSuffixesAreNamed() {
|
||||||
|
val p = nbnsPacket("WORKGROUP", 0x1E)
|
||||||
|
val n = assertNotNull(NetbiosParser.parse(p, p.size))
|
||||||
|
assertEquals("WORKGROUP", n.name, "the 15-byte space padding leaked into the name")
|
||||||
|
assertEquals("browser_elections", n.role)
|
||||||
|
assertEquals("workstation", NetbiosParser.roleOf(0x00))
|
||||||
|
assertEquals("suffix_0xAB", NetbiosParser.roleOf(0xAB))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun netbiosResponsesAreSeparatedFromRequests() {
|
||||||
|
val p = nbnsPacket("FILESRV", 0x20, flags = 0x8500)
|
||||||
|
assertTrue(assertNotNull(NetbiosParser.parse(p, p.size)).isResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun netbiosMalformedIsRejectedWithoutThrowing() {
|
||||||
|
val good = nbnsPacket("PRINTER", 0x00)
|
||||||
|
|
||||||
|
assertNull(NetbiosParser.parse(ByteArray(0), 0))
|
||||||
|
assertNull(NetbiosParser.parse(good, 20), "truncated before the encoded name ends")
|
||||||
|
assertNull(NetbiosParser.parse(good, good.size + 40), "declared length past the buffer")
|
||||||
|
|
||||||
|
// A character outside A-P cannot be half of an encoded byte. Guessing at the rest would
|
||||||
|
// fabricate a hostname, so the whole packet is refused.
|
||||||
|
val badAlphabet = good.copyOf()
|
||||||
|
badAlphabet[15] = 'Z'.code.toByte()
|
||||||
|
assertNull(NetbiosParser.parse(badAlphabet, badAlphabet.size))
|
||||||
|
|
||||||
|
// The length byte must be exactly 32; anything else is a different protocol on this port.
|
||||||
|
val badLen = good.copyOf()
|
||||||
|
badLen[12] = 16
|
||||||
|
assertNull(NetbiosParser.parse(badLen, badLen.size))
|
||||||
|
|
||||||
|
// A name that decodes to nothing but padding is not a name.
|
||||||
|
val blank = nbnsPacket("", 0x00)
|
||||||
|
assertNull(NetbiosParser.parse(blank, blank.size))
|
||||||
|
|
||||||
|
val junk = ByteArray(80) { (it * 13).toByte() }
|
||||||
|
NetbiosParser.parse(junk, junk.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun netbiosControlCharactersInANameAreNeutralised() {
|
||||||
|
// Legal first-level encoding, illegal content: these bytes decode cleanly and would
|
||||||
|
// otherwise reach a JSON document, and from there somebody's terminal.
|
||||||
|
val p = nbnsPacket("A\u0001B\u0002C", 0x00)
|
||||||
|
assertEquals("A?B?C", assertNotNull(NetbiosParser.parse(p, p.size)).name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WS-Discovery ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private val wsdHello = bytes(
|
||||||
|
"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:wsd="http://schemas.xmlsoap.org/ws/2005/04/discovery"
|
||||||
|
xmlns:wsdp="http://schemas.xmlsoap.org/ws/2006/02/devprof">
|
||||||
|
<soap:Header>
|
||||||
|
<wsa:To>urn:schemas-xmlsoap-org:ws:2005:04:discovery</wsa:To>
|
||||||
|
<wsa:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/Hello</wsa:Action>
|
||||||
|
<wsa:MessageID>urn:uuid:0a7e6d1b-0000-4000-8000-000000000001</wsa:MessageID>
|
||||||
|
</soap:Header>
|
||||||
|
<soap:Body>
|
||||||
|
<wsd:Hello>
|
||||||
|
<wsa:EndpointReference>
|
||||||
|
<wsa:Address>urn:uuid:9f8e7d6c-1111-4222-8333-444455556666</wsa:Address>
|
||||||
|
</wsa:EndpointReference>
|
||||||
|
<wsd:Types>wsdp:Device pub:Computer</wsd:Types>
|
||||||
|
<wsd:XAddrs>http://192.168.1.77:5357/8f2c-4b1a/</wsd:XAddrs>
|
||||||
|
<wsd:MetadataVersion>1</wsd:MetadataVersion>
|
||||||
|
</wsd:Hello>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wsdHelloYieldsActionUuidTypesAndXaddrs() {
|
||||||
|
val m = assertNotNull(WsdParser.parse(wsdHello, wsdHello.size))
|
||||||
|
assertEquals("Hello", m.action)
|
||||||
|
assertEquals(
|
||||||
|
"urn:uuid:9f8e7d6c-1111-4222-8333-444455556666", m.deviceUuid,
|
||||||
|
"the EndpointReference address is the device identity, not the header MessageID",
|
||||||
|
)
|
||||||
|
assertEquals("wsdp:Device pub:Computer", m.types)
|
||||||
|
assertEquals("http://192.168.1.77:5357/8f2c-4b1a/", m.xaddrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wsdProbeCarriesNoDeviceIdentityAndIsStillRecorded() {
|
||||||
|
val probe = bytes(
|
||||||
|
"""<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
|
||||||
|
xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
|
xmlns:wsd="http://schemas.xmlsoap.org/ws/2005/04/discovery">
|
||||||
|
<soap:Header>
|
||||||
|
<wsa:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</wsa:Action>
|
||||||
|
</soap:Header>
|
||||||
|
<soap:Body><wsd:Probe><wsd:Types>wsdp:Device</wsd:Types></wsd:Probe></soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
)
|
||||||
|
val m = assertNotNull(WsdParser.parse(probe, probe.size))
|
||||||
|
assertEquals("Probe", m.action)
|
||||||
|
assertNull(m.deviceUuid)
|
||||||
|
assertEquals("wsdp:Device", m.types)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wsdMalformedIsRejectedWithoutThrowing() {
|
||||||
|
assertNull(WsdParser.parse(ByteArray(0), 0))
|
||||||
|
assertNull(WsdParser.parse(wsdHello, wsdHello.size + 100), "declared length past the buffer")
|
||||||
|
val prose = bytes("hello world")
|
||||||
|
assertNull(WsdParser.parse(prose, prose.size), "not a SOAP envelope")
|
||||||
|
// An envelope with no leaf worth reading is an absence, not an empty device.
|
||||||
|
val bare = bytes("<soap:Envelope></soap:Envelope>")
|
||||||
|
assertNull(WsdParser.parse(bare, bare.size))
|
||||||
|
|
||||||
|
// Cut mid-element: whatever was complete is extracted, the rest is simply absent.
|
||||||
|
val cut = wsdHello.copyOf(wsdHello.size / 2)
|
||||||
|
WsdParser.parse(cut, cut.size)?.let { assertNull(it.xaddrs, "an unterminated element was invented") }
|
||||||
|
|
||||||
|
val junk = ByteArray(512) { (it * 7).toByte() }
|
||||||
|
assertNull(WsdParser.parse(junk, junk.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wsdHostileXmlCostsNothingBecauseNothingParsesItAsXml() {
|
||||||
|
// A billion-laughs entity bomb. Against a real XML parser this expands to gigabytes; here
|
||||||
|
// the entities are never resolved, which is the entire reason for not using one.
|
||||||
|
val bomb = bytes(
|
||||||
|
"<!DOCTYPE lolz [<!ENTITY lol \"lol\">" +
|
||||||
|
(0..8).joinToString("") { i ->
|
||||||
|
val prev = if (i == 0) "" else (i - 1).toString()
|
||||||
|
"<!ENTITY lol$i \"&lol$prev;&lol$prev;\">"
|
||||||
|
} +
|
||||||
|
"]><soap:Envelope><wsa:Action>x/Bye</wsa:Action><body>&lol8;</body></soap:Envelope>"
|
||||||
|
)
|
||||||
|
assertEquals("Bye", assertNotNull(WsdParser.parse(bomb, bomb.size)).action)
|
||||||
|
|
||||||
|
// Nesting deep enough to blow a recursive-descent parser's stack.
|
||||||
|
val deep = bytes("<soap:Envelope>" + "<a>".repeat(20_000) + "</soap:Envelope>")
|
||||||
|
WsdParser.parse(deep, deep.size)
|
||||||
|
|
||||||
|
// A payload far larger than any real datagram, pinning that the text cap is applied before
|
||||||
|
// the matching rather than after it.
|
||||||
|
val huge = bytes("<soap:Envelope><wsa:Action>x/Hello</wsa:Action>" + "z".repeat(200_000))
|
||||||
|
assertEquals("Hello", assertNotNull(WsdParser.parse(huge, huge.size)).action)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user