net/upnp: LAN attacker can poison external-IP advertisement via UPnP-IGD response race (Step 3 post-Codex; see #19) #10

Closed
opened 2026-06-04 12:23:11 +00:00 by dobbscoin · 2 comments
dobbscoin commented 2026-06-04 12:23:11 +00:00

Background

PR #9 (merge 24196aa) closed the worst external-IP-discovery trust surface — the cleartext-HTTP callout to amazonaws.com / ipify / icanhazip. One smaller surface remains for any OFFd compiled with USE_UPNP=1: the UPnP-IGD address-discovery path in src/net.cpp (around the MapPort / ThreadMapPort machinery, miniupnpc backend).

That path trusts whatever device responds first to the SSDP M-SEARCH multicast at 239.255.255.250:1900. Its GetExternalIPAddress SOAP response is fed directly into:

AddLocal(addrLocalHost, LOCAL_UPNP);

…which seeds mapLocalHost at score 3 (post-PR-#9 enum numbering) and gossips the address to peers via addr and version messages.

There is no authentication of the UPnP-IGD responder, no certificate pinning, no out-of-band sanity check on the returned IP. It is trust-on-first-respond.

Threat model

Attacker capability: code execution on any device on the OFFd operator's LAN. They do NOT need:

  • Root on the OFFd box
  • Control of the real router
  • Prior knowledge of OFF
  • Outbound network privilege

Concrete attacker positions in the wild:

  • An unpatched IoT device on the operator's home network
  • A guest on the same wifi (coffee shop, co-working, family-and-friends visiting)
  • A roommate's compromised laptop
  • Any device sharing the broadcast domain

Attack chain

  1. Attacker runs a UPnP-IGD impersonator on the LAN. Off-the-shelf tooling exists (miranda-upnp and similar); a from-scratch implementation is ~200 lines of Python. The impersonator listens on SSDP multicast and serves a forged IGD device-description XML.

  2. OFFd boots. miniupnpc multicasts M-SEARCH * HTTP/1.1. Both the attacker's daemon and the real router see it. Attacker is at microsecond latency on the same broadcast domain — they win the race the vast majority of the time. (Fallback if they don't: send unsolicited SSDP NOTIFY between OFFd boots.)

  3. miniupnpc accepts the attacker's LOCATION: URL, fetches the fake device-description XML, and locks onto the attacker's SOAP endpoint for the rest of the OFFd process lifetime.

  4. miniupnpc calls UPNP_GetExternalIPAddress. Attacker returns any IP they want:

    • An attacker-owned VPS running a malicious OFFd
    • A target node the attacker wants peers to misdial
    • An innocent third party's IP, for griefing or abuse-laundering
  5. OFFd records AddLocal(<attacker-chosen-ip>, LOCAL_UPNP). Score 3 enters mapLocalHost.

  6. For the next ~24 hours, OFFd advertises that IP as itself in addr messages to every connected peer and in version handshakes to inbound peers. Honest peers gossip it onward.

What the attacker gains

In order of badness:

a. Eclipse-attack staging. Attacker advertises their own malicious-OFFd IP as the victim's address. Honest peers dialing the victim end up at the attacker, get a curated chain view, are eclipsed at the moment of first contact. This is the same protocol-level primitive the 2018 >80%-hash attacker used (see project-off-attacker-and-checkpoint). PR #9 closed this for the HTTP path; UPnP remains.

b. Pool-operator denial / griefing. Advertise a wrong IP for a victim mining-pool's wallet node. Miners can't reach the pool; pool operator gets falsely-attributed outage reports. Reputation hit.

c. Reputation attack on an uninvolved third party. Pick <attacker-chosen-ip> = some residential ISP user. OFFd makes them look like a node. They get inbound P2P traffic and may trigger ISP abuse-detection.

d. Treasury/bridge confusion during a contested operation. If a future Treasury-signing OFFd is ever misconfigured behind a UPnP NAT, the attacker can make peers disagree about which IP is canonical during a fork-coordination moment. Doesn't compromise the wallet key, only the discovery layer.

Realistic impact for OFF today

Cluster nodes: low. Operator-controlled cluster nodes already configure -externalip= (LOCAL_MANUAL, higher score than LOCAL_UPNP) — the UPnP-injected address loses the score comparison and is never advertised. Nodes hosted on VPSes with static public IPs have no gateway router to SSDP at all. There is no LAN-resident-attacker scenario for VPS-hosted nodes; the hypervisor / VLAN boundary is a much larger pre-requisite than "be on the same wifi."

Third-party hobbyist operators: medium. A user running OFFd at home behind a residential router without -externalip= is the target. As the network grows post-Restoration, this demographic will exist. They are the population this exploit is for.

Proposed mitigation (phased)

  1. v2.0.x release notes (immediate, zero code). Document -upnp=0 -externalip=<your-real-ip> as the recommended config for any public-facing OFFd. Explain why (this issue).

  2. v2.1.0 default flip (one-line code change). Change the default of -upnp from true to false in src/init.cpp (HelpMessage / SoftSetBoolArg path). Bitcoin Core itself made this flip in bitcoin/bitcoin#20410 (2020, merged into 22.0) on the same threat-model reasoning. Operators who explicitly want UPnP can still pass -upnp=1.

  3. v2.2.x or later (depends/ work). Compile-time USE_UPNP=0 in release builds; drop miniupnpc from depends/ entirely. Would benefit from a Skifdni-style depends/ PR. Eliminates the surface for everyone, including operators who would have ignored the warning in step 1.

Trade-offs

  • Step 2 (default flip) breaks NAT-traversal convenience for hobbyists who were relying on UPnP for inbound peer connectivity. They'll need to set -upnp=1 explicitly or configure port-forwarding manually. Same tradeoff Bitcoin Core accepted in 2020.
  • Step 3 (depends/ removal) is irreversible without re-adding the recipe. Reasonable once the chain is stable; premature now.
  • The cluster is unaffected by any of this because of -externalip= precedence — these are operator-protection changes, not cluster-protection changes.
  • PR #9 (merged 2026-06-04, 24196aa) — closed the analogous HTTP-discovery path. This issue is what's left after that.
  • Closed issue #5 — same family of concerns, scoped to HTTP retirement.
  • bitcoin/bitcoin#20410 — Bitcoin Core's default-UPnP-off PR.
  • CVE-2020-12695 — "CallStranger" — wider UPnP exposure on WAN side; less directly applicable here (this issue is LAN-resident attacker) but shows the broader pattern.
  • project-off-attacker-and-checkpoint — historical eclipse-attack precedent in OFF.

Not a Restoration blocker

This is a hardening item, not a fork-window item. No consensus impact. Step 1 (docs) can ship anytime; steps 2 and 3 fit naturally into the next v2.x point releases.

## Background PR #9 (merge `24196aa`) closed the worst external-IP-discovery trust surface — the cleartext-HTTP callout to amazonaws.com / ipify / icanhazip. One smaller surface remains for any OFFd compiled with `USE_UPNP=1`: the UPnP-IGD address-discovery path in `src/net.cpp` (around the `MapPort` / `ThreadMapPort` machinery, miniupnpc backend). That path trusts whatever device responds first to the SSDP `M-SEARCH` multicast at `239.255.255.250:1900`. Its `GetExternalIPAddress` SOAP response is fed directly into: ```cpp AddLocal(addrLocalHost, LOCAL_UPNP); ``` …which seeds `mapLocalHost` at score 3 (post-PR-#9 enum numbering) and gossips the address to peers via `addr` and `version` messages. There is no authentication of the UPnP-IGD responder, no certificate pinning, no out-of-band sanity check on the returned IP. It is trust-on-first-respond. ## Threat model **Attacker capability:** code execution on **any** device on the OFFd operator's LAN. They do NOT need: - Root on the OFFd box - Control of the real router - Prior knowledge of OFF - Outbound network privilege Concrete attacker positions in the wild: - An unpatched IoT device on the operator's home network - A guest on the same wifi (coffee shop, co-working, family-and-friends visiting) - A roommate's compromised laptop - Any device sharing the broadcast domain ## Attack chain 1. Attacker runs a UPnP-IGD impersonator on the LAN. Off-the-shelf tooling exists (`miranda-upnp` and similar); a from-scratch implementation is ~200 lines of Python. The impersonator listens on SSDP multicast and serves a forged IGD device-description XML. 2. OFFd boots. miniupnpc multicasts `M-SEARCH * HTTP/1.1`. Both the attacker's daemon and the real router see it. Attacker is at microsecond latency on the same broadcast domain — they **win the race** the vast majority of the time. (Fallback if they don't: send unsolicited SSDP `NOTIFY` between OFFd boots.) 3. miniupnpc accepts the attacker's `LOCATION:` URL, fetches the fake device-description XML, and locks onto the attacker's SOAP endpoint for the rest of the OFFd process lifetime. 4. miniupnpc calls `UPNP_GetExternalIPAddress`. Attacker returns any IP they want: - An attacker-owned VPS running a malicious OFFd - A target node the attacker wants peers to misdial - An innocent third party's IP, for griefing or abuse-laundering 5. OFFd records `AddLocal(<attacker-chosen-ip>, LOCAL_UPNP)`. Score 3 enters `mapLocalHost`. 6. For the next ~24 hours, OFFd advertises that IP as itself in `addr` messages to every connected peer and in `version` handshakes to inbound peers. Honest peers gossip it onward. ## What the attacker gains In order of badness: a. **Eclipse-attack staging.** Attacker advertises their own malicious-OFFd IP as the victim's address. Honest peers dialing the victim end up at the attacker, get a curated chain view, are eclipsed at the moment of first contact. This is the same protocol-level primitive the 2018 >80%-hash attacker used (see [[project-off-attacker-and-checkpoint]]). PR #9 closed this for the HTTP path; UPnP remains. b. **Pool-operator denial / griefing.** Advertise a wrong IP for a victim mining-pool's wallet node. Miners can't reach the pool; pool operator gets falsely-attributed outage reports. Reputation hit. c. **Reputation attack on an uninvolved third party.** Pick `<attacker-chosen-ip>` = some residential ISP user. OFFd makes them look like a node. They get inbound P2P traffic and may trigger ISP abuse-detection. d. **Treasury/bridge confusion during a contested operation.** If a future Treasury-signing OFFd is ever misconfigured behind a UPnP NAT, the attacker can make peers disagree about which IP is canonical during a fork-coordination moment. Doesn't compromise the wallet key, only the discovery layer. ## Realistic impact for OFF today **Cluster nodes: low.** Operator-controlled cluster nodes already configure `-externalip=` (`LOCAL_MANUAL`, higher score than `LOCAL_UPNP`) — the UPnP-injected address loses the score comparison and is never advertised. Nodes hosted on VPSes with static public IPs have no gateway router to SSDP at all. There is no LAN-resident-attacker scenario for VPS-hosted nodes; the hypervisor / VLAN boundary is a much larger pre-requisite than "be on the same wifi." **Third-party hobbyist operators: medium.** A user running OFFd at home behind a residential router without `-externalip=` is the target. As the network grows post-Restoration, this demographic will exist. They are the population this exploit is for. ## Proposed mitigation (phased) 1. **v2.0.x release notes (immediate, zero code).** Document `-upnp=0 -externalip=<your-real-ip>` as the recommended config for any public-facing OFFd. Explain why (this issue). 2. **v2.1.0 default flip (one-line code change).** Change the default of `-upnp` from `true` to `false` in `src/init.cpp` (`HelpMessage` / `SoftSetBoolArg` path). Bitcoin Core itself made this flip in [bitcoin/bitcoin#20410](https://github.com/bitcoin/bitcoin/pull/20410) (2020, merged into 22.0) on the same threat-model reasoning. Operators who explicitly want UPnP can still pass `-upnp=1`. 3. **v2.2.x or later (depends/ work).** Compile-time `USE_UPNP=0` in release builds; drop miniupnpc from `depends/` entirely. Would benefit from a Skifdni-style depends/ PR. Eliminates the surface for everyone, including operators who would have ignored the warning in step 1. ## Trade-offs - **Step 2 (default flip) breaks NAT-traversal convenience for hobbyists** who were relying on UPnP for inbound peer connectivity. They'll need to set `-upnp=1` explicitly or configure port-forwarding manually. Same tradeoff Bitcoin Core accepted in 2020. - **Step 3 (depends/ removal)** is irreversible without re-adding the recipe. Reasonable once the chain is stable; premature now. - **The cluster is unaffected by any of this** because of `-externalip=` precedence — these are operator-protection changes, not cluster-protection changes. ## Related - PR #9 (merged 2026-06-04, `24196aa`) — closed the analogous HTTP-discovery path. This issue is what's left after that. - Closed issue #5 — same family of concerns, scoped to HTTP retirement. - [bitcoin/bitcoin#20410](https://github.com/bitcoin/bitcoin/pull/20410) — Bitcoin Core's default-UPnP-off PR. - [CVE-2020-12695](https://nvd.nist.gov/vuln/detail/CVE-2020-12695) — "CallStranger" — wider UPnP exposure on WAN side; less directly applicable here (this issue is LAN-resident attacker) but shows the broader pattern. - [[project-off-attacker-and-checkpoint]] — historical eclipse-attack precedent in OFF. ## Not a Restoration blocker This is a hardening item, not a fork-window item. No consensus impact. Step 1 (docs) can ship anytime; steps 2 and 3 fit naturally into the next v2.x point releases.
dobbscoin commented 2026-06-06 23:52:26 +00:00

Phase 2 landed — #14 merged at 63626e76. Runtime default for -upnp flipped from USE_UPNP (compile-time) to false. UPnP is now opt-in.

What this changes:

  • New daemon installs (Offeringsd with no -upnp arg in Offerings.conf): UPnP is off at startup. No M-SEARCH on the LAN, no IGD SOAP traffic, no attacker race window.
  • New wallet installs (Offerings-qt): fUseUPnP defaults to false in QSettings.
  • Existing users (daemons with upnp=1 in Offerings.conf, or wallets with fUseUPnP=true already saved in QSettings): behavior unchanged — the persisted opt-in carries forward. They have to deliberately untoggle to get the new posture, which is the correct migration behavior. Worth calling out in v2.1.0 release notes so existing operators know to revisit the setting.

Phases recap:

  • Phase 1 (release-notes recommendation in v2.0.4 / v2.0.5): ✓ done
  • Phase 2 (runtime default flip): ✓ done via #14
  • Phase 3 (compile-time USE_UPNP=0 + drop miniupnpc from depends/): still outstanding. Skifdni-style PR — irreversible without re-adding the depends recipe, so deferring until the chain is stable post-Codex. Not a Restoration blocker.

Leaving this issue open for Phase 3 tracking. The attack surface is now down to opt-in only — a meaningful reduction, but a clearer floor would be removing UPnP support from release binaries entirely.


Default unbound. Iä Iä.

**Phase 2 landed** — #14 merged at `63626e76`. Runtime default for `-upnp` flipped from `USE_UPNP` (compile-time) to `false`. UPnP is now opt-in. What this changes: - **New daemon installs** (`Offeringsd` with no `-upnp` arg in `Offerings.conf`): UPnP is off at startup. No M-SEARCH on the LAN, no IGD SOAP traffic, no attacker race window. - **New wallet installs** (`Offerings-qt`): `fUseUPnP` defaults to false in QSettings. - **Existing users** (daemons with `upnp=1` in `Offerings.conf`, or wallets with `fUseUPnP=true` already saved in QSettings): behavior **unchanged** — the persisted opt-in carries forward. They have to deliberately untoggle to get the new posture, which is the correct migration behavior. Worth calling out in v2.1.0 release notes so existing operators know to revisit the setting. Phases recap: - **Phase 1** (release-notes recommendation in v2.0.4 / v2.0.5): ✓ done - **Phase 2** (runtime default flip): ✓ done via #14 - **Phase 3** (compile-time `USE_UPNP=0` + drop miniupnpc from `depends/`): **still outstanding.** Skifdni-style PR — irreversible without re-adding the depends recipe, so deferring until the chain is stable post-Codex. Not a Restoration blocker. Leaving this issue open for Phase 3 tracking. The attack surface is now down to opt-in only — a meaningful reduction, but a clearer floor would be removing UPnP support from release binaries entirely. --- *Default unbound. Iä Iä.*
dobbscoin commented 2026-06-07 22:20:01 +00:00

Closing as covered.

Step 1 (release-notes recommendation): shipped in v2.0.4 / v2.0.5 release notes.
Step 2 (runtime default flip, -upnp=0): shipped in v2.0.6 via PR #14 (commit 63626e7). Daemons built from v2.0.6 onward no longer enable UPnP without explicit -upnp=1.
Step 3 (drop miniupnpc from depends/ + finish Windows CI alignment): tracked in #19, decision: post-Codex (after block 1,050,667).

Closing this issue since the parent threat-model description is captured in #19's body and there's nothing left in the parent that doesn't have a home elsewhere. Reopen if Step 3 surfaces additional concerns that don't fit in #19.

Closing as covered. **Step 1 (release-notes recommendation):** shipped in v2.0.4 / v2.0.5 release notes. **Step 2 (runtime default flip, `-upnp=0`):** shipped in v2.0.6 via PR #14 (commit `63626e7`). Daemons built from v2.0.6 onward no longer enable UPnP without explicit `-upnp=1`. **Step 3 (drop miniupnpc from depends/ + finish Windows CI alignment):** tracked in #19, decision: post-Codex (after block 1,050,667). Closing this issue since the parent threat-model description is captured in #19's body and there's nothing left in the parent that doesn't have a home elsewhere. Reopen if Step 3 surfaces additional concerns that don't fit in #19.
dobbscoin closed this issue 2026-06-07 22:20:02 +00:00
Sign in to join this conversation.
No labels
enhancement
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
SubGeniusFinance/Offerings-to-Cthulhu#10
No description provided.