← Back to main page Technical post-mortem

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.

Responsible disclosure. The underlying bugs live in the open-source LNbits code and were actively exploited on several servers; the LNbits project asked operators worldwide to shut down. This report describes the methodology at a conceptual level so the flaws can be recognised, checked and fixed – it is deliberately not a ready-made attack tool and contains no runnable payload scripts.

Summary (TL;DR)

Contents 1. The attack chain at a glance 2. Vulnerability A – TPoS IDOR (wallet takeover) 3. Vulnerability B – Paywall local file read / SSRF 4. Vulnerability C – 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:

  1. 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).
  2. Vulnerability B (Paywall): A file-type paywall fetches a freely chosen URL via urllib. file:// is accepted → arbitrary local files become readable, including the LNbits database (all keys), the .super_user file and the mounted LND macaroon.
  3. Vulnerability C (auth): With the extracted superuser ID, a user-id-only login is enough to become admin.
  4. 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)

  1. Create your own TPoS → wallet = your own wallet (ownership check passes).
  2. PUT …/tposs/{id} with wallet = a foreign wallet ID → assignment re-pointed.
  3. 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:

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 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:

6. Timeline (UTC)

TimeEvent
07 Nov, eveningFirst recon: probing /api/v1/auth/impersonate, /admin/…, a test registration.
08 Nov 09:42–09:49Several throwaway accounts (incl. "admin"). Automated probing of the extensions.
08 Nov 09:48Fossa IDOR tested against a foreign wallet (first tested with deadbeef… to check validation).
08 Nov 10:33Phase 1 – TPoS IDOR: the fee wallet is drained via re-pointed TPoS.
08 Nov 11:52–12:27Paywall LFI: file-type paywall, file:///…, repeated downloads.
08 Nov 12:02First usr= login.
08 Nov 12:31Superuser confirmed (/admin/api/v1/settings → 200), mass enumeration of all users.
08 Nov 13:11–16:22Phase 2: systematic draining of the wallets, roughly one payment every 5 s.
08 Nov 14:13–14:16Attempts via the node UI (phoenixd/send, DELETE /node/…/channels) – all failed.
08 Nov 16:22LNbits 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

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

9. Countermeasures & hardening

Immediately

Configuration

Fix recommendations for the extension authors


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.

Last updated: 10 November 2026  ·  Back to main page