🔑 THIS PAGE ASSUMES THE DEVICE IS YOURS. THAT IS NOT A DISCLAIMER — IT IS THE SUBJECT.
Everything here is written for someone holding hardware they bought, pointing it at a core they run. ⭐ That is not packaging around the technical content; it is what the technical content is for. A guide to reusing your own device and a guide to attacking someone else’s are different documents even where a paragraph would look the same. ⛔ Nothing here is for equipment or a network you do not own — not a carrier’s, not a neighbour’s, not one you found. No route on this page is published to help you reach somebody else’s unit, and every one of them needs physical or LAN access you would only have to your own. 📌 What you may publish, what you may not, and the one bright line:
LEGAL.md.
🔴 WHICH RALINK BUILD IS THIS PROCEDURE FOR? — READ THIS BEFORE YOU LOG IN
Everything below that uses
guest/1qaz@WSXdescribes a Ralink runningFW:1.0.34. ⛔ THE ACCOUNT SETS ON THE TWO BUILDS ARE DISJOINT, NOT OVERLAPPING:FW:1.0.34 guest:…:1001:1001:guest:/tmp:/bin/sh <- THE ENTIRE FILE. No root. No sshd. FW:1.0.31 root:…:0:0: + sshd:…:0:0: <- THE ENTIRE FILE. NO `guest` AT ALL. FW:1.0.29 root + sshd, same as 1.0.31⇒ ⛔ On a 1.0.29/1.0.31 unit,
guest/1qaz@WSXDOES NOT EXIST and the login is refused. ⇒ ☠️ AND A FAILED LOGIN ON THIS DEVICE’S SINGLE-CLIENTtelnetdIS INDISTINGUISHABLE FROM A BRICKED BOARD — so the first conclusion available to you is the worst one, and it is wrong. ✅ On 1.0.29/1.0.31 log in asroot. On 1.0.34 log in asguest.✅ CHECK THE UNIT IN FRONT OF YOU — ONE COMMAND, AND IT CANNOT GO STALE:
sh cat /etc_ro/version # -> FW:1.0.34 -- Fri Apr 20 20:43:20 CST 2012# or FW:1.0.31 – Thu Oct 6 09:37:21 CST 2011 ⭐ The IMAGE states its own identity. This document cannot. An undated, unversioned procedure is undetectably stale: there is no anchor for a reader to compare against the device, so nothing in the text can contradict a unit that has been re-flashed or bank-flipped. ⚠️ Dates in this file certify WHEN something was measured, never WHETHER IT STILL HOLDS.
Scope: the shell-level detail is measured on a DPH-151. The two-processor architecture is
established for the 151 and 153. The DPH-154 is a different design — see
HARDWARE.md — and none of the chip-level detail here should be assumed to
apply to it.
🎯 WHICH ROUTE REACHES WHICH CHIP — READ THIS BEFORE PICKING ONE
[added 2026-09-13. This document listed six routes for months and never said which SoC each one lands on. A lane with Ralink root read the whole file looking for the way onward.]
route lands on works on a COLD unit? 1 — SSH to the radio processor picoChip .186⛔ NO — “available once you have installed a key.” It does not tell you how to install one. 2 — DMI console picoChip’s attribute model ⚠️ configures the cell. Not a shell. 3 — the Ralink gateway (telnet · guest/pw · IPC injection ·wizardUDP 14677)Ralink .185✅ YES — this is the one that works cold 4 — serial console both had UART headers ⚠️ case opening. Not the path used here. 5 — the management plane (TR-069/CWMP) picoChip config ⚠️ config only. Not a shell — but see PATH D 6 — the nano3G’s DMI ⛔ a DIFFERENT DEVICE n/a to a DPH-151 ⛔⛔ SO THE HONEST SUMMARY: EVERY ROUTE THAT WORKS COLD LANDS ON THE RALINK.
There is no documented route that gets you a shell on the picoChip from scratch. Route 1 presupposes the key; Routes 2 and 5 are configuration, not shells; Route 4 needs the case open. ⇒ ⭐⭐⭐ The chain that closes it is PATH D — the CWMP route TO a shell:
CMHS/ACS binds -> Download RPC -> sdphook -> post_swdl_hook runs it AS ROOT -> enables sshd + installs a key -> THEN Route 1 becomes available⇒ Route 1 is the DESTINATION, not an entry point.
✅✅ AND PATH D IS NOT A THEORY — IT IS HOW
.244’s PICOCHIP ACTUALLY GOT ROOT.`[findings-dph151-root-baseline.md:1-4 — “first root baseline, measured 2026-09-05 21:59Z. READ OFF THE DEVICE OVER SSH AS uid=0 ON THE PICO (192.168.157.186, via the Ralink
:22 DNAT). Everything below is measured, not inferred." — and :8 attributes the nv_env write to "OUR v7 HOOK", :57 to "the hook installed JP's dph151-jp key".]` ⚠️ **This page previously said PATH D was "assembled from measured parts". That hedge was WRONG and it was mine** — it turns a demonstrated capability into a hypothesis, which is exactly what stops the next reader using it. ### ⛔ **TWO OBSTACLES, BOTH FROM THE SAME FILE, BOTH NAMED** ``` :33 persistent_ssh.sh kills DslmSsp — THE MAIN APPLICATION. On a unit that is NOT mid-transaction it plausibly CAUSES the reboot it exists to prevent. ⇒ kill rmmwd swdl_client post_swdl_hook ONLY. :47 the retry authenticated with cwmp_rce_key while the hook had installed dph151-jp. DIFFERENT KEYS, rc=255 six times. ⭐ A lost race and a rejected key are indistinguishable from outside — only the return code separated them. ✅ v8 installs BOTH public keys. ``` ⏱️ **SSH opens for ~30–60 s and then the unit auto-reboots.** `persistent_ssh.sh` must run **inside that window** or the service closes again. ⛔ **The upstream hook installs a PUBLISHED private key** (tracked in a public third-party repo). **Mint a fresh pair before driving it.** ⛔ **Its upstream hook installs a PUBLISHED private key** (tracked in a public third-party repo). **Mint a fresh pair before it is ever driven.**
On the DPH-151 and DPH-153 the unit is two SoCs, and confusing them wastes days:
picoChip SoC the RADIO half. Runs the ip.access femtocell stack,
the DMI management interface, the cell, Iuh.
│
│ a private point-to-point link, /30, two usable addresses,
│ fixed at the factory in RFC1918 space
│
Ralink SoC the NETWORK GATEWAY half. DHCP server, NAT, and the
pico's default route to your LAN.
Everything published about rooting these devices in 2012 is about the Ralink — the
UART header, the SSH password, the wizard backdoor. Everything that gates RF is on the
picoChip. “Assume root” usually means root on the half that does not control the radio.
⭐ But the Ralink is the gateway, so from Ralink root you control every packet the radio half sees — which is what makes “be the infrastructure” viable without ever rooting the picoChip.
⚠️ Read the link addresses off your own unit
ip addr/ifconfigon either side, or the boot log. They are factory-fixed, but write down what your unit says rather than trusting any number in a guide, including this one.
The Ralink DNATs several TCP ports straight through to the picoChip. ⇒ The SSH you reach at the unit’s LAN address is the picoChip’s, not the Ralink’s. The Ralink’s own SSH port is closed — because it has no SSH server at all (below).
⚠️ THE PORT LIST IS PER-UNIT — READ IT OFF YOUR OWN NAT TABLE, DO NOT INHERIT IT
`[measured 2026-09-14: .244 forwards 22, 80, 8080, 20000; .106 forwards 22, 80, 8080 — no
- lucid-console154, live
iptables -t nat -L PREROUTING -n, both controls passing.]⇒ **set_port_fwdis a settable verb, so the forwards ARE per-unit configuration.** A list lifted from another unit's findings is a.244fact wearing a.106` label — exactly the device-scope error this corpus repeats most. Enumerate; never assume the fourth rule is there.
⚠️ “NO SSH” IS NOT “NO REMOTE SHELL” — TWO READERS MADE THAT LEAP ON 2026-09-13. The Ralink has
telnetd, and it is reachable. See Route 3 below. Do not read this paragraph as “the gateway SoC is unreachable”; it says only that the protocol is not SSH.
🎯 THIS IS HOW A DPH GETS ROOT ON ITS PICOCHIP. NO JTAG. NO SERIAL. NO SOLDERING.
[JP, 2026-09-13, verbatim: **"no we did it all over netork"** · and Osmocom Discourse topic 2625 post 41 (tempest → nickvsnetworking), recovered by JP because Anubis blocks automated fetch. Full text:(private lab notes: Discourse topic 2625 post 41, tempest, 2026-09-04)]⛔ EVERY OTHER ROUTE ON THIS PAGE LANDS ON THE RALINK, OR NEEDS A KEY THAT IS ALREADY ON THE PICO. Route 0 is the step that crosses between them, and it was missing from this document until 2026-09-13 — which cost a lane an entire evening on Route 5, which this page says in bold is not how root is obtained.
1. ROUTE + FIREWALL + TWO LAN IPs
a host route to 192.168.157.186/30 via the DPH's LAN IP, allowed through your firewall,
and TWO LAN IPs on your side (the ACS and CMHS must be on DIFFERENT addresses —
the femto sends NO TLS SNI, so the handler and certificate are chosen BY DESTINATION IP).
2. ./certpatch.sh — IT BUILDS TWO CERTS FOR TWO DIFFERENT LEGS. THE RCE IS ON THE ACS LEG.
chain203.pem ACS femtocell.wireless.att.com O=att, no OU IP:10.0.0.228 <- THE TARGET
chain202.pem CMHS cmhs*.wireless.att.com O=attmobility,TPE IP:10.0.0.36
⭐ tempest activates ONE CMHS FQDN and it differs per unit ("my unit likes looking up
cmhsse-decatur..."), but CMHS IS NEVER DEFEATED — IT IS BYPASSED. The shell comes from
the ACS/TR-069 leg. WATCH WHAT YOUR UNIT DIALS; do not guess and do not activate all eight.
3. SERVE THE DOWNLOAD. The unit pulls it from the python server.
4. ⏱ SSH IS OPEN FOR ~30-60 SECONDS, AND THEN THE UNIT REBOOTS ITSELF.
5. ./persistent_ssh.sh — INSIDE THAT WINDOW.
⛔ Without it the unit reboots and shuts the SSH server off again.
⏱ THE 30-60 SECOND WINDOW IS THE WHOLE OPERATION — HAVE STEP 5 READY TO RUN BEFORE STEP 3.
The self-reboot is EXPECTED BEHAVIOUR, not a fault and not a sign you broke something. ⇒ Plan it as a race you have already staged for, not as a step you perform when you see it.
⚠️ THE CERTIFICATE DATES, AND THE ORIGINAL AUTHOR’S OWN DOUBT — KEPT BECAUSE IT IS HONEST
The reason given is that the Ralink’s clock resets to 1/1/2000 whenever power is pulled, so certificate validity must start at 1/1/2000. ⚠️ tempest is explicitly sceptical of that reasoning: “I ran the date command over telnet, and it had no effect on the problem where the DPH terminated the connection early, maybe it’s something else? But it does work.” ⇒ ⭐ The BACKDATING IS LOAD-BEARING; the EXPLANATION for it is not established. Do the backdating; do not build on the reason.
📋 WHAT “IT TERMINATED THE CONNECTION EARLY” LOOKS LIKE — so you recognise it
Completes mutual TLS, presents its factory Cisco client certificate, sends ZERO application bytes, and FINs at ~11-12 ms.
[measured on the wire here 2026-09-13, and matching findings-lead-howwegotin.md's description of the pre-fix state]⇒ That is the symptom Route 0 exists to cure. It is NOT a firewall, NOT a routing fault, and NOT a server-side bug — a same-endpoint control shows a working unit succeeding against the identical IP, handler and server certificate.
✅✅ AND IT IS CONFIRMED ON A DPH-151 — BY THE PERSON WHO RAN IT
[JP, 2026-09-13, verbatim: **"the 153 exploit to access the pico worked on the 151 we did it alrday"** · and **"no we did it all over netork"**]⇒ ⭐⭐⭐ THE 153’s PICO EXPLOIT WORKS ON THE 151. IT HAS BEEN DONE ON<unit-151-a>, WHICH HAS A LIVE ROOT SHELL ON BOTH CHIPS TODAY (ip202ff, uid 0 — verified 2026-09-13). ⛔ THIS REPO’S OWNreference/working-scripts/README.mdCARRIES THE OPPOSITE CAUTION: “These target a DPH-153. Our unit is a DPH-151… tempest states plainly that the 151/154 have no reliable instructions.” ⇒ That caution was correct WHEN WRITTEN and is now superseded by our own operational result. It is the reason nobody here reached for this route for days. ⭐⭐ The SOFTWARE half transfers. It is only the 153’s JTAG/HARDWARE half that does not — and Route 0 needs none of it.
📌 Also known to work on at least 4 DPH-153 units (tempest, post 41).
This is the comfortable route, and it is available once you have installed a key.
⛔ A modern OpenSSH client will refuse this device three times, for three different
reasons, in sequence.
The daemon is an old dropbear on a 2.6-series kernel and offers only algorithms modern OpenSSH has disabled. You will hit key exchange, then host key, then cipher — fixing each one reveals the next. Several people here rediscovered one flag at a time over weeks because each error looks like the final answer.
✅ Put it all in
~/.ssh/configonce:Host mycell HostName <ip> User root KexAlgorithms +diffie-hellman-group1-sha1 HostKeyAlgorithms +ssh-rsa PubkeyAcceptedAlgorithms +ssh-rsa Ciphers +aes128-cbc MACs +hmac-sha1A bare
ssh <ip>will keep failing. That is the client refusing, not the device being down.
Four independent layers, all worth having, because any one of them can be undone by a firmware operation:
The ip.access stack exposes a DMI interface that speaks get / set / action against
the device’s attribute model. This is the interface that actually configures the cell —
and, critically, the one that moves what goes on the air. The management/TR-069 tree is a
different store; see trap 15 in TRAPS.md.
Probe 8090 first on any new unit. If it is open you may not need a root shell at all, and the whole project collapses to a config exercise.
⚠️ Three things about port 8090 that are not what you would guess
- It was NOT listening by default on our DPH-151. Measured: zero listeners, with
/proc/net/tcpconfirmed readable so it is a real zero rather than a blind one. It has to be started — and the library path is required or the binary dies immediately.- “Unauthenticated” is now MEASURED — but on the sibling, not on a MicroCell. On an ip.access nano3G the port is a telnet DMI console with no authentication step of any kind, demonstrated rather than assumed, and it is a full root path from there (Route 6). On a DPH-151 it remains inferred: no login step appears in any documented sequence, and nobody here has demonstrated it, because on our unit the port never listened. ⇒ Plan for it being unauthenticated; do not claim it is, on a MicroCell.
- ✅ There is a better path that bypasses the socket entirely — the DMI client has a one-shot command mode you can run over SSH. It is non-interactive, scriptable, and does not consume the single-client port. Prefer it.
⚠️ And two ways the telnet front end fails without telling you
- The client sends a Telnet linemode negotiation on connect, and if nobody answers it, the prompt never arrives and every command times out.
- A failed read returns the bare prompt, not empty output. So an emptiness check passes a non-answer and you act on it.
⚠️ Two transports, and the wrong one is silent rather than broken
On our DPH-151 the telnet front end accepted input and never answered a
get— bare LF, CRLF, and Telnet linemode negotiation all returned a banner and admi>prompt with no result — while the local one-shot client answered the byte-identical commands.✅ Validate your transport with a known-present attribute before believing any silence. Otherwise “attribute not found” and “transport dead” are the same observation.
⚠️ One command per invocation. A single bad attribute aborts a whole DMI batch, so a valid attribute in a bad batch reads as does not exist.
⚠️ Put a timeout on every call. A get can hang indefinitely even as the only client on
the box. Without a bound, one hung call stalls everything after it — and a killed call must
not abort the run.
⛔ Never kill the management daemon. It owns the TR-069 session, the data model and the DMI action path. Killing it destroys the channel you are working over; on our unit a published “clean up these processes” list caused a reboot.
🚪 THIS ROUTE IS TWO DOORS, AND THIS PAGE USED TO LIST THEM AS ONE LINE
The summary table above reads
telnet · guest/pw · IPC injection · wizard UDP 14677as a single entry. ⛔ They are not one thing, and the difference decides which you can use:telnet tcp 23 NEEDS CREDENTIALS (build-dependent) -> INTERACTIVE shell, WITH OUTPUT wizard udp 14677 NO credentials at all -> BLIND exec. Replies to hardcoded multicast 234.2.2.7, never to you.⭐⭐ PREFER TELNET WHENEVER YOU HAVE THE CREDENTIALS — an interactive shell beats blind execution on the same chip at the same privilege. ⚠️ The backdoor sounds more capable and is less capable. ⇒ It is for the case telnet is not available: wrong build, unknown password, or
telnetdnot running. ⛔ AND NEITHER REACHES THE picoChip.wizardis a MIPS binary on the Ralink’s own filesystem, and the Ralink cannot reach the pico’s filesystem by any route[measured with root, 2026-09-14]. 📌 Full comparison, with the port-scan behaviour and how to prove blind execution worked:BRINGUP.md→ TWO DOORS INTO THE RALINK. Pointer, not a copy.
The Ralink has no SSH server as shipped. [read from both extracted rootfs banks of a
firmware image — ⚠️ this is a read of the IMAGE, not of a live device. If something was
installed at runtime this read would look identical and be wrong.] The /etc/ssh directory
is empty and dated 2011; there is no sshd and no dropbear; busybox has no ssh applets.
What it does have is telnetd, bound to the internal point-to-point address only.
🔴🔴 CORRECTED 2026-09-13 — THE INFERENCE THAT USED TO SIT HERE IS FALSE AND IT COST A LANE AN EVENING
“so it is not reachable from your LAN, and every path to it goes through the picoChip.”[MEASURED 2026-09-13: root obtained on .106's Ralink from katana, by host route + telnet, WITHOUT touching the picoChip. This sentence is why nobody tried it for weeks.]⭐⭐⭐ A BIND ADDRESS DECIDES WHICH PACKETS A DAEMON ACCEPTS. ROUTING DECIDES WHICH PACKETS ARRIVE.
The Ralink owns BOTH the LAN address AND
192.168.157.185. So a host route pointed at the LAN address is delivered locally, totelnetd, with no picoChip in the path:sudo ip route add 192.168.157.185/32 via <lan-ip> # additive, reversible telnet 192.168.157.185 # guest / 1qaz@WSX⇒ ⛔ “Bound to an address that is not on your LAN” is NOT “unreachable from your LAN.” ⚠️ The premise was true and stayed true —
telnetdreally is bound-b 192.168.157.185. Only the conclusion was wrong, which is why re-reading the sentence kept confirming it. 📌 SeeBRINGUP.mdPhase 1b, and~/Projects/microcell/keys/dph151/rroot.py.
⛔ And it structurally CANNOT keep an SSH server, which is why nobody should try
The Ralink’s root filesystem is an initramfs embedded in the kernel image. There is no rootfs partition,
/varand/tmpare RAM, and the small flash has no data partition. ⇒ Persistent SSH there means rebuilding and reflashing the kernel — on the processor that NATs every packet to the radio half. Brick it and the radio becomes unreachable too.⭐ Note why the inference from an image is sound here specifically: the image is the root filesystem by construction, so “no daemon in the image” really does mean “no daemon after any reboot”. That inference would be invalid on a device with writable flash — do not carry it across.
Access routes, all from fail0verflow’s 2012 work:
BRINGUP.md Step 1b.
🔴 CORRECTED 2026-09-18 — THIS LINE USED TO SAY THE CREDENTIAL IS IN THE fail0verflow WRITE-UP. IT IS NOT.
This bullet read:
“We are deliberately not reprinting the credential pair here; it is in the fail0verflow write-up, which you should read anyway.”Both halves were wrong. ⛔ fail0verflow never published the password. Their 2012 post says only that they cracked it — “the system includes users for ssh and root, both of which have the same password”, “non-dictionary, but after around 5 days of average processing, we were able to determine the password” — and the string appears nowhere in it. ⛔ And this repo was reprinting the pair anyway, in 8 places across two files, while this line claimed it was not. ⇒ A stated policy that the corpus does not follow is worse than no policy: it tells a reader the credentials are not here, so they stop looking and miss the bank-dependence that actually matters. ⭐ The repo’s own law, firing on the repo: a correction that updates one mention of a fact leaves the others reading as confirmation. Here the un-updated mention was the policy itself. ⚠️ The accounts are bank-dependent: on our unit one firmware bank carries aguestaccount and noroot, and the other carriesrootand noguest. ⇒Login incorrectmay mean “wrong bank”, not “wrong password”. Which bank your unit booted is a separate question — see trap 2 inTRAPS.md.
wizard UDP backdoor on port 14677 — unauthenticated remote command
execution, bound to all interfaces because it was only ever meant to be reachable inside
the operator’s IPsec tunnel.⚠️ The commonly-cited NAME for this route is wrong, and the artefact carries the error
The route that actually yields root on a DPH-151 is telnet, a hardcoded vendor guest password, and a command injection through the local IPC client — the three bullets above. It is frequently called a “CWMP RCE”, and it is not one: CWMP is not involved.
⭐ The misnomer is baked into the tooling — the key file people pass around is named for CWMP — so it propagates every time someone quotes the filename. Expect every other write-up you find to use the wrong name, and do not go looking for a TR-069 vulnerability that is not there.
⭐ The backdoor survived a hardware generation
fail0verflow published against the 151 in 2012 and the same binary is still present in the 153. It was not patched between them. ⚠️ That raises the prior for the 154; it does not settle it, and a negative there is a result worth publishing.
⚠️ Searching a firmware image for the decimal string
14677returns zero — a port lives in code as a number, not as text. It appears as an immediate load of the byte-swapped value. A string search is the wrong instrument for a port number, and its zero is not evidence of absence.
Both SoCs had UART headers. On the 151, fail0verflow used header JP1 at 56700 baud (try 57600 too).
Header JP1 on the 151: pin 1 RX, pin 2 TX, pin 3 GND.
⛔ Use a 3.3 V-only adapter, and ⭐ make your first session receive-only — with the
adapter’s TX unconnected, even a 5 V adapter cannot damage the device, so you can start
capturing before you have proved anything about voltages. Full rationale, pinout and the baud
transposition trap are in HARDWARE.md.
The boot log is the richest single artefact on these devices: it names the boot order, the configuration mechanism, the inter-processor link, and precisely what fails when the operator’s infrastructure is unreachable. Capture it to a file the first time.
⚠️ READ THE RECONCILIATION EIGHT LINES BELOW BEFORE ACTING ON THIS ROUTE’S VERDICT.
Short form: “TR-069 is not how root is obtained” is TRUE OF THE RALINK and FALSE OF THE PICOCHIP. ⇒ For a pico shell, TR-069 IS the route —
persistent_ssh.sh:6runsssh -i cwmp_rce_key root@192.168.157.186, and that is how.244first got root[findings-dph151-root-baseline.md:1-4]. See Route 0.🔴 CORRECTION TO MY OWN BANNER, WHICH STOOD HERE FOR FOUR MINUTES
[nebula-librarian3 — three errors in one edit, all mine, none caught by my own review]1. I wrote "TR-069 IS how root is obtained" FLATLY. The reconciliation below already said it better: BOTH claims are correct and they are ABOUT DIFFERENT PROCESSORS. My version would send someone to CWMP for a RALINK shell, where it is the WRONG route. 2. I wrote that the correction sat "~115 lines BELOW". IT IS EIGHT. I estimated a distance instead of counting it -- the relative-coordinate error this corpus cards -- inside a banner whose entire subject was placement. 3. My diagnosis followed from that wrong number: "a correction downstream of its error is invisible". At eight lines and clearly written, that explains nothing.⭐⭐ THE REAL EXPLANATION IS AMBIGUITY, NOT DISTANCE, and the reconciliation already named it: “THIS SENTENCE IS TRUE OF THE RALINK AND FALSE OF THE PICOCHIP, AND THE AMBIGUITY COST AN EVENING.” ⇒ On a two-processor device, a sentence true of one chip and false of the other is read as whichever chip the reader has in mind. ⛔ I “fixed” a document that had already fixed itself, less accurately, and justified it with a number I had not measured.
The TR-069 / CWMP stack is a legitimate configuration path. If you control DNS for the
device — and you do, since it is on your network and its management hostnames are dead — you
can answer as its management server without touching the device at all. See
CONFIG.md.
🔴🔴 THIS SENTENCE IS TRUE OF THE RALINK AND FALSE OF THE PICOCHIP, AND THE AMBIGUITY COST AN EVENING
[reconciled 2026-09-13 after JP: "we have all the info there you are still guesiing"]ACCESS.md Route 5 (here) "TR-069 is NOT how root is obtained on this device" reference/working-scripts/README "Root on the PICOCHIP is obtained via RCE over CWMP/TR-069 on the ACS leg, then an SSH key is planted."⇒ ⭐⭐⭐ BOTH ARE CORRECT AND THEY ARE ABOUT DIFFERENT PROCESSORS. The RALINK is rooted by telnet + a vendor guest password + IPC injection (Route 3) — and calling THAT a “CWMP RCE” is the misnomer. The PICOCHIP is rooted over the ACS/TR-069 leg — see Route 0. ⛔ Neither document cited the other, so a reader arriving at Route 5 is told the management plane is a dead end and sent away from the only route that produces a pico shell. ⚠️ It is not how root is obtained ON THE RALINK, despite a widely-repeated name that says otherwise. See the correction under Route 3 — and Route 0 for the picoChip, where it IS the route.
The point for a reader is the architecture: the device is designed to be provisioned remotely by whoever answers those hostnames, and on your own network that is you.
On getting root: the method is documented, the key is yours to make
Root on a DPH-151 comes from a publicly documented procedure, not from anything discovered here — the
wizardbackdoor above is the published route, and it lands you a root shell from which you install your own SSH key.✅ Generate your own keypair. Nobody else’s key is of any use to you, and a key that has been published is worse than no key. There is no credential in this repo for the same reason there is no point in one.
⛔ And do not “confirm it worked” by running the tool a second time — that check cannot fail. See trap 22 in
TRAPS.md; it is the single most likely way a reader misleads themselves here.
Measured on an ip.access nano3G, not on a MicroCell — see
HARDWARE.md for what
does and does not transfer. On our DPH-151 port 8090 was not listening at all, so none of
this was reachable there. It is here because the two devices run the same software train,
because the shape is a class of bug worth recognising, and because the defence is one write
and nobody would think to apply it.
TCP 8090 is a plain telnet DMI console with no authentication step of any kind — measured on this hardware, not inferred. It exposes the attribute model read/write plus the action table. Everything below follows from it being open.
It is open only when two conditions hold at once. From /etc/init.d/opnormal’s
dmistart():
if [ -f ${DMISCRIPT} ]; then # /var/ipaccess/init.dmi EXISTS
clean_sets_from_initdmi
IS_DMI_AUTOGEN=`grep -c "// init.dmi - THIS FILE IS AUTO-GENERATED" ${DMISCRIPT}`
if [ $IS_DMI_AUTOGEN -eq 0 ] && [ -x ${PROG_DMI} ]; then
${PROG_DMI} -c "call ${DMISCRIPT}" & # run it
fi
else # init.dmi ABSENT -- the ONLY telnet path
if [ ${ENV_START_DMI_TELNET:-"FALSE"} == "TRUE" ]; then
${PROG_DMI} -u ${DMI_TELNET_PORT} &
open_firewall_port ${DMI_TELNET_PORT}
fi
fi
⇒ init.dmi absent AND ENV_START_DMI_TELNET=TRUE. Both, or there is no port at all.
⚠️ Note what else lives in that branch: open_firewall_port. The firewall is not a second
layer here — it is opened by the same code that opens the port.
The firmware never writes ENV_START_DMI_TELNET. Enumerated over the whole of rcS:
$SETNVENV call sites, entire file 2 ENV_VERBOSE_CONSOLE_ENABLED
ENV_FIREWALL_DISABLED
ENV_START_DMI_TELNET, anywhere in rcS 0 <- never written by the firmware
CONTROL: ENV_VERBOSE_CONSOLE_ENABLED 4 <- the reader works, so the 0 means something
And a factory restore deletes nv_env.sh outright, so on the next boot the variable is not
set to FALSE — it is absent, and opnormal falls through to its own :-"FALSE" default.
⇒ ⭐ A virgin or factory-restored unit has the console OFF BY ABSENCE, and the commissioning
web UI’s toggle is its only writer — dmi_config.cgi, which serves exactly this:
<input type="submit" name="dmi_telnet" value=Enable >
✅ Confirmed end to end on one unit: the toggle was pressed during commissioning, and after
Complete Commissioning plus two power cycles the variable read TRUE. Since no firmware path
writes it, that TRUE can only have come from the button — and it persists.
⭐⭐ So the chain has two stages, and the first one is a supported feature
STAGE 1 the COMMISSIONING WEB UI enables the console. Not an exploit -- a BUTTON, and the only thing that sets the flag the branch above depends on. STAGE 2 the console, now reachable, is unauthenticated.⇒ A vendor-supported commissioning control turns on an unauthenticated, root-equivalent interface, and nothing else can turn it on.
⭐ This sharpens the defence rather than weakening it. There is no patch to wait for and no subtle hardening to get right: the operator’s entire control surface is that checkbox and the two conditions in that
if. Nothing else in the firmware touches the flag.⚠️ And it closes symmetrically, which is worth knowing before you press anything: a factory restore deletes
nv_env.sh, so a restore turns the console back off — along with whatever else you had configured. That is the same event as trap 49, where the threshold is shorter than the manuals say. The reset button is both the recovery path and the thing that removes your access.⛔ Measured on an ip.access nano3G. The DPH equivalent is UNMEASURED and should not be assumed — on our DPH-151 the port never listened at all.
call echoes what it cannot parsecall is documented as “Executes a DMI script”. It takes a path, opens it, and echoes every
line as ### Executing: <line> before failing to parse it. ⇒ Any file the DMI process can
open is readable — every config file, every init script, the NV environment.
⭐ Read the ### Executing: lines and not the error echo — the error path lowercases,
so a value’s case is destroyed in exactly the copy most people scroll to.
📌 This is genuinely useful for legitimate work, which is worth saying plainly: it is how you answer “is that key actually in the config file” without having a shell at all.
/var/ipaccess/nv_env.sh is sourced as root early in boot. Several MIB string attributes
are written into it verbatim, as export lines. crlServerBaseUrl (2203) is one:
set crlServerBaseUrl="http://example/" -> export ENV_CRL_BASE_SERVER="http://example/"
That is the whole bug, and it is worth stating as a class rather than as a trick: a
configuration string is interpolated into a shell file that is later executed, with no
validation and no quoting discipline. ;, backticks, $( and | all round-trip unmodified.
⚠️ The detail that makes it work where you might expect it not to: the DMI parser owns the
double quote, so the shell string cannot be terminated from inside the value. It does not
need to be — $(...) command substitution is evaluated inside double quotes. A value of the
shape x$(…) therefore has its contents run as root, at every boot, when nv_env.sh is
sourced. No second service, no network fetch, no memory corruption: the device does it because
that is what sourcing a shell file means.
⚠️ A structural caveat, which is also why this is a two-reboot procedure and not a one-shot:
$( ) runs in a subshell, so it cannot change the environment of the parent. Altering an
NV variable means editing the file and booting again.
diagnosticTuning (2320)🔴 ADDED 2026-09-16. THIS DOCUMENT TAUGHT THE NARROW PRIMITIVE AND OMITTED THE GENERAL ONE.
crlServerBaseUrllets you inject into the VALUE of ONE variable you did not choose.diagnosticTuninglets you choose the variable NAME. ⇒ Same console, same sink, more reach.
It is a vendor debug hook taking an arbitrary name/value list. [read end to end from
ipa-mgr_app 563.16.0 — the attribute-set dispatcher at cmp r5,#2320]
set diagnosticTuning=({name=ENV_VERBOSE_CONSOLE_ENABLED,value=TRUE})
The dispatcher walks the list and branches on what the NAME CONTAINS:
name contains "ENV_" -> setNvEnvVar() -> /var/ipaccess/nv_env.sh
name contains "CLI_STARTUP_CMD" -> CreateSignal(0x505) -> SendSignal(partner 5)
name contains "CLI_IMD_CMD" -> CreateSignal(0x504) -> SendSignal(partner 5)
⇒ ⭐⭐ So there are TWO primitives behind one attribute — an NV-environment write and a
command-signal dispatch — and the second has nothing to do with nv_env.sh at all.
And the NV write has the same missing quoting discipline as the route above:
setNvEnvVar(name, value, variant):
snprintf(newline, 1024, "export %s=\"%s\"\n", name, value)
... strstr(line,"export NAME=") ? replace : copy ... no match anywhere -> APPEND
rename(tmp_nv_env.sh -> nv_env.sh)
⇒ %s twice, into a shell file that is sourced as root. ⭐ Note it writes via a temp file and
rename() — an ATOMIC swap, unlike the setnv_env.sh script path, which does two sed -i passes
and an append with no atomicity.
☠️☠️ TWO NAMES, ONE CHARACTER-CLASS APART, AND THEY ARE DIFFERENT THINGS
setnv_env.sh a SHELL SCRIPT in /opt/ipaccess/bin/ <- what rcS and a root shell call setNvEnvVar() a FUNCTION INSIDE ipa-mgr_app <- what the DMI dispatcher calls⇒ ⭐⭐⭐ The DMI path reaches
nv_env.shWITHOUT invoking the shell script. The manager binary opens, rewrites andrename()s the file itself. ⚠️ THIS MATTERS BECAUSE A CAREFUL CORPUS NOTE WARNS ABOUT THE OTHER ONE:setnv_env.shcontains a string — “will be updated on OMCR (if already exist in diagnosticTunning list) on next AP reboot” — and reasoning from that string alone, whether a DMI write causes the SCRIPT to run is an INFERENCE and is correctly flagged as one. ⇒ ✅ It is not the claim above. The claim above is a disassembly read of the attribute-set dispatcher, and it bypasses the script entirely. ⭐ Two sources, two mechanisms, nearly the same name — and only one of them is an inference. ✅ The cheap confirmation, read-only and non-destructive, is still worth running first:get diagnosticTuning · get diagnosticTuning[0].name · get diagnosticTuning[0].valueAn indexed name/value list containing
ENV_*entries confirms the shape at the wire.
🔴🔴 AND A BOOTSTRAP LOOP THAT DECIDES WHETHER YOU CAN USE ANY OF THIS
DMI :8090 requires ENV_START_DMI_TELNET=TRUE opnormal.sh:258-259 (151) if [ ${ENV_START_DMI_TELNET:-"FALSE"} == "TRUE" ] <- DEFAULTS FALSE opnormal:307-308 (154) the identical gate that flag IS an NV variable ⇒ set by writing nv_env.sh and the diagnosticTuning route to nv_env.sh ⇒ REQUIRES DMI⇒ ☠️☠️☠️ YOU CANNOT OPEN THE
:8090LISTENER THROUGH THE ROUTE THAT REQUIRES IT.[corroborated: nothing else in either rootfs sets the flag — the only occurrence in the 154 tree is the gate READING it — and it is absent from all four nv_env.sh files held, 0 occurrences each]⭐⭐⭐ BUT
:8090IS NOT THE ONLY DOOR TO DMI, AND THE TWO ARE MODEL-SPECIFICTwo files in this corpus looked contradictory — one calls a DPH-151’s DMI console “open, unauthenticated”, the other has
:8090shut on both 151s. Both are right; they name DIFFERENT TRANSPORTS to the SAME console.nano3G DMI over TCP :8090 gated on ENV_START_DMI_TELNET -> the loop above binds DPH-151 DMI via `ipa-dmi -c` ⛔ NOT telnet, NOT :8090 — "the nano3G method does not apply". And the 151's Ralink has NO DNAT for 8090, so it is unreachable from the LAN whatever the picoChip does.⇒ ⭐⭐ So on a DPH-151,
ipa-dmi -cruns ON the picoChip — which means it is available once you ALREADY HAVE A SHELL THERE, and not before. ⇒ ✅ THE CORRECTED CONCLUSION, same as before but for the right reason:diagnosticTuningis a POST-SHELL CONVENIENCE on a 151, not a way in. ⛔ It is a genuine first-step route only on the nano3G, where:8090listens and is reachable. 📌 The 151’s way in staysBRINGUP.mdROUTE 3.⚠️ AND THE GENERAL LESSON, WHICH COST TWO LANES AN HOUR TONIGHT
“Has DMI” is not one predicate. A console can be reachable by one transport and not another on the same unit, and “the DMI console is open” is true or false depending which you meant. ⭐ Name the TRANSPORT, not just the console.
⛔ IT IS A WHOLE-LIST REPLACE — 32 ENTRIES, STRIDE 168 BYTES
memcpy(buf, incomingValue, 0x1504)= 4 + 32×168. ⇒ Writing the list without reading it first silently drops every entry you were not thinking about — ⚠️ including the one holding your own access open. ✅get diagnosticTuningfirst, every time.
⚠️ AND THE PAYLOAD IS DORMANT ON ARRIVAL — THIS IS WHERE PEOPLE CONCLUDE IT FAILED
The write APPENDS to
nv_env.shand does nothing else. ⛔ It does not source, restart or signal anything. ⇒ You inject, you watch, and nothing happens — because it is sitting armed. ✅ Trigger it deliberately. A LOGIN is the cheapest trigger —/etc/profilesourcesnv_env.shunconditionally, with no root guard and no variant guard. ⭐ And on these units a login IS a root login: there is exactly one uid-0 account and no non-root interactive account, so there is no weaker case to hedge. 📌 Ten consumers source that file —sshd,startgetty,iptablesinit,rcS,opnormal,startmode,diaguploadamong them — so it fires eventually on its own. ⚠️ “Eventually” is not a step in a procedure, and the gap between injection and effect is exactly where a reader gives up and reaches for something destructive.
📌 The DPH-154 reaches this same sink through CWMP instead, because it has no DMI console:
BRINGUP-DPH154.md Phase 2. ⭐ One mechanism, two doors.
The attribute route above goes through the DMI console. There is another, through the commissioning web UI, and the pair is worth seeing together because they are guarded differently:
attribute route set <a URL-shaped attribute> -> a URL-field validator exists here
file route dmi_config.cgi, multipart upload -> the file field BYPASSES that validator
form field `dmi_filename` entirely
⇒ The uploaded file lands as init.dmi, and opnormal executes it as root at boot, through
the first branch of the if above (ipa-dmi -c "call …"). ⚠️ No injection is needed on this
path at all — the file is a script, and the device runs it because that is the feature.
⭐ The shape worth carrying away: somebody DID think about validation here. There is a validator, on the field that looks like the input. The multipart file field is a second path to the same sink and does not pass through it. ⇒ When you find input validation on an embedded device, ask what else reaches the same place.
⚠️ And this changes what the defence below COSTS you
Installing an
init.dmito close:8090is still the right move. But the file you install is executed as root at every boot — that is not a side effect, it is the mechanism. ⇒ You are not disabling a feature; you are choosing to use it. Write the file accordingly, and read the marker rule immediately below before you do.
⭐⭐ Both guards on the
init.dmipath are agrepfor a COMMENTLook again at
dmistart(). The device decides whether to executeinit.dmiby counting occurrences of the string// init.dmi - THIS FILE IS AUTO-GENERATED. A file carrying that marker is not run; a file without it is. There is a second marker of the same kind —// !--- This comment is used by WebIf to save the ---!— which decides whethersetlines inside the file get commented out before it runs.⇒ Both markers are provenance claims carried in a comment, and a comment is not provenance. A file that simply omits them is executed in full, with its
setlines intact. ⚠️ And they were not written as security controls — they exist to stop the device re-running its own generated configuration. They are load-bearing for security anyway, which is the usual way this happens.⭐ The transferable lesson: when you meet an integrity check on an embedded device, ask what it would cost an author to omit. If the answer is “delete one line”, it is a marker, not a check.
☠️ And it bites the author, not just the attacker — we nearly shipped this exact failure. A first draft of one of our own
init.dmifiles carried a comment explaining the autogen banner, and that explanatory comment contained the banner text. The device’sgrep -cis unanchored and counts occurrences anywhere in the file, so it returned 1, and the script would have been silently skipped — no error, no log line, just a boot where nothing applied. ⇒ The warning would have caused the exact failure it was warning about. Do not write the marker string into a file you want executed, in any context, including a comment about the marker.
Close the console. Either condition breaks the chain, and either is one write:
ENV_START_DMI_TELNET = FALSE # via the vendor's own NV setter, then reboot
— or install any init.dmi at all, which takes the other branch and never opens the port.
⚠️ And the trade nobody discovers until it has cost them a cycle
Installing an
init.dmiCLOSES:8090. The two are mutually exclusive by construction — they are the two arms of oneif. Andinit.dmiis a genuinely useful feature: it is how you make settings apply at boot with no management server anywhere. ⇒ Deploy one and you have closed your own way in. Have another route working first, and test it before you upload.⚠️ And the consequence reaches your tooling, not just your next session. Any bring-up script that drives the console over
:8090cannot run against a unit that has aninit.dmi— not because it is broken, but because the port it needs does not exist on that unit. ⇒ A tool written for a unit withoutinit.dmicannot bring up a unit with one, and it will fail at connect with no indication that the cause is a file you installed deliberately weeks earlier. If you install one, your bring-up path has to move to a transport that does not depend on that port — the one-shot client over SSH, per Route 2.
On the injection itself there is nothing to patch — no vendor, no firmware update — so the defence is architectural:
⚠️ One robustness note if you edit nv_env.sh by any route: it has no backup and is
rewritten non-atomically on every boot — two whole-file sed passes and an append. A power
cut inside that window leaves a corrupted environment with nothing to restore from. That is a
hazard for ordinary configuration work, quite apart from anything above.
⭐ “No backup” is measured, and it is worth knowing why it is not obvious
The vendor’s setter edits with
sed -ie, which looks like it requests a backup suffix. On this firmware’s BusyBox it does not —-ithere takes no attached suffix, so-ieparses as-i -eand nothing is backed up.[measured on the device: BusyBox v1.9.2 (2011), whose own help prints a bare-i; a direct run left no suffixed file.]⚠️ On a modern BusyBox the identical flag DOES write a backup — see trap 56, because the version boundary is the whole finding.✅ So back it up yourself, by convention, before you touch it. On our unit the only copies that exist are ones we made by hand. ⇒ A reader who trusts the flag gets nothing; a reader who copies first gets everything.
⛔ What this repo does not contain, deliberately: a working payload, or a construction sequence you could follow without understanding it. Mechanisms are described — in enough detail to verify on your own unit, because a description too vague to check is not documentation — and the assembled article is not. The test applied above: a reader should finish understanding the class of bug, and be unable to skip straight to a tool. That boundary is in the README and it is not an oversight.
| route | status |
|---|---|
| SSH to the picoChip with legacy algorithms | measured live, DPH-151 |
| DMI one-shot client answering get/set/action | measured live, DPH-151 |
| DMI on TCP 8090 | measured live: not listening by default on our DPH-151, and its telnet front end did not answer once started. “Unauthenticated” is inferred on a MicroCell, never measured there. |
| DMI on TCP 8090, unauthenticated, as a root path | measured live, ip.access nano3G — console, file read, and the nv_env.sh injection sink. Not reproduced on any DPH, where the port did not listen. See Route 6. |
init.dmi closing :8090 |
read from opnormal on a nano3G — the two paths are arms of one if. Not tested by installing one. |
| Ralink has no SSH server | read from a firmware image, not confirmed on a live device |
Ralink telnet / IPC / wizard backdoor |
reported (fail0verflow 2012); wizard binary present in 151 and 153 images |
| serial console pinout and baud | reported (fail0verflow 2012) |
| anything at all on the DPH-154 | unverified |
🎯 MEASURED LIVE ON
.106(151#2), 2026-09-13 ~17:0x PDT. Root in one connect, no exploit, no injection, nothing mutated.
1. HOST ROUTES (already present on katana; add on any host that needs them)
ip route add 192.168.157.185/32 via <the unit's LAN IP> <- Ralink
ip route add 192.168.157.186/32 via <the unit's LAN IP> <- picoChip
2. telnet 192.168.157.185 -> login: root -> NO PASSWORD ASKED
BusyBox v1.8.2 (2012-04-20) ash. uid 0.
katana has NO telnet binary -> `apt install telnet`. Do this FIRST. I narrated this
blocker three times instead of spending 20 seconds on it.
Ralink busybox has NO nc, NO head, -> `nc -z .186 3001` printed "shut3001" because nc DOES NOT
NO tr, NO id, no `busybox --list` EXIST. That is a FALSE ZERO, not a closed port.
UDP/14677 tested over TCP -> the wizard backdoor is UDP. A TCP probe of a UDP service
answers "closed" for every unit, working or not.
⭐ Every one is the same species: a true statement about a narrower universe than the claim it supported. ✅ On this device, run the utility check BEFORE the measurement, and treat “not found” as a broken instrument rather than a result.
/usr/sbin: rmm_client cs_client ipc_client ipc_server config_server
telnetd udhcpd chpasswd setlogcons <- that is the WHOLE toolbox
rmm_client <ip> <command>, command list:
reset factory_reset clear_tamper do_software_download get_software_status
set_bandwidth get_bandwidth switch_fw_boot set_telnetd set_port_fwd
get_uptime cs_cmd sleep crash
rmm_client 192.168.157.185 set_telnetd 0/1
rmm_client 192.168.157.185 set_port_fwd proto port action
⛔⛔ factory_reset AND crash ARE IN THAT LIST, ONE WORD FROM cs_cmd. Never type them.
✅ Works: rmm_client 192.168.157.185 cs_cmd "<cmd>" — runs as root ON THE RALINK. Verified
with an echo marker.
🔴 Does NOT work on an unprovisioned unit: the same call against 192.168.157.186 returns
Can't read response from peer, and so does get_uptime. ⇒ The pico’s rmm/IPC responder
is DOWN, not filtered. findings-dph151-pico.md recorded this on 2026-09-05 and it is still
true on a different unit eight days later — so it is a property of the UNPROVISIONED STATE, not
of that one box.
[measured on .106, each with a passing control]
<unit-151-b>:22 / .186:22 CLOSED <- dropbear is NOT on 0.0.0.0 because
ENV_VERBOSE_CONSOLE_ENABLED != TRUE. THIS IS THE LOCK.
udp/69 TFTP no answer
tcp 80 / 443 / 8080 / 8090 CLOSED <- so the dmi_config.cgi FILE-UPLOAD route is NOT available
pico IPC 3001 responder down
CONTROL: .185:23 OPEN <- the probe works; the zeros above are real
⇒ ⭐⭐⭐ THE RALINK IS ROOT AND THE PICO IS UNTOUCHED. Getting one does NOT get you the other, and the guides that teach pico→Ralink have the dependency BACKWARDS — on a factory-fresh unit the Ralink is reachable first and the pico is behind default-deny.
.244’s PICO — recovered from (private lab notes)grep -q pounce-rce /var/ipaccess/root_home/.ssh/authorized_keys 2>/dev/null || \
echo "ssh-rsa AAAA… cwmp_rce_proof pounce-rce" >> /var/ipaccess/root_home/.ssh/authorized_keys
/opt/ipaccess/bin/setnv_env.sh ENV_START_DMI_TELNET TRUE
# DPH151_SSH_PERSIST_BEGIN
/opt/ipaccess/bin/setnv_env.sh ENV_VERBOSE_CONSOLE_ENABLED TRUE
/opt/ipaccess/bin/setnv_env.sh ENV_START_DMI_TELNET TRUE
iptables -I INPUT 1 -p tcp --dport 22 -j ACCEPT
🔴🔴🔴 THE KEY IN THAT COMMAND HAS ITS PRIVATE HALF ON PUBLIC GITHUB. DO NOT REUSE IT.
[nebula-librarian3, 2026-09-13, measured offline. Identity by HASH, not by the comment string.]selfclean_hook_jp.sh installs TWO keys: 38cb81eb65f12ad1 "dph151-jp" <- JP's own. Fine. b22d85d26763594a "cwmp_rce_proof pounce-rce" <- 🔴 THIS ONE ssh-keygen -y -f (private lab notes) -> b22d85d26763594a EXACT MATCH git -C (private lab notes) remote -v origin https://github.com/nickvsnetworking/DPH153-AT.git <- PUBLIC REPO⇒ ⛔ ANY UNIT PROVISIONED WITH THIS ACCEPTS ROOT SSH FROM A KEY ANYONE CAN
git clone. ⇒ ✅ MEASURED 2026-09-13 —.244IS IN THAT STATE, AND IT IS AN ACCEPTED RISK, NOT AN OPEN QUESTION.[team-lead, read over .244's own root shell, TWICE. This block previously said the file had NOT been read; that was true when written and is now false.]wc -l /var/ipaccess/root_home/.ssh/authorized_keys -> 4 grep -oE "dph151-jp|pounce-rce" -> 1 dph151-jp · 1 pounce-rce 🔴 PRESENT⇒ 151#1 accepts root SSH from a private key anyone can
git clone. CONFIRMED ON THE DEVICE, not inferred. ⇒ ✅ JP WAIVED IT EXPLICITLY — verbatim: “no wrorries about the public key”. ⛔ So do NOT re-raise it as a finding, and do NOT remove the key on your own initiative: it is a decision he has made, and.244is the unit that currently works. ⭐ What survives regardless of his decision is the NAMING trap above — the standing rule namedselfclean_hook.sh, the live artefact wasselfclean_hook_jp.sh, and the_jpsuffix reads like the SAFE variant. Identity came from the hash.☠️ WHY THIS WAS NOT OBVIOUS, AND IT IS A NAMING TRAP
The standing rule in
2g/CLAUDE.mdnamesselfclean_hook.shas the dangerous one. This isselfclean_hook_jp.sh— ONE SUFFIX APART, and_jpreads like the SAFE variant. ⛔ It is the public hook **PLUS JP’s key, not INSTEAD OF it.** ⇒ A rule that names a FILE does not reach a copy of that file under another name. ⭐ And the comment stringpounce-rcelooks bespoke — locally coined, one-off, ours. It is not. ⇒ Path is position, hash is identity — this corpus’s own law, applied to a key.✅ THE FIX IS ONE LINE, AND A CLEAN REPLACEMENT ALREADY EXISTS
Delete the
pounce-rceline from the hook and install only JP’s key — or the keypair minted 2026-09-13 at~/Projects/microcell/keys/dph151/dph151_nebula_2026-09-13(RSA-2048,0600, private half has never left katana). ⛔ Do not serve the two-key hook to another unit.✅ RESOLUTION, SAME NIGHT — THE SERVED PAYLOAD IS CLEAN. THIS IS NOT ON THE LIVE PATH.
[measured offline 2026-09-13, by key-body hash across every artifact in the tree]ACS SDP_URL (config/exchange/dph153-acs/acs_tr069.py:10) = .../rmm-selfclean-v10.sdp evidence/v10/rmm-selfclean-v10.sdp.c77d57af... pounce-rce: 0 ✅ CLEAN ---- the artifacts that DO still carry it ---- build/dph151/selfclean_hook_jp.sh 1 build/dph151/rmm-selfclean-v8.sdp 1 evidence/v9/rmm-selfclean-v9.sdp 2 (already DO-NOT-SERVE) rmm-selfclean.sdp (unversioned) · v7 · v10 0 ✅⇒ ✅ A BOOT Inform -> Download ->
post_swdl_hookrun will NOT install the public key. ⛔ WHAT REMAINS TRUE:selfclean_hook_jp.shandv8.sdpstill contain it, and this page named that hook as “the payload”. ⇒ A rebuild, a fallback to v8, or following the older line above RE-INTRODUCES IT. ⭐ And the corpus already warns that three paths carry the unversionedrmm-selfclean.sdpname — here the wrong pick is also the UNSAFE one. ⚠️ BOUND: this was checked against the PRESERVEDevidence/v10copy, identified by the hash in its filename. Whether the LIVE docroot still serves those exact bytes was NOT verified — that needs onecurlfromexchangeagainst that hash.⛔⛔ FORWARD HAZARD: THE PACKED SDP IS OLDER THAN THE
.shBESIDE IT — REBUILDING FROM SOURCE RE-ADDS THE PUBLIC KEY.
[morpheus-iuh154, 2026-09-13, verifying the served artefact. Same shape as the three-copies trap, one level down: not two files with one name, but ONE NAME whose SOURCE and ARTEFACT have drifted.]rmm-selfclean.sdp (4450 B, the PACKED artefact) pounce-rce 0 · dph151-jp 1 ✅ SAFE — this ships selfclean_hook_jp.sh (the SOURCE beside it) pounce-rce 1 · dph151-jp 3 🔴 carries the public key⇒ The safe thing to serve was built from an EARLIER hook than the script sitting next to it. ⇒ 🔴 ANYONE WHO “REFRESHES” THE SDP BY REPACKING THAT
.shADDS A KEY THE CURRENT ARTEFACT DOES NOT HAVE. ⇒ ⭐⭐⭐ REBUILD-FROM-SOURCE IS THE MOVE THAT MAKES IT WORSE HERE — the exact opposite of the usual instinct, and the instinct fires hardest on someone being careful. ✅ Serve the ARTEFACT, verified by hash (890bd9f1…). If you must rebuild, hash the keys in the output and compare against the artefact you are replacing —printf '%s' "$b" | sha256sum, never a bare pipe fromgrep, or a trailing newline will read as a substitution. 📌 Raised to JP directly on 2026-09-13. This banner records it; it does not decide it.
⚠️ THAT IS THE PERSISTENCE STEP, NOT THE ENTRY. The .dbg shows it delivered over SSH to
<unit-151-a>:22, answered by dropbear_0.50 — i.e. the pico’s sshd was ALREADY listening when
this ran. ⛔ Do not read it as the way in. ENV_START_DMI_TELNET TRUE is what opens :8090.
That single step is the whole remaining gap on an unprovisioned unit. Candidates, none yet
demonstrated: rmm_client … do_software_download / switch_fw_boot (the swdl path, where
post_swdl_hook runs as root — ⚠️ it also rm -rfs the config bank, so stage a payload FIRST),
or the pico’s own outbound ACS leg per Route 0.
🎯
TR-069 IS how root is obtained.[dph-femtocell 1352a84]No shell, no JTAG, no serial.
ACS Download RPC -> rmm-selfclean.sdp -> post_swdl_hook runs the hook AS ROOT on the PICO
(private lab notes) (the payload, 6892 B)
(private lab notes) (4450 B, one 0x5007 hook item, 3 CRCs verified)
the hook then:
installs authorized_keys (dph151-jp + pounce-rce) in /var/ipaccess/root_home/.ssh
🔴 pounce-rce = THE PUBLIC-GITHUB KEY. See the red banner above before serving this.
setnv_env.sh ENV_VERBOSE_CONSOLE_ENABLED TRUE -> rcS.d/S10sshd binds 0.0.0.0:22
opmode.sh re-asserts it EVERY boot, AFTER init_nv_env resets nv_env->FALSE
PROVISIONING a DPH-151 -> TR-069. Needs NO shell, NO RCE, NO key.
PERSISTENT SSH on it -> the RCE ladder. A DIFFERENT GOAL.
⭐ “We have been climbing the wrong ladder.” [findings-151b-pico-access-and-acs-init.md §3]
Anyone whose goal is “configure the cell” should never touch the ladder at all.
TLS-OK IS THE TRAP[measured on exchange, 6-hour window, BOTH units]
<unit-151-b> 95 sessions <unit-151-a> 1280 sessions
every one: -> <your-cmhs-ip>:443 [CMHS/other] · TLS-OK clientcert=len=1006 · peer closed (state=init)
The cells reach us, authenticate with a valid client cert, and OUR ACS never advances past
init. ⇒ ⭐⭐ TLS-OK is the loudest success line in the log and it certifies the TRANSPORT
ONLY. The session dies one state later, and a transport receipt reads like a provisioning receipt.
THE MECHANISM: one IP cannot serve both roles. ctx is chosen by getsockname() because these
devices send no TLS SNI, and the dispatch has no branch for <your-cmhs-ip> — it falls to
else: chan="CMHS/other". A CWMP device that lands there is answered with an XMPP stream
header, which it cannot parse, so it closes at state=init.
femtocell.wireless.att.com -> <your-acs-ip> ✅ ACS handler
dpewe-santa-clara.wireless.att.com -> <your-acs-ip> ✅ ACS handler
cmhswe-santa-clara.wireless.att.com -> <your-cmhs-ip> ⛔ NO BRANCH -> else -> CMHS
✅ DNS has since been corrected for femtocell, which is why .244 now lands 385 Informs on
.21. ⚠️ Two findings that looked contradictory — “0 Informs, ever” and “385 Informs, all
ACS” — are the SAME endpoint before and after a DNS change. ⭐ When two careful measurements of
one system disagree, check whether the system changed between them before doubting either.
0959849 removed tools/access/dph151-unlock-and-patch.sh and dph151-finish-bringup.sh as
“unheadered scratch duplicates”. They were not duplicates — the first is the only copy of the
working post-shell bring-up (set hnbCId 7938, rrmUnlock/unlock, localSelectCellParams,
establishPermanentHnbGwConnection) plus the watchdog patch that teaches it the cell-level
action unlock it never had. Restored in 78ee691.
⭐ “Duplicate” is a claim about CONTENT and the commit made it from the FILENAME.
[two lanes, opposite directions, inside ten minutes, 2026-09-14]
export ENV_VERBOSE_CONSOLE_ENABLED=$DEFAULT_UNHARDENED
apcfg-x (563) 0 occurrences -- ABSENT (control: 6 DEFAULT_UNHARDENED hits, reader works)
rootfs154 (579/282F) rcS:113-114, BOTH vars -- present, UNCONDITIONAL
unhardened FS_LETTER set: 563 -> A C E G I W X Z (EIGHT)
579 -> A C E G I W X Y Z (NINE -- Y added between trains)
⇒ ⭐⭐⭐ THE TWO TRAINS NEED OPPOSITE LEVERS. On 579 the unconditional export makes
FS_VARIANT the master and clobbers a direct write. On 563 that block does not exist, so the
if [ "$ENV_..." = "" ] guard is the whole story and THE DIRECT WRITE IS THE LEVER:
/opt/ipaccess/bin/setnv_env.sh ENV_VERBOSE_CONSOLE_ENABLED TRUE
/opt/ipaccess/bin/setnv_env.sh ENV_FIREWALL_DISABLED TRUE
✅ OUR UNITS ARE 563 — 205F_fscheck_563.21.8_C_812_21.md5, iapc.563.21.8.
⚠️ Advice derived from the 579 tree was the EXACT INVERSE of correct for our device.
FS_VARIANT IS NOT A SETTABLE LEVER — THE CORPUS ALREADY TESTED AND DROPPED ITfindings-dph154-v9-preflight.md, verbatim: “FS_VARIANT dev-trick does NOT transfer —
/etc/profile reads it from /etc/sw_description.dat on the RO rootfs … it is why v8’s
FS_VARIANT="224A" line is deliberately NOT in v9.”
⇒ The export FS_VARIANT="224A" found on .244 is OUR OWN v8 payload’s FAILED override attempt
— not a vendor value and not evidence the lever works. ⭐ An artefact of our own earlier failure,
read back as a discovery.
ls, NOT A VARIABLE HUNT — confirmed in two trains, two families.244 (563/151) /etc/205F_fscheck_563.21.8_C_812_21.md5
154 (579/282) /etc/282F_fscheck_579.11.127_C_900_143.md5
/etc/<VARIANT>_fscheck_<fw>_*.md5 gives the variant AND the train from one listing.
📌 Better still, read /etc/sw_description.dat — the file the system itself uses.
.244 IS NOT A CONTROL FOR A PRISTINE UNIT — WE UNHARDENED IT OURSELVESENV_VERBOSE_CONSOLE_ENABLED="TRUE" · ENV_FIREWALL_DISABLED="TRUE" · an opmode.sh ROBUST_SSH
stanza that re-asserts them and starts dropbear on :22. ⇒ Every “it works on .244”
observation is taken on a device we modified. ⭐ The unit you succeeded on stops being a
control the moment you succeed on it.
/var/ipaccess/nv_env.sh is on ENV_DIAG_FILE_LIST. ⇒ A diagupload bundle hands you the
LIVE file. ⭐ On 563 the entire gate is if [ "$ENV_..." = "" ], so that file IS the decision:
PRESENT-AND-FALSE and ABSENT are different answers and they select different levers.
⇒ Read the target device before choosing a lever from a different one.
[.106, 2026-09-14 ~00:2xZ. Root on the Ralink throughout; every probe below run FROM it.]
:22 / :80 pico DROPS at its OWN firewall conntrack: SYN_SENT … [UNREPLIED]
⭐ NOT an RST. The "Connection refused" katana sees is synthesised elsewhere --
so it is NOT evidence that sshd is down. It is evidence of a DROP.
:8090 NO DNAT on the Ralink ⚠️ TRUE, AND NOT THE BINDING CONSTRAINT — see below
:3001 accepts TCP, application NEVER replies (get_uptime AND get_software_status)
controls: .185 -> "Uptime is: 4371 seconds" .190 -> "failed ipc connect"
⇒ three distinct outcomes, so the instrument discriminates. MUTE, not down.
udp/69 tftp pico's tftpd NOT running -- it exists only during an upgrade flow
rmm_client 192.168.157.185 set_telnetd 1 -> "The response: 80" NO PORT OPENED, either chip
🔴 CORRECTED 2026-09-14 —
:8090IS SHUT BECAUSE **NOTHING IS LISTENING, NOT BECAUSE OF THE DNAT.**
[nebula-librarian3. I corrected this exact framing in BRINGUP.md two hours earlier and left THIS mention standing — this corpus's own law, fired on the person enforcing it.]etc/init.d/opnormal:258-259 (563 tree — OUR train) if [ ${ENV_START_DMI_TELNET:-"FALSE"} == "TRUE" ]; then ⬅ DEFAULTS TO FALSE WHEN UNSET ENV_START_DMI_TELNET in every nv_env.sh held here: 0 · 0 · 0 MEASURED: :8090 probed WITH A PORT-FORWARD IN PLACE -> still closed [team-lead 2026-09-14] :8090 also not listening on .244 [lucid-console154]⇒ ⛔
ipa-dmi -u 8090NEVER STARTS, so there is nothing on the far side of any DNAT. ⇒ ☠️ “No DNAT” sends you to the FIREWALL when the fault is in the BOOT SCRIPT. ⭐ A true statement one level above the real constraint is worse than a false one: it survives checking. ⇒ ⚠️ And “unreachable whatever the pico does” is the wrong shape — it is unreachable BECAUSE OF what the pico does, or rather does not: start the listener. ✅ It is 151-WIDE, not a.106fault — shut on both units. 📌 Full table:BRINGUP.md§3.⭐ AND THE PICO IS DEMONSTRABLY ALIVE, WHICH KILLS THE “UN-STARTED UNIT” READING
udp src=192.168.157.186 dst=192.168.157.185 dport=53 <- IT IS ACTIVELY RESOLVING NAMES ICMP replies · TCP accepted on 3001 · ARP present⇒ It is running and trying to reach things. It refuses every channel we can reach, which is a DIFFERENT FAULT from a stalled boot and sends you somewhere else entirely.
Ralink root -> pico root should read thisfirst: that step does not exist, and four of us spent an evening looking for it.**
/etc/dhcpd0.cfg: start 192.168.157.186 · end 192.168.157.186 (a ONE-ADDRESS pool),
opt router 192.168.157.185 · opt dns 192.168.157.185, /30.
⇒ ⭐⭐ The Ralink IS the pico’s gateway AND its resolver, on a link with exactly one client.
A redirect applied HERE reaches only this unit — no shared DNS change, no risk to any other cell.
📌 And the pico’s traffic leaves MASQUERADED as the unit’s LAN address ⇒ a DNS measurement
taken upstream is attributed to the LAN IP, not to the picoChip that made it. ⭐ A NAT’d
measurement names the translated host, and nothing in the data says otherwise — one lane’s
correct DNS capture sat misattributed for days because of exactly this.
[2026-09-13, ~6 hours lost to reading the first one. 151#2 = <unit-151-b>.]
209 sessions · TLS-OK, valid client cert · ZERO application bytes · ~12 ms close · 0 Informs
Read as a fault all afternoon. It is ORDINARY IDLE BEHAVIOUR. A CWMP client re-reads DNS and
re-dials AT BOOT (1 BOOT Inform); between boots it polls and has nothing to say.
⇒ ✅ TO GET A SESSION FROM A QUIET DPH, POWER-CYCLE IT. There is no cleverer RPC: every
management write is an RPC, every RPC rides a session, and the CPE opens the session.
“151#2 has no management-server pointer” drove a redirector theory, a cert theory, and a claim that the two units genuinely differ. Read from 151#1’s own root shell:
tr069_cur_cfg.xml.gz AcsUrl = https://femtocell.wireless.att.com:443/acs
EnableCWMP = true · PeriodicInformEnabled = true · Interval = 900
md5(cur_cfg) == md5(def_cfg) <- 151#1 IS RUNNING PURE FACTORY DEFAULTS
⇒ ⭐⭐ THE FACTORY DEFAULT ALREADY CONTAINS THE ACS URL. 151#1 carries no more pointer than
151#2 does. Same model, same train (563.21.8), same config, same destination (.21:443), same
client-cert CA (Femtocell CPE Sub CA), same certs served.
⇒ ⭐⭐⭐ THE CHECK THAT SETTLED A BROKEN UNIT WAS ON THE WORKING ONE — and nobody looked there
for hours, because the working unit was not the problem.
server cert / chain / CA both bundles carry IDENTICAL anchors, certs 2-6. Both leaves
self-signed, so chain membership cannot separate them.
client-cert vintage all three units issue from CN=Femtocell CPE Sub CA
our dispatch / wrong handler .106 lands on ACS/femtocell correctly, 209/209
our ACS dropping it read_http() returns None ONLY on recv()==empty. Malformed input
would BLOCK, not return. So "peer closed" is a REAL close.
server-speaks-first deadlock greeted it with an unprompted HTTP 204 (scoped to .106): it closed
9 ms later, still silent. IT IS NOT WAITING FOR US.
diagupload as a "read" it is TWO SetParameterValues RPCs -- inside the session we lack.
⚠️ And its landing endpoint :8082 was DOWN: a trigger fired at a dead
endpoint yields the SAME ABSENCE as a device that never answered.
.selfclean_sent was written at queue-build AND at the send site. The build-time write
always won, so the arm burned before a byte went out. The log printed FIRED and
session continues while the measured send count was ZERO. A 2026-09-05 fix meant to MOVE that
write had COPIED it.
⇒ ⭐ A log that says nothing invites investigation. A log that says the wrong thing ends it.
✅ Fixed 2026-09-13: one write site remains, at the Download branch. The marker now records a SEND.
⚠️ ORDER WAS LOAD-BEARING: a power cycle BEFORE this fix would have produced a BOOT Inform,
burned the arm, sent nothing, and read as “the method does not work on this unit” — burying the
real defect deeper.
[exchange dph153-acs journal, all boots, 2026-09-13]
CENSUS of all 385 CWMP Informs, by OUI:
100 2026-09-05 OUI 445829 = 151#1 <- A DPH-151. IT INFORMED 100 TIMES.
285 2026-09-11 OUI 481D70 = the DPH-154
EventCodes actually observed:
38 ['X 00000C SW_UPGRADE','M Download','7 TRANSFER COMPLETE','1 BOOT'] <- THE EXPLOIT WORKING
18 ['X 00000C NON_CLEAN_SHUTDOWN_COLD','1 BOOT'] <- A POWER CYCLE
8 ['1 BOOT'] 2 ['X 00000C SW_FAULT_APP_FAILURE','1 BOOT']
306 ['2 PERIODIC']
⇒ ✅ 1 BOOT Informs are real (71 of them) and NON_CLEAN_SHUTDOWN_COLD + 1 BOOT is the
power-cycle signature. ⇒ ✅ AND THE DEVICE KEEPS ITS OWN RECEIPT: 151#1’s
/var/ipaccess/selfclean_hook.log is dated Sep 5 23:41, === selfclean v6 uptime=13551s ===,
with nv_env.sh.pre-dph151 and .pre-dph151-jp beside it — the same day as its 100 Informs.
⇒ ⭐⭐⭐ TR-069 -> Download -> post_swdl_hook AS ROOT IS PROVEN ON THIS MODEL, END TO END.
I read the LAST 3 Informs, saw the 154’s OUI, and reported “neither DPH-151 has ever
Informed — the path has never worked on this model.” The 100 I needed were at the OTHER END
of a chronologically-sorted file.
⇒ ⭐⭐ Sorting by time makes a tail sample unrepresentative of WHO, not merely of HOW MANY.
A random sample would have found the 151 immediately; the time-ordered one could not, by
construction. ⛔ The conclusion drawn was not “we have less data than we thought” but “the
method does not work” — a sampling artefact promoted to a property of the system.
📌 This is the corpus’s own print-the-total rule, broken by the person quoting it to three
other lanes in the same hour. ✅ uniq -c over the WHOLE field is one command and cannot lie
the same way.
1. ARM .selfclean_arm.<peer> present, .selfclean_sent.<peer> ABSENT
⚠️ verify the marker is written ONLY at the send site -- a build-time write burns the
arm before anything is sent, and the log prints FIRED while sending zero bytes
2. VERIFY the payload BY HASH, never by path. v10 = c77d57af24d2a32a3f786ad646cf3498
⛔ v9 strips ~21 of 24 ENV_ vars -- DO NOT SERVE. ⛔ v8 + selfclean_hook_jp.sh carry
a key whose PRIVATE half is on public GitHub. v10 is clean.
3. SERVE the download host must be UP (:8081) before the cycle, not after
4. POWER-CYCLE THE UNIT -> NON_CLEAN_SHUTDOWN_COLD + 1 BOOT Inform -> Download fires
5. TWO REBOOTS -> $( ) runs in a subshell, so sshd starts with the OLD value on the
first. ⛔ CHECKING SSH AFTER ONE REBOOT RECORDS A WORKING PAYLOAD
AS A FAILURE.
[caught 2026-09-13 ~17:5x PDT, with a power cycle already green-lit. Nothing fired.]
size header verdict
rmm-selfclean.sdp 4450 B " SDP\0..." 1 IMAG item ✅ the build that ROOTED 151#1
rmm-selfclean-v7.sdp 6252 B " SDP\0..." ✅ valid container
rmm-selfclean-v8.sdp 7208 B " SDP\0..." ✅ valid container
rmm-selfclean-v10.sdp 10240 B "../../../var/\0\0\0" 🔴 NOT AN SDP. A ustar TAR.
entries: ../../../var/ipaccess/opmode.sh (6424 B)
its own comment: "Landed by tar path traversal"
acs_tr069.py:11 SDP_URL = ".../rmm-selfclean-v10.sdp" <- WHAT THE ACS SHIPS
⇒ The armed payload was the TAR TRAVERSAL, not the proven SDP hook. It overwrites
/var/ipaccess/opmode.sh — sourced at boot, on a unit with no shell. Extraction happens
during transfer, so a partial write commits every completed entry, and no backup exists
because the traversal overwrites before any payload of ours runs. ⇒ Silent, fail-closed,
unrecoverable lockout.
This corpus says select a payload BY HASH, never by path. I did. The hash MATCHED
(c77d57af24d2a32a3f786ad646cf3498) — because it was the correct hash OF THE WRONG KIND OF
THING. It certified the file was un-tampered and free of the public-GitHub key — both true,
and neither is “this is an SDP”.
⇒ ⭐⭐⭐ INTEGRITY AND IDENTITY ARE DIFFERENT PROPERTIES. A HASH ONLY EVER SPEAKS TO THE FIRST.
✅ THE CHECK THAT WORKS IS FOUR BYTES: head -c 4 <file> must read " SDP". A container
check cannot be satisfied by the wrong container.
acs_tr069.py:6 reads # v7: adds FS_VARIANT="224A" — DIRECTLY ABOVE THE v10 PATH.
The file changed underneath its own annotation. ⭐ A comment names the version it was
written for, never the version in the line beneath it.SDP_URL was already wired to the traversal. ⇒ The analysis was correct and never reached
the thing it was about, because nobody re-read the config line the decision was supposed to
govern. ✅ After deciding between two paths, READ THE LINE THAT SELECTS ONE..sdp AND THE .sh BESIDE IT ARE DIFFERENT OBJECTS — AND REBUILD FROM SOURCE MAKES IT WORSE[measured 2026-09-13, two lanes independently]
rmm-selfclean.sdp (4450) selfclean_hook_jp.sh
pounce-rce 0 1
the public-repo key material 0 1
dph151-jp 1 3
the packed script installs: dph151-jp ONLY
⇒ The SDP was packed from an EARLIER hook than the .sh sitting next to it. The script on disk
has drifted AHEAD of the artefact, so selfclean_hook_jp.sh looks like the source of
rmm-selfclean.sdp while being a later, less safe version.
⇒ ☠️ ANYONE WHO “REFRESHES” THE SDP BY REPACKING THAT .sh ADDS A KEY WHOSE PRIVATE HALF IS ON
PUBLIC GITHUB — a key the current artefact does not contain. ⭐ The safe artefact and the unsafe
source share a name-stem, and rebuild from source is the move that makes it worse — the
opposite of the usual instinct.
✅ Check the ARTEFACT’s contents, never the file named like it. ⛔ Two lanes independently
concluded “the proven SDP carries pounce-rce” by reading the .sh; both were wrong.
~/.ssh/dph151_id_rsa derives AAAAB3NzaC1yc2EAAAADAQABAAABAQDj/k0rNLqx9iNH6qqOQgUXstKErZ172nCI
rmm-selfclean.sdp installs AAAAB3NzaC1yc2EAAAADAQABAAABAQDj/k0rNLqx9iNH6qqOQgUXstKErZ172nCI
^ EXACT MATCH
⇒ The key installed and the key you authenticate with are the same one — which closes the v7
rc=255 failure mode by construction, not by luck. That failure was never a lost race; it was
a key mismatch, and it cost six retries because a mismatch and a missed window look identical.
⚠️ AND USE ssh-keygen -lf AS A KEY’S IDENTITY, NEVER A HAND-ROLLED HASH OF A GREP’d BLOB — a
lane quoted a fingerprint computed through a pipe (trailing newline) and got a different number for
the same key. ⭐ A hash of a string is a hash of how you piped it. And a fingerprint
mismatch on a key reads as “wrong key, do not trust” — the exact misread that cost those retries.