Everything is wired up, let’s flash

She said, “Where we goin’?” I said, “The moon”

Alright so we left off with all the wires connected, the bench power supply ready, and my ESP-Prog looking like a spaghetti monster grew on my desk. Time to actually make this thing do something.

The first thing I did was download the firmware images. OpenWrt has specific builds for the MR18 under the ath79/nand target:

cd firmware/
wget https://downloads.openwrt.org/releases/25.12.0/targets/ath79/nand/openwrt-25.12.0-ath79-nand-meraki_mr18-initramfs-kernel.bin
wget https://downloads.openwrt.org/releases/25.12.0/targets/ath79/nand/openwrt-25.12.0-ath79-nand-meraki_mr18-squashfs-sysupgrade.bin
echo "db191ecf0224f030365d604aa3919da9  openwrt-25.12.0-ath79-nand-meraki_mr18-initramfs-kernel.bin" | md5sum -c
echo "53e272bed2041616068c6958fe28a197  openwrt-25.12.0-ath79-nand-meraki_mr18-squashfs-sysupgrade.bin" | md5sum -c

Two images—the initramfs kernel (6.9 MB, boots entirely from RAM) and the sysupgrade image (the one that actually gets flashed to NAND permanently). The plan was simple: load the initramfs into RAM over JTAG, boot it, then use the running OpenWrt to flash the sysupgrade image. Simple right? Right? No.

The Wrong Fucking Binary

So I actually didn’t download those files first. I’m a dumbass. See, the MR18 exists under TWO different OpenWrt targets: ar71xx and ath79. They both support the same QCA9557/AR9344 SoC. I grabbed the ar71xx one first because it showed up first in my search and I didn’t think twice about it.

Loaded it up, CPU resumed, lzma-loader started doing its thing and… nothing. Absolutely nothing. No serial output, no Ethernet link, no sign of life. The LZMA decompressor just silently died.

After way too long debugging I figured out the issue. The ar71xx build has a different lzma-loader startup sequence—it zeros out the BSS section BEFORE relocating itself to a different memory address. The problem is that the BSS region overlaps where the compressed kernel data lives. So the loader literally wipes its own payload before it gets a chance to decompress it. The ath79 build does it the right way—relocates first, then zeros BSS at the new address, leaving the original compressed data untouched.

This was the kind of bug that made me question my career choices for about 30 minutes until I read more carefully and realized I just downloaded the wrong file. Anyway, switched to ath79. Moving on.

OpenOCD: My New Best Friend (Who I Hate)

Now for the actual JTAG part. I needed OpenOCD to talk to the MR18’s EJTAG interface through the ESP-Prog. OpenOCD is this open-source tool that speaks every debug protocol under the sun, including MIPS EJTAG. I wrote two config files—one for the ESP-Prog adapter (telling OpenOCD it’s an FT2232H on channel 0) and one for the MR18 target (MIPS M4K core, big-endian, with the weirdest JTAG TAP ID I’ve ever seen: 0x00000001).

Yeah, 0x00000001. Per the IEEE spec, bit 0 being 1 just means “this is a real IDCODE, not the BYPASS register.” Qualcomm/Atheros set literally everything else to zero. Thanks guys, very helpful for auto-detection. OpenOCD can’t figure out what chip it’s talking to from that alone, so you have to spell everything out in the config.

The 2-Second Window

Here’s where it gets spicy. Remember how I mentioned in the last post that the Meraki bootloader disables JTAG probing within about 2 seconds of power-on? Let me explain what’s actually happening.

When the MR18 powers on:

  1. The AR9344 SoC boots from internal ROM
  2. The Nandloader (Meraki’s bootloader in NAND) initializes DDR RAM and loads the Cisco kernel
  3. Cisco’s Linux kernel starts up and reconfigures the GPIO pin that carries TDO (JTAG Test Data Out) for something else entirely
  4. JTAG scan chain is physically broken. Game over.

