app: listen to what the segment says unprompted
server-release / image (push) Successful in 14s
server-release / release (push) Successful in 31s

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:
mrambossek
2026-08-02 14:58:41 +02:00
co-authored by Claude Opus 5
parent 93125a4b1d
commit 9ee7d554a6
11 changed files with 1800 additions and 4 deletions
+5
View File
@@ -28,4 +28,9 @@ dependencies {
implementation(project(":core-measurement"))
implementation(libs.kotlinx.serialization.json)
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)
}
}