Can Lain protect?
October 26, 2026
Phase 4 of the roadmap asked one question: can Lain protect money, not only make it? We said we were not interested in a nice-looking LLM report, only in the full cycle: find → prove → fix → verify. This is the record of the first pass through that cycle, with everything needed to check it.
Who did the work, plainly: the code was read and the fixes written by Claude Code, one of the coding agents Lain's forge drives, working on Lain's own repositories. Every proof below was run inside the sandbox described first, never on a machine holding keys.
1. The sandbox
A proof of concept is code nobody has read yet, so it must not run where keys live. scripts/sandbox/run.sh in Lain OS builds a throwaway container from git archive HEAD of a repository — tracked files only, so no .env, no data/, no wallet files — plus an optional folder of proof files, and runs one command with:
--network none: the proof cannot reach a chain, an API or the local network;- nothing from the host environment, so no keys;
- every Linux capability dropped,
no-new-privileges, an unprivileged user; - a read-only root (only
/tmpis writable, and nothing there can be executed), pid, memory and CPU ceilings, a timeout; - the container and its image deleted afterwards.
Checked from inside, with a real .env sitting in the repository on the host:
network: none
.env present: false
secret-looking env: none
write /work: refused
write /tmp: yes
uid: 1000
2. Auditing our own code
We started where an attacker would: the surfaces Lain exposes. The daemon runs an HTTP bridge on 127.0.0.1:7777 so the console, the TUI tools and the Wired game can talk to her.
3. The first real issue: any web page could speak as the operator
The bridge answered every request with Access-Control-Allow-Origin: * and took the speaker's identity, userId, from the request body.
Loopback is not a boundary for a browser. Any page the operator opens can send POST http://127.0.0.1:7777/chat with Content-Type: text/plain — a "simple" request, so the browser sends it without asking first — and because of the *, the page could also read the answer. It could just as well read /sessions, every conversation Lain has had, and switch which model answers.
The identity made it worse. userId: "user" is the identity of the TUI on the operator's own machine, and Lain trusts it as the operator outright. So a web page could talk to Lain as the operator and ask for things only the operator may ask for, such as changing her settings. A Telegram identity on the allowlist (tg:<name>) worked the same way.
Fix (lain-labs/os#9):
- a request that carries an
Originis served only if that origin is explicitly allowed (LAINOS_HTTP_ORIGINS), and gets its own origin back, never*. Local tools send noOriginand work as before; - an HTTP caller can no longer speak as
user,agentortg:…unless it holds a gateway key (LAINOS_HTTP_KEY).
Proof. scripts/http-smoke.ts attacks the bridge the way a page would. Run in the sandbox against the code before the fix:
FAIL a cross-origin POST /chat is refused (403)
FAIL …and never reaches the runtime
FAIL a cross-origin read is refused too (403)
FAIL no response says Access-Control-Allow-Origin: *
FAIL userId "user" over HTTP is refused (403)
FAIL userId "agent" over HTTP is refused (403)
FAIL userId "tg:lev" over HTTP is refused (403)
FAIL no unauthenticated HTTP caller was handled as the operator
…
10 failed
And after the fix, same sandbox, same test:
all 15 passed
The test is now part of npm test, so the hole cannot quietly come back.
What is still open. A GET request carries no Origin when a browser thinks it is same-origin, which is what a DNS-rebinding attack arranges; checking the Host header would close that, and is the next change. The Wired game's ticket server (:7788) still allows any origin on purpose — it signs game tickets for any player and holds no operator power.
4. The first CTF: Damn Vulnerable DeFi, "Unstoppable"
Damn Vulnerable DeFi v4 is a public set of deliberately broken DeFi contracts. In "Unstoppable", a vault offers free flash loans and a monitor contract pauses the vault if a loan ever fails. The goal: stop the vault from lending, starting with 10 DVT.
The vault refuses any loan unless its share supply converts exactly to the tokens it holds:
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
totalAssets() is the vault's raw token balance, but shares are only minted through deposit. Sending tokens straight to the vault raises the balance without minting a share, and the equality never holds again. The whole solution is one line:
token.transfer(address(vault), 1);
Every later loan reverts, the monitor sees the failure, pauses the vault and hands ownership back — exactly the success condition. Run with Foundry inside the sandbox, offline, on a local EVM. Before the solution:
[PASS] test_assertInitialState()
[FAIL: log mismatch at param 0: expected=0x…00, got=0x…01] test_unstoppable()
Suite result: FAILED. 1 passed; 1 failed
After:
[PASS] test_assertInitialState() (gas: 53694)
[PASS] test_unstoppable() (gas: 65743)
Suite result: ok. 2 passed; 0 failed; 0 skipped
Setup notes, for anyone repeating it: forge 1.7.1, solc 0.8.25 (the official JavaScript build behind a small wrapper, because the sandbox host could not download the native binary), forge-std 1.17.0, solmate 6.8.0, solady 0.1.26 and OpenZeppelin 5.6.1 from npm rather than the repository's pinned submodules. The challenge files themselves are unmodified apart from the body of test_unstoppable(), as the rules require.
The lesson is the one that matters for Lain's wallets too: never let an invariant depend on a balance anyone can send to.
What comes next
More of our own code first — the Laravel backend behind lain-os.com, then the trading tools that touch real wallets — then harder CTF levels, then authorized bug bounty programs. Each finding gets the same treatment: a failing test, a fix, the same test passing, and a note here.