Steps 1 through 3 take about 2 seconds. That’s our window. Two fucking seconds to power on the device, have OpenOCD scan the JTAG chain, find the TAP, and halt the CPU before the kernel murders our debug interface.

My first attempt at this was also wrong. I started OpenOCD BEFORE powering on the MR18. Seemed logical right? Have the debugger ready and waiting. Nope. OpenOCD runs its -c init which immediately tries to scan the JTAG chain. All the lines are floating because there’s no power. OpenOCD sees nothing, enters an error state, and when the MR18 finally comes alive OpenOCD is already sulking in a corner refusing to talk.

The fix: power on first, wait about 1.5 seconds for the Nandloader to be alive and have JTAG active, THEN start OpenOCD.

Automating the Timing Attack

I wasn’t about to sit there with a stopwatch trying to manually time all of this. So I wrote a Python script—mr18_flash.py—that automates the entire process. The script uses the SCPI bench power supply (remember those drivers I wrote for my ESET 453 class?) to power cycle the MR18 programmatically.

The sequence goes like this:

psu("psu chan off")    # Cut power
time.sleep(2.5)        # Wait for caps to discharge
psu("psu chan on")     # Apply 12V
time.sleep(1.5)        # Wait for Nandloader
# Start OpenOCD, connect, HALT HALT HALT

The 2.5 second off period makes sure all the capacitors fully discharge so the SoC actually resets. The 1.5 second on delay is the sweet spot I found empirically—long enough that the Nandloader has initialized DRAM and the JTAG TAP is alive, short enough that the kernel hasn’t killed JTAG yet.

Once OpenOCD connects, the script has roughly 0.5 seconds left in the window. It runs a tight halt loop combining two methods—OpenOCD’s high-level halt command, and raw EJTAG register manipulation where I shove 0x9008 into the EJTAG control register to set PROBEN (enable processor access), JTAGBRK (request debug break), and BIT3 (just always set, the spec says so). Then it polls for the BRKST bit to confirm the CPU actually halted.

The whole thing is wrapped in a retry loop—up to 6 power-cycle attempts. If the halt fails (the kernel got there first), kill OpenOCD, power cycle, try again. At 20ms per halt attempt within each power-on window, that’s about 50 shots per cycle. Empirically, it usually works on the first or second power cycle. When it doesn’t, it’s because USB latency on the host added some unpredictable delay to the first JTAG transaction.

Attempt 1... OpenOCD connected... halt loop... HALTED!

That first time seeing “HALTED” print out? Genuinely one of the best feelings. The CPU was frozen in the middle of the Nandloader, JTAG was ours, and Cisco’s kernel never got a chance to ruin our day.

Loading the Binary: 70 Seconds of Anxiety

With the CPU halted, OpenOCD gives us full control. Time to shove 6.9 MB of OpenWrt kernel into RAM. The command is deceptively simple:

load_image openwrt-initramfs-kernel.bin 0xa005FC00 bin

That address—0xA005FC00—is in KSEG1, the uncached segment of MIPS memory. KSEG1 means the writes bypass the CPU’s data cache and go straight to physical RAM. This is important and I’ll explain why later (spoiler: I learned the hard way).

The transfer happens over EJTAG PRACC (Processor Access)—OpenOCD feeds 32-bit words through the debug interface one at a time. At a 1000 kHz JTAG clock, this gives about 97 KB/s throughput. For a 6.9 MB file that’s roughly 70 seconds of watching a progress bar crawl.

70 seconds doesn’t sound bad, but when you’re staring at it wondering if this attempt is going to be the one that works, every second is an eternity. And then the load finishes, you fire the launch trampoline, and… data error! on the serial console.

That was just the beginning of a very long night. But this post is getting long enough—the cache coherency nightmare, the bit-flip corruption, and the hand-encoded MIPS assembly are all coming in the next one.