[{"content":"TL;DR While porting the Ruckus H510 to OpenWrt, I wrote 4 bytes to what I thought was the U-Boot environment partition (mtd8). It was actually 0:CDT — the Qualcomm Configuration Data Table. The device hard-bricked before U-Boot even loaded. Recovery required an external SPI programmer and a SOIC-8 clip.\nThe setup The H510 (like most embedded Qualcomm boards) has a 4 MiB SPI NOR that holds the boot chain: SBL1, MIBIB, QSEE, CDT, DDRPARAMS, U-Boot, and the ART partition. Each is exposed as a /dev/mtdN block device from the running kernel — and on the stock Ruckus firmware, every one of them is writable.\nI was diagnosing why fw_setenv-style writes to mtd10 (0:APPSBLENV) weren\u0026rsquo;t persisting on unit B. As a test, I ran a \u0026ldquo;surgical\u0026rdquo; 4-byte dd write to mtd8 to see if other SPI NOR partitions were writable.\nmtd8 happened to be 0:CDT.\nThe mistake # The command that bricked the device dd if=/tmp/4bytes of=/dev/mtd8 bs=1 count=4 A bare dd to a NOR mtd device performs the write, but the underlying flash AND-merges the new bytes into the existing bits. Flash can flip 1-bits to 0-bits, but not vice versa. Even a \u0026ldquo;harmless\u0026rdquo; 4-byte write therefore mangles the partition\u0026rsquo;s first 32 bits.\nFor 0:CDT, which SBL1 validates by magic number + length, those first 32 bits are enough.\nThe crash On the next power cycle, the board didn\u0026rsquo;t reach U-Boot. The serial console showed:\nB - boot_config_data_table_init, Start B - Error: CDT is not programmed B - Boot error occurred!. Error code: 302a SBL1 (Stage 1 Boot Loader) runs from mask ROM. It validates CDT before loading anything else. Invalid CDT = no U-Boot, no recovery shell, no second chances.\nWhy this is more dangerous than it looks No kernel-side write protection. The Ruckus kernel exposes every SPI NOR partition (SBL1, MIBIB, QSEE, CDT, DDRPARAMS, APPSBL) as a writable /dev/mtdN. The JEDEC-standard block-protection bits on the MX25L3233F could be set to write-protect these partitions, but Ruckus ships them unset.\nMTD_POWERUP_LOCK doesn\u0026rsquo;t help. The stock SPI NOR driver reports MTD_POWERUP_LOCK in the flags, but it doesn\u0026rsquo;t implement the lock/unlock ioctl. flash_unlock /dev/mtd10 returns ENOTSUP (error 95). Despite the flag suggesting otherwise, flash_erase and dd write successfully without any unlock step.\nMTD numbering is unstable. mtd8 was 0:CDT on the stock kernel. On a different kernel build, or on OpenWrt, the numbering shifts:\nPartition Stock Ruckus mtd OpenWrt mtd 0:APPSBLENV mtd10 mtd8 0:CDT mtd8 mtd6 Board Data mtd3 mtd11 The one stable anchor is 0:APPSBLENV = mtd10 on both stock and OpenWrt (by partition name, not number).\nRecovery With SBL1 refusing to boot, the only path is external: CH341A programmer + SOIC-8 clip on the SPI NOR chip.\n0:CDT contains DK04.1-C1 reference-design configuration — no per-unit data — so cross-unit copy is safe. Restore the 64 KiB at offset 0xc0000 from a backup of another H510 (or the SPI NOR backup you definitely made before writing to any flash partition).\n0:APPSBLENV can be left blank; U-Boot falls back to compiled-in defaults.\nFlash lock investigation After the recovery, I investigated whether flash_unlock is needed before writing to SPI NOR or NAND from the stock firmware.\nNAND (mtd0, mtd1, mtd2): flags = 0x400 (MTD_WRITEABLE). No powerup lock. ubidetach + ubiformat work without any unlock step.\nSPI NOR (mtd8–mtd12): flags = 0xc00 (MTD_WRITEABLE | MTD_POWERUP_LOCK). flash_unlock returns ENOTSUP — the stock kernel\u0026rsquo;s SPI NOR driver doesn\u0026rsquo;t implement the lock/unlock ioctl. Despite the flag, flash_erase and dd write successfully without any unlock step.\nConclusion: flash_unlock is neither required nor functional in the no-UART install path. The install sequence is simply flash_erase → dd.\nRules going forward Never dd/cat/echo to any /dev/mtdN whose partition has a 0: prefix, except 0:APPSBLENV. Use mtd_debug erase\nmtd_debug write if you must, never the raw block device. Verify mtd numbers against /proc/mtd before every write. The numbering can shift between kernel builds.\nCapture a full SPI NOR backup before any NOR write. dd if=/dev/mtd0ro of=/tmp/full-nor.bin bs=4096 from the OEM kernel gives you a 4 MiB recovery image you can flash back externally if things go wrong.\nWhat this teaches \u0026ldquo;Read-only\u0026rdquo; and \u0026ldquo;write-protected\u0026rdquo; are different things. The Ruckus kernel exposes boot-critical partitions as writable block devices with no enforcement. A typo on the mtd number and the device is hard-bricked before any bootloader can help you.\nThe CDT brick is a reminder that \u0026ldquo;no UART\u0026rdquo; doesn\u0026rsquo;t mean \u0026ldquo;no risk\u0026rdquo; — the OEM kernel gives you enough flash access to brick the device beyond its own bootloader\u0026rsquo;s reach.\n","permalink":"https://blog.leavitt.dev/posts/spi-nor-detour/","summary":"How a single 4-byte dd write to the wrong MTD partition on an in-wall Ruckus H510 turned a software-recoverable experiment into a CH341A + SOIC-8 clip recovery job.","title":"Cautionary tale: bricking an AP with a 4-byte SPI NOR write"},{"content":"Why this thing The Ruckus H510 is an in-wall 802.11ac AP that shipped into MDU and hospitality installs. It\u0026rsquo;s built around Qualcomm\u0026rsquo;s IPQ40xx SoC, has 512 MiB of RAM, 128 MiB of NAND, and a 4 MiB SPI NOR for the boot chain. It\u0026rsquo;s also end-of-life: Commscope isn\u0026rsquo;t shipping firmware updates anymore, and the stock Unleashed firmware is stuck on an old-ish kernel with a proprietary userspace (v54_bsp) that most operators have no reason to trust or maintain.\nNo Ruckus device exists upstream in target/linux/ipq40xx/ as of 2026-04-24, so this was a from-scratch port. Here\u0026rsquo;s how it went.\nPhase 1: Serial recon First step: get a console on the thing. The H510 has a populated 4-pin header on the board (TX=pin 1, GND=pin 4, RX=pin 5). FT232 at 115200 8N1. The SoC is an IPQ40xx family (Dakota) — the boot log confirms AP-DK04.1-C1 reference design, quad-core Cortex-A7 [410fc075], 512 MiB RAM.\nThe U-Boot requires Ctrl-C (not any key) to interrupt the 4-second autoboot countdown. Timing a single keypress is a coin flip. The technique that worked: spam Ctrl-C bytes (0x03) in a tight loop for the whole PBL → SBL1 → U-Boot window (~15–25 seconds):\nfor i in $(seq 1 500); do printf \u0026#39;\\x03\u0026#39; \u0026gt; /dev/ttyUSB0 2\u0026gt;/dev/null sleep 0.05 done With a background cat /dev/ttyUSB0 \u0026gt; capture.log \u0026amp; reading serial, this is fully automatable. The excess Ctrl-Cs just print \u0026lt;INTERRUPT\u0026gt; harmlessly after the prompt appears.\nA single boot log gives you almost everything: SoC family, RAM size, flash layout, PHY IDs, reserved memory regions, kernel version, and the board identifier strings. For locked-bootloader devices this is the cheapest, safest source of truth.\nPhase 2: U-Boot recon (reads only) Never nand write, never sf erase, never saveenv until you\u0026rsquo;ve dumped everything read-only. Key finds from the U-Boot session:\nThe stock DTB is the single most valuable artifact. On any OpenWrt-kernel-based OEM, the DTB is embedded in a FIT image inside a UBI volume. Ruckus wraps the FIT with a 160-byte rcks_wlan header, but U-Boot can crack it:\nsetenv mtdids nand0=nand0 setenv mtdparts mtdparts=nand0:48m(main),48m(bkup),32m(data) ubi part bkup ubi read 0x84100000 kernel imxtract 0x841000a0 fdt@1 0x84500000 fdt addr 0x84500000 fdt print /soc This gave us 8 LED GPIOs, the reset button, the TPM/I2C reset lines, and the switch port ↔ MDIO mapping in a single session.\nFull SPI NOR backup via TFTP put — nuclear recovery fallback:\nsf read 0x86000000 0 0x400000 tftpput 0x86000000 0x400000 h510-spinor-backup.bin This covers SBL1, MIBIB, QSEE, CDT, DDRPARAMS, APPSBL (U-Boot), ART, Board Data — everything short of NAND contents.\nPhase 3: DTS drafting Every non-trivial claim in the DTS carries an inline comment citing the capture file and line where it was observed:\nmemory { device_type = \u0026#34;memory\u0026#34;; /* 512 MiB confirmed: attempt-2-uboot.log \u0026#34;DRAM: 512 MiB\u0026#34; */ reg = \u0026lt;0x80000000 0x20000000\u0026gt;; }; The nearest upstream reference for the IPQ4019 Dakota: Aruba Instant On AP-303H (qcom-ipq4029-ap-303h.dts). It\u0026rsquo;s the closest architectural match — enterprise AP, dual integrated radios, TPM, NAND + SPI NOR. Meraki MR33 as a cross-check for locked-bootloader workarounds on this SoC.\nDead ends \u0026ldquo;The R500 is the template\u0026rdquo; — wrong. R500 is ath79 / QCA9557 (MIPS, 64 MiB SPI NOR, 256 MiB RAM). Completely different architecture. The mistake was caught in the first 30 seconds of the boot log — Format: Log Type - Time(microsec)... is a Qualcomm SBL1 signature, not Atheros.\n\u0026ldquo;The R510 is upstream in ipq40xx\u0026rdquo; — also wrong. Verified via git ls-files in the OpenWrt tree: zero *ruckus* DTS files anywhere under target/linux/ipq40xx/dts/ as of 2026-04-24.\nLesson: grep the tree before claiming a device is upstream. Even for devices that \u0026ldquo;feel like they should be\u0026rdquo; supported.\nPhase 4: Build + TFTP-boot loop All build tooling runs inside a Debian bookworm Docker container matched to the host UID/GID. The WSL2 host stays clean, the build is reproducible, and subsequent DTS-only rebuilds after the first toolchain bootstrap are 2–5 minutes.\nThe initramfs FIT image (h510-initramfs-fit-uImage.itb) boots via:\ntftpboot 0x84000000 h510-initramfs.itb bootm 0x84000000 First boot revealed:\nGPIO polarity was wrong. The stock Ruckus DTB declares all 8 LED GPIOs as GPIO_ACTIVE_LOW. The H510\u0026rsquo;s actual wiring is active-high — physical HIGH lights the LED. Ruckus\u0026rsquo;s proprietary v54_bsp driver silently inverts; the upstream gpio-leds driver doesn\u0026rsquo;t. Symptom: every LED was lit because \u0026ldquo;off\u0026rdquo; (deasserted active-low = physical HIGH) was actually the on state. Diagnosed by toggling and observing, then flipping every flag to GPIO_ACTIVE_HIGH.\nMAC offset off-by-one. First DTS draft put the WAN MAC nvmem cell at offset 0x807f. Correct offset is 0x807e. The failure mode was safe — reading 6 bytes from 0x807f gave a multicast bit-set address, which the ethernet driver correctly rejected and fell back to random MAC. Caught because the interfaces came up with 82:54:76:... instead of the sticker 30:87:d9:1c:cd:40.\nDEVICE_DTS default derivation surprise. The ipq40xx image Makefile derives DEVICE_DTS from the device name via the last underscore-part. For ruckus_h510 that produced h510, but our file was named qcom-ipq4019-ruckus-h510.dts. Fix: explicitly set DEVICE_DTS := qcom-ipq4019-ruckus-h510 in the device stanza.\nPhase 5: NAND install + persistence With the initramfs boot confirmed stable, the next step was flashing OpenWrt into NAND and making it autoboot from cold power-up.\nThe H510 uses an A/B boot scheme: rcks_wlan.main (slot A, 0x0–0x3000000) and rcks_wlan.bkup (slot B, 0x3000000–0x6000000) with a UBIFS datafs in the remaining 32 MiB. We flash OpenWrt into slot B and pin bootcmd to always boot it.\nFrom U-Boot:\ntftpboot 0x84000000 h510-factory.ubi nand erase 0x3000000 0x3000000 nand write 0x84000000 0x3000000 \u0026lt;size\u0026gt; The bootcmd environment needs two paths: one for slot A (stock Ruckus, using bootlcl rcks_wlan.main — the Ruckus U-Boot\u0026rsquo;s purpose-built command that handles TZ handoff) and one for slot B (OpenWrt, using bootm). The naive bootm 0x840000a0 for slot A crashes with external abort on non-linefetch because it skips the TZ handoff that the stock kernel requires.\nFrom the running OpenWrt system, fw_setenv sets the U-Boot environment to always boot slot B. The AP now autoboots OpenWrt on power-up. Slot A is untouched as a one-command fallback.\nA notable gotcha: the 0:CDT (Configuration Data Table) partition is exposed as a writable /dev/mtdN from the OEM kernel with no write protections. A typo on the mtd number and the device is hard-bricked before U-Boot even runs — SBL1 validates CDT by magic\nlength, and a corrupted CDT means no bootloader handoff. Recovered on one unit via CH341A + SOIC-8 clip with the SPI NOR backup. Rule going forward: never dd/cat/echo to any /dev/mtdN whose partition has a 0: prefix except 0:APPSBLENV. Use mtd_debug erase + mtd_debug write if you must, never the raw block device.\nPhase 6: WiFi Both ath10k radios (2.4 GHz and 5 GHz, integrated into the IPQ4019) come up, but the stock Board Data in the SPI NOR 0:ART partition returns zeros via BMI (bmi-board-id). Ruckus\u0026rsquo;s stock v54_bsp driver writes calibration via a side channel that the upstream ath10k driver doesn\u0026rsquo;t replicate.\nThe workaround: custom board file generated via ath10k-bdencoder, using the upstream bmi-board-id=16/17 reference-design entries from the IPQ40xx DK04.1 reference board as payload. Each radio gets a per-radio variant string (Ruckus-H510-2g / Ruckus-H510-5g) to distinguish them in the board file lookup.\nThis is now packaged properly as ipq-wifi-ruckus_h510: package/firmware/ipq-wifi/files/board-ruckus_h510.QCA4019 holds the board file, and the Makefile calls generate-ipq-wifi-package for it. No more files/ overlay hack — it\u0026rsquo;s a proper OpenWrt package ready for upstreaming.\nWhat works ✅ Boot from NAND (slot B), autoboot on power-up ✅ Ethernet: 4-port switch (IPQ40xx ESS + QCA8075 PHY), WAN port ✅ Both ath10k radios (2.4 + 5 GHz) with custom board-2.bin ✅ LEDs (power red/green, wifi, director) with correct polarity ✅ Reset button ✅ TPM (Infineon SLB9645 I2C, tpm_tis driver) ✅ Slot A fallback: stock Ruckus firmware untouched in rcks_wlan.main ✅ MAC addresses from proprietary Board Data SPI partition via nvmem What\u0026rsquo;s next The immediate work is upstreaming: DTS cleanup and the 02_network / uboot-envtools additions. The ipq-wifi-ruckus_h510 package is already in place. The H510 is EOL but the same librkscli.so pattern and IPQ40xx reference design show up across the Ruckus Unleashed 200.x line — the R510, R610, and likely others are candidates for the same porting treatment.\nThe DTS, image recipe, and the ipq-wifi-ruckus_h510 board-file package live on the ruckus-h510 branch of my OpenWrt fork (target/linux/ipq40xx/dts/qcom-ipq4019-ruckus-h510.dts and the matching image stanza).\nLessons learned Ctrl-C flood is portable. The Ruckus U-Boot needs Ctrl-C specifically, not any-key. The spam technique should work on any Ruckus AP with a similar boot chain.\nTemplate from architecture, not model number. Ruckus re-uses the \u0026ldquo;R/M/H/T + 3-digit\u0026rdquo; naming across totally different SoC families. The R500 is MIPS, the H510 is ARM — model-number similarity means nothing.\nVendor DTBs lie about polarity. They\u0026rsquo;re authoritative for pin numbers but not necessarily for polarity flags or labels. Always toggle and observe on real hardware.\nbootlcl vs bootm matters on OEM U-Boots with TZ handoff. The naive bootm approach skips the TZ setup that the stock kernel expects. Use the OEM\u0026rsquo;s purpose-built boot command for slot A.\nMTD_POWERUP_LOCK doesn\u0026rsquo;t mean locked. The stock SPI NOR driver doesn\u0026rsquo;t implement the lock/unlock ioctl despite reporting the flag. flash_erase and dd work directly — convenient for install, dangerous for accidental writes.\n","permalink":"https://blog.leavitt.dev/posts/h510-openwrt-port/","summary":"From serial console to persistent OpenWrt install on an EOL enterprise in-wall AP. The story of a from-scratch port of the Ruckus H510 to OpenWrt.","title":"Porting the Ruckus H510 to OpenWrt"},{"content":"TL;DR CVE-2026-42189 is a pre-authentication DoS in russh, an actively maintained Rust SSH library. One malformed USERAUTH_INFO_RESPONSE packet (~50 bytes after encryption) triggers a multi-gigabyte allocation and OOM-kills any russh-based server running keyboard-interactive auth. No credentials needed.\nCVSS 7.5. Affects russh ≤ 0.58.0 and Warpgate ≤ 0.23.0. Fixed in russh 0.60.1 and Warpgate 0.23.1.\nI found it with cargo-fuzz. The harness design is what made it findable: the relevant attack surface lives behind key exchange, in a session state that has to be set up before any fuzzed bytes can reach it. Pointing libfuzzer at the parsers I could see from outside the library wasn\u0026rsquo;t going to find this. Pre-staging the session state inside the harness did, in about 20 minutes of run time.\nWhy russh I\u0026rsquo;m working on ferrosync, a Rust implementation of rsync. The project is currently paused for a redesign, but the original goal is one I still care about: rsync that runs natively on Windows, without WSL or Cygwin in the way. SSH transport is a hard dependency for that, and russh was the option I went with.\nWorking on ferrosync, I hit a real bug. Windows\u0026rsquo; VirtualLock was failing with ERROR_WORKING_SET_QUOTA on default systems, because the process minimum working set leaves no headroom for locked cryptographic pages. That turned into PR 661: Windows mlock hardening with working-set growth on demand, partial-rollback for failed locks, and a handful of overflow guards. Eugene merged it the day after I opened it.\nSo by the time I started looking at russh as a security target I\u0026rsquo;d already been reading the codebase carefully for weeks. I knew where the cipher and key exchange code lived. I knew the shape of the auth state machine.\nThreat-modeling, not parser-spraying Most \u0026ldquo;I added cargo-fuzz to project X\u0026rdquo; writeups follow the same recipe: find every public parser entry point, wrap each one in a fuzz target, press go. That produces a respectable harness, and it\u0026rsquo;s where I\u0026rsquo;d started in the past.\nFor russh, that approach hits a wall. The interesting bugs aren\u0026rsquo;t in the parsers reachable from outside the library. They\u0026rsquo;re behind the encrypted channel.\nHere\u0026rsquo;s what an attacker actually does. They open a TCP connection. They complete the SSH key exchange using anonymous Diffie-Hellman, which costs them nothing because kex doesn\u0026rsquo;t authenticate the client. They\u0026rsquo;re now past the encryption layer and can send arbitrary post-kex packets to the server, and they haven\u0026rsquo;t presented credentials yet. The server\u0026rsquo;s auth state machine is the next thing they touch.\nThat\u0026rsquo;s the surface that matters, and it\u0026rsquo;s the surface fuzzing won\u0026rsquo;t reach without setup. libfuzzer\u0026rsquo;s mutator can\u0026rsquo;t manufacture a valid kex handshake on its own, so pointing it at the kex parser only finds bugs in the kex parser. Bugs that require being past kex stay invisible.\nSo I worked backwards from the goal: I wanted libfuzzer\u0026rsquo;s bytes to land in Session::process_packet, with the session already in the post-kex, pre-auth state, the cipher already set up, and the auth handler ready to receive input. A fuzzer at that point can explore most of the high-value attack surface in seconds.\nThe harness The full branch is at coreyleavitt/russh tree/add-cargo-fuzz. It includes nine fuzz targets covering the obvious parser surfaces: kexinit on both sides, channel open messages, key parsing, certificate decoding, known_hosts pattern matching, ssh-id lines, agent responses, and russh-config parsing. Worth having. None of them are where this CVE came from.\nThe target that found CVE-2026-42189 is fuzz_server_packet. It\u0026rsquo;s two lines:\nfuzz_target!(|data: \u0026amp;[u8]| { let _ = russh::fuzz_helpers::server_process_packet(data); }); The work is in fuzz_helpers::server_process_packet, gated behind #[cfg(fuzzing)] so it doesn\u0026rsquo;t pollute the public API. It does one unobvious thing: it constructs a Session already in the post-kex, pre-auth state, with keyboard-interactive auth active and the cipher set to cipher::clear::Key. The bytes libfuzzer hands me go straight into the parser. There\u0026rsquo;s no encryption layer to negotiate.\nfn make_session() -\u0026gt; Session { let encrypted = Encrypted { state: EncryptedState::WaitingAuthRequest(auth::AuthRequest { methods: MethodSet::all(), partial_success: false, current: Some(CurrentRequest::KeyboardInteractive { submethods: String::new(), }), rejection_count: 0, }), // ...cipher = clear, mac = NONE, kex = None... }; Session { common: CommonSession { // ...all the fields process_packet expects... }, // ... } } pub fn fuzz_server_process_packet(data: \u0026amp;[u8]) -\u0026gt; Result\u0026lt;(), crate::Error\u0026gt; { let rt = tokio::runtime::Builder::new_current_thread() .enable_time() .build()?; rt.block_on(async { let mut session = make_session(); let mut handler = FuzzHandler; // returns Auth::Partial w/ prompts let _ = session.process_packet(\u0026amp;mut handler, data).await; Ok(()) }) } Two design choices made it work.\nFirst, the cipher is set to cipher::clear::Key and the MAC to mac::NONE. russh has a no-encryption cipher implementation already. It has to: every real SSH connection starts in cleartext before kex completes. I borrowed that machinery so I didn\u0026rsquo;t have to write a fake encryption layer. The fuzzer\u0026rsquo;s bytes are processed as if they\u0026rsquo;re already valid post-decryption SSH packets.\nSecond, the session is staged in WaitingAuthRequest with a FuzzHandler whose auth_keyboard_interactive returns Auth::Partial. That\u0026rsquo;s the exact state a real server is in mid-2FA flow, after it\u0026rsquo;s sent the prompts to the client and is waiting for responses. Anyone running keyboard-interactive auth (the standard mechanism for TOTP and similar second-factor flows) hits this state on every login.\nThe fuzzer ran. Within twenty minutes, the container OOM-killed itself.\nTriage cargo-fuzz dropped a crash artifact into artifacts/fuzz_server_packet/. Six bytes: 3d ff ff ff ff ff. The first thing I did was check byte 0 against RFC 4252 and RFC 4256. 0x3d is decimal 61, which is SSH_MSG_USERAUTH_INFO_RESPONSE. That confirmed the fuzzer had found something in the auth path I\u0026rsquo;d staged for, not in some unrelated packet handler.\nNext I needed to figure out what the remaining bytes meant in the context of that message type. RFC 4256 section 3.4 defines the wire format:\nbyte SSH_MSG_USERAUTH_INFO_RESPONSE int num-responses string response[1] ... string response[num-responses] So bytes 1 through 4 are the num-responses field. The artifact has ff ff ff ff there: 0xffffffff, or u32::MAX (4,294,967,295). Byte 5 is 0xff, trailing noise from the mutator that never gets read.\nI pulled up read_userauth_info_response in russh/src/server/encrypted.rs and traced the path. u32::decode(r) reads the count, the cast to usize widens it, and Vec::with_capacity tries to reserve space for that many Option\u0026lt;Bytes\u0026gt; entries before the loop ever touches the packet body. ASAN\u0026rsquo;s output confirmed it: the allocator attempted 0x1fffffffe0 bytes. That\u0026rsquo;s u32::MAX times the 32-byte Option\u0026lt;Bytes\u0026gt; layout on x86-64, roughly 137 GB. The process died on the allocation, not on any subsequent parse.\nThe takeaway for triage was clean: the bug is a single unchecked client-controlled integer flowing into a capacity hint with no bound against the actual packet length. Everything after that, the loop, the response decoding, is irrelevant. The server is dead before it gets there.\nThe bug The vulnerable code is in russh/src/server/encrypted.rs, in the function read_userauth_info_response:\nlet n = map_err!(u32::decode(r))?; let mut responses = Vec::with_capacity(n as usize); for _ in 0..n { responses.push(Bytes::decode(r).ok()) } n is read from the client\u0026rsquo;s packet. It\u0026rsquo;s a u32, so up to 4.3 billion, and it\u0026rsquo;s passed straight to Vec::with_capacity with no bound on remaining packet size or any other ceiling.\nA malicious client sends n = 0x10000000 (268 million) in a packet that\u0026rsquo;s otherwise empty. The server calls Vec::with_capacity(268_435_456). Each entry in the vector is an Option\u0026lt;Bytes\u0026gt;, which is roughly 24 bytes on a 64-bit platform. The allocation request is about 6.4 GB.\nMost servers don\u0026rsquo;t have 6.4 GB of free RAM. The kernel OOM-killer fires, the russh process dies, every active SSH session on that server drops, and the attacker can reconnect faster than the server comes back up.\nNo credentials, no session. The n field is decoded before the auth handler ever validates anything.\nWho\u0026rsquo;s affected The default Handler::auth_keyboard_interactive in russh returns Auth::reject(), which means the vulnerable code path doesn\u0026rsquo;t trigger on a stock russh build. The bug only fires when a server actively returns Auth::Partial with prompts, which is the intended API for handlers implementing 2FA, TOTP, or any multi-step credential flow.\nThat\u0026rsquo;s why Warpgate is in the affected list. Warpgate is an SSH and HTTPS bastion built on russh by the same maintainer, and it uses keyboard-interactive prompts as part of its login UX. Anyone running Warpgate as a bastion host was vulnerable to a single-packet pre-auth crash of the bastion itself: the server you put in front of your real infrastructure can be killed by an unauthenticated attacker on a packet boundary.\nProof of concept The repro is straightforward and was included in the private report:\nA minimal russh server with a Handler::auth_keyboard_interactive that returns Auth::Partial with prompts. Roughly 30 lines of code. A Python client that uses paramiko to do the SSH key exchange (so we get past kex without having to implement it ourselves), then sends a hand-crafted USERAUTH_INFO_RESPONSE with n = 0x10000000 and no response data after the count. A Docker container with a 512 MB memory cap. The russh server process inside the container exits 137 (SIGKILL from the OOM-killer) when the malformed packet arrives. The full PoC is in the security advisory\u0026rsquo;s record and was provided to the maintainer as part of disclosure. I\u0026rsquo;m not publishing the runnable code here, since the public advisory already contains everything an operator needs to verify their patched version is actually patched.\nThe fix The fix is a one-line bound on n:\nlet n = map_err!(u32::decode(r))?; let max_responses = r.remaining_len().saturating_add(3) / 4; let n = (n as usize).min(max_responses); let mut responses = Vec::with_capacity(n); for _ in 0..n { responses.push(Bytes::decode(r).ok()) } Each response in the wire format needs at minimum a 4-byte length prefix, even when the response itself is empty. So if there are N bytes left in the packet, there can be at most N/4 responses no matter what the attacker claims. Bounding n against that gives a tight cap that doesn\u0026rsquo;t change behavior for well-formed packets, and defangs the malicious case to at most a packet-sized allocation.\nThe saturating_add(3) handles the edge case where remaining_len() isn\u0026rsquo;t a clean multiple of 4. Rounding up rather than down keeps the bound from truncating the last response in a borderline well-formed packet.\nI implemented and tested this fix in a private fork while preparing the disclosure. It shipped in russh 0.60.1.\nDisclosure 2026-03-17: PR 661 opened. 2026-03-18: PR 661 merged. 2026-03-20: Vulnerability privately reported via GitHub Security Advisories, with a Docker-contained PoC and the proposed fix implemented. 2026-04-20: Coordinated disclosure. Russh 0.60.1 and Warpgate 0.23.1 released. Advisory published. Thirty-one days from private report to public advisory. That\u0026rsquo;s on the short end of \u0026ldquo;responsible\u0026rdquo; by industry convention, and it reflects how engaged Eugene Pankov was throughout. He responded quickly, evaluated the proposed fix on the merits, and shipped it without friction.\nSolo-maintainer security disclosure has a reputation for being rough. Reports get ignored, argued with, patched in ways that introduce new bugs, or sat on for months. None of that happened here. The russh and Warpgate disclosure pipeline is in good hands.\nWhat I\u0026rsquo;d take from this if I were you A few things from this work that I think generalize:\nThreat-model before you fuzz. The first question isn\u0026rsquo;t what parsers a library exposes. It\u0026rsquo;s what an attacker wants to do, and which code path gets them closest. The parsers behind that path are where the harness budget belongs.\nStage state past the wall. SSH, TLS, WireGuard, IPSec, anything with a handshake, has a phase where attackers pay no cost and a phase past it where the interesting bugs live. Building harnesses that pre-stage past the handshake is the difference between fuzzing 5% and 95% of the relevant code path. The pattern is generic: find the type that represents \u0026ldquo;this connection is set up,\u0026rdquo; construct one in test scaffolding, drive it from there.\nUse the host project\u0026rsquo;s own machinery. russh already had cipher::clear for the pre-kex phase of real connections. I didn\u0026rsquo;t have to write a fake encryption layer. Most non-trivial protocol libraries have similar internal affordances if you read for them.\nMaintainer relationships are a side effect, not a setup. PR 661 wasn\u0026rsquo;t strategic. I was fixing something I\u0026rsquo;d hit. But landing a substantive contribution before reporting a vulnerability changed the disclosure dynamic. Eugene already knew I\u0026rsquo;d read the code, and that my fix was reviewable. That trust matters when you\u0026rsquo;re asking a maintainer to take a security report seriously.\nThis is my first CVE. None of it required wizardry. It required picking a target with real motivation, reading the code carefully enough to threat-model it, designing a harness that puts the fuzzer where attackers actually live, and running the disclosure process the way the maintainer would want it run.\nReferences Advisory: CVE-2026-42189 / GHSA-f5v4-2wr6-hqmg CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H (7.5 high) Fuzz harness: coreyleavitt/russh tree/add-cargo-fuzz Entry-point fix: Eugeny/russh#661 ferrosync: coreyleavitt/ferrosync Thanks to Eugene Pankov for the maintainer-side work and responsiveness.\n","permalink":"https://blog.leavitt.dev/posts/cve-2026-42189-russh/","summary":"How designing the fuzz harness around real attacker surface, instead of pointing libfuzzer at every parser I could see, surfaced an unbounded allocation in russh\u0026rsquo;s keyboard-interactive auth in under 20 minutes.","title":"CVE-2026-42189: a pre-auth DoS in russh, found by fuzzing the right state"}]