LNbits incident of 8 November 2026 – technical analysis
How the wallets were drained – written up so that other operators and the LNbits project can learn from it.
Affected software: LNbits v1.6.2 (self-hosted). Analysis as of: 10 November 2026. All times in UTC.
Summary (TL;DR)
- An attacker needed no server or admin access. An ordinary user account – the kind a multi-user instance legitimately and intentionally hands out – was enough to reach other users' balances through the bugs.
- Three weaknesses were chained: an IDOR in the TPoS extension, a
local file read / SSRF in the Paywall extension, and the enabled login method
user-id-only(the user ID is the password). - Result: takeover of the superuser, read-out of all admin keys, systematic draining of the wallets.
- Damage: 864 payouts; the bulk of the stolen sats went to one destination node. The exact total is deliberately not stated here.
- The Lightning node (LND) itself was not directly abused – every LND payment has a matching LNbits record,
no channel close, no on-chain drain. However, the mounted
admin.macaroonwas readable and must be treated as compromised.
user-id-only as a credential
5. Aggravating configuration
6. Timeline
7. Indicators of Compromise
8. Am I affected? – Detection
9. Countermeasures & hardening
1. The attack chain at a glance
The starting point is an ordinary user account, the kind a multi-user instance legitimately and intentionally hands out – so no privileged access. From there the three bugs interlock; each one is bad on its own, together they add up to full takeover:
- Vulnerability A (TPoS): With your own "TPoS" terminal, an update can re-point the wallet assignment to a foreign wallet, which is then drained via the ATM/withdraw function. The first target was the service-fee wallet (owned by the superuser).
- Vulnerability B (Paywall): A
file-type paywall fetches a freely chosen URL viaurllib.file://is accepted → arbitrary local files become readable, including the LNbits database (all keys), the.super_userfile and the mounted LND macaroon. - Vulnerability C (auth): With the extracted superuser ID, a
user-id-onlylogin is enough to become admin. - Plunder: As superuser, list all users, fetch their admin keys and pay out every wallet to the
attacker's own node via
POST /api/v1/payments.
2. Vulnerability A — TPoS IDOR (wallet takeover)
IDOR / Broken Access Control Extension tpos, endpoint PUT /tpos/api/v1/tposs/{tpos_id}.
The data model of the update request contains a freely settable wallet field:
class CreateTposData(BaseModel):
wallet: str | None # <- client-controlled
name: str | None
...
The handler checks ownership only against the current state and then writes back all
supplied fields unfiltered via setattr:
@tpos_api_router.put("/api/v1/tposs/{tpos_id}")
async def api_tpos_update(data, tpos_id,
wallet = Depends(require_admin_key)):
tpos = await get_tpos(tpos_id)
...
if wallet.wallet.id != tpos.wallet: # ownership check
raise HTTPException(403, "Not your TPoS.")
...
for field, value in update_payload.items():
setattr(tpos, field, value) # <- 'wallet' gets overwritten
tpos = await update_tpos(tpos)
The logic flaw: the check only confirms that the TPoS belongs to the caller right now.
Immediately afterwards, the same request is allowed to set the wallet assignment to any foreign wallet.
There is no second check whether the new target wallet belongs to the caller.
Flow (conceptual)
- Create your own TPoS →
wallet= your own wallet (ownership check passes). PUT …/tposs/{id}withwallet= a foreign wallet ID → assignment re-pointed.- Trigger the TPoS ATM/withdraw function. The system now pulls from the foreign wallet. In the logs this shows
up as error messages like
Insufficient balance. Your balance is 398313 sats– even though your own wallet had 0 sat.
This requires knowing a foreign wallet ID. For the service-fee wallet it can be obtained indirectly, because
service-fee bookings carry the same payment_hash as the triggering payment. On this server,
47 attacker TPoS with withdraw_limit = 100000000 were pointed at the fee wallet.
3. Vulnerability B — Paywall local file read / SSRF
LFI / SSRF Extension paywall (v1.2.0), the paywall download endpoint.
A file-type paywall stores a user-set URL in file_config.url. On download it is passed
unfiltered to urllib:
async def _file_streamer(url, headers):
with request.urlopen(request.Request(url=url, headers=headers)) as dl_file:
yield dl_file.read()
The problem: urllib.request.urlopen accepts not only http(s):// but also
file://. There is no scheme allow-list and no path restriction. This turns the endpoint into:
- Local file read via
file:///…– any file readable by the container. - SSRF via
http://…– requests from the server's point of view (internal services, cloud metadata, etc.).
Aggravating factor: the LNbits container ran as root and had the data directory as well as the LND directory mounted. Readable this way were, among others:
- the LNbits SQLite database → all wallet admin keys and account data,
- the
.super_userfile → the superuser ID, - the (read-only) mounted
admin.macaroonof the Lightning node.
The download requires a paid invoice – but the amount is free and may be 0, so that hurdle
disappears. The last stored probe value in the logs is file:///data/.env. Which files were
actually read cannot be proven from the logs; the superuser login that immediately follows (see Vulnerability C) is
exactly consistent with it.
Note: a second extension (fossa) also accepts a foreign wallet on creation (IDOR). No
funds were demonstrably moved through it, but the bug should be fixed as well.
4. Vulnerability C — user-id-only as a credential
Design / Auth Core setting auth_allowed_methods.
When the user-id-only method is enabled, knowing a user ID is enough to log in as that user
(…/api/v1/auth?usr=<id>). The user ID is thus effectively username and password in one.
Combined with the local file read from Vulnerability B (reading .super_user), this leads straight to
admin rights: afterwards /admin/api/v1/settings returned status 200, and the attacker
side enumerated every account along with wallets and admin keys via
/users/api/v1/user?limit=100&offset=….
5. Aggravating configuration
These points are what made the damage big in the first place – independent of the code bugs:
- Public multi-user instance: running it for other users is a legitimate, intended use of LNbits – but it means every authenticated route must be treated as potentially hostile (defense in depth). This is exactly where the extensions failed.
user-id-onlyinauth_allowed_methods: turns a leaked ID into a valid login instantly.- Service-fee wallet owned by the superuser: an attractive, central target.
- Container as root, LND macaroon mounted: elevates a file read into a node-level risk.
- Audit log only for 4xx/5xx, without IP: reconstruction was only possible via the general debug log.
6. Timeline (UTC)
| Time | Event |
|---|---|
| 07 Nov, evening | First recon: probing /api/v1/auth/impersonate, /admin/…, a test registration. |
| 08 Nov 09:42–09:49 | Several throwaway accounts (incl. "admin"). Automated probing of the extensions. |
| 08 Nov 09:48 | Fossa IDOR tested against a foreign wallet (first tested with deadbeef… to check validation). |
| 08 Nov 10:33 | Phase 1 – TPoS IDOR: the fee wallet is drained via re-pointed TPoS. |
| 08 Nov 11:52–12:27 | Paywall LFI: file-type paywall, file:///…, repeated downloads. |
| 08 Nov 12:02 | First usr= login. |
| 08 Nov 12:31 | Superuser confirmed (/admin/api/v1/settings → 200), mass enumeration of all users. |
| 08 Nov 13:11–16:22 | Phase 2: systematic draining of the wallets, roughly one payment every 5 s. |
| 08 Nov 14:13–14:16 | Attempts via the node UI (phoenixd/send, DELETE /node/…/channels) – all failed. |
| 08 Nov 16:22 | LNbits stopped → attack ends. No attacker activity after restart. |
7. Indicators of Compromise (IoC)
Attacker-related artefacts. The IP addresses come from the application log; because the reverse
proxy was configured to trust any X-Forwarded-For, they may be spoofed and should be treated as
indicative only.
Destination node of the stolen sats
021294fff596e497ad2902cd5f19673e9020953d90625d68c22e91b51a45c032d3
Nearly all stolen sats went to this one node. Operators of custodial/exchange services can check this pubkey.
Observed source IPs (treat as spoofable)
66.179.142.14 64.38.116.4 45.86.210.167
186.247.35.215 186.247.40.143 216.203.38.19
94.183.234.112 104.28.0.0/16 (WARP range)
Attacker artefacts in the extension data
- ~47 TPoS with random names (e.g.
awu421m8,atmfjyiks) andwithdraw_limit = 100000000on a foreign wallet. - A
file-type paywall withfile_config.url = file:///…(e.g.file:///data/.env). - Throwaway accounts created in quick succession, including the username
admin.
8. Am I affected? — Detection
To check your own instance (stop the service first and work on copies):
Log signatures
# suspicious wallet takeover via TPoS update, followed by a "foreign" balance
grep -nE 'PUT /tpos/api/v1/tposs/' debug.log
grep -n 'Insufficient balance. Your balance is' debug.log
# paywall file downloads (LFI probes)
grep -nE 'paywall/(download|api/v1/paywalls)' debug.log
# login by user ID + admin access
grep -nE '/api/v1/auth\?usr=|/admin/api/v1/settings|/users/api/v1/user' debug.log
In the database
- TPoS and Fossa entries whose
walletdoes not match the creating account. - Paywalls with
extras.type = "file"and afile_config.urlstarting withfile:or an internal address. - Conspicuous series of outgoing payments (same amounts, ~5-second cadence) to the same destination node.
9. Countermeasures & hardening
Immediately
- Shut LNbits down until patched extensions/version are deployed, and do not run it publicly reachable.
- If an LND macaroon was readable inside the container: rotate the macaroons and reconnect all dependent services. Treat the old
admin.macaroonas compromised. - Treat all LNbits keys as compromised: rotate wallet admin/invoice keys, rebuild the superuser, block attacker accounts, delete attacker objects (TPoS/Fossa/Paywall).
Configuration
- Remove
user-id-onlyfromauth_allowed_methods. - Weigh public reachability and open registration deliberately – where multi-user operation is not needed, keep the instance internal/authenticated. (Multi-user hosting remains legitimate; then defense in depth is what counts.)
- Separate the service-fee wallet from the superuser account.
- Disable unneeded extensions – in particular
tpos,paywallandfossauntil fixed. - Do not run the container as root; restrict mounts (especially node macaroons) to the minimum and read-only.
- Enable full audit logging (including 2xx and the source IP), and do not let the reverse proxy blindly trust any
X-Forwarded-For.
Fix recommendations for the extension authors
- TPoS: on update, validate the new target wallet against the owner – or exclude the
walletfield from the update model entirely (makewalletimmutable). - Paywall: enforce a scheme allow-list in
urlopen(https, possiblyhttp), blockfile://and internal targets, and don't run the fetch with the app user's filesystem access. - Fossa: on creation, verify that the given wallet belongs to the caller.
This report has been deliberately anonymised: internal addresses, hostnames, wallet IDs and accounts of the affected instance have been removed or replaced with placeholders. Attacker-related artefacts are kept so they can serve defence and attribution.