← Back to Writing
August 10, 202620 min read

The Art of Reading Assembly: In Practice

assemblyreverse-engineeringfirmwarearmiriver

The Art of Reading Assembly: In Practice

Last time, I told you how to read assembly. This time, I'll show you what happened when I actually did.

It's the story of a twenty-two-year-old MP3 player, a wall of encrypted noise, two months of chasing the wrong lead, a rookie mistake that broke everything I thought I was reading, and a bug that had been sitting quietly in the firmware since before I was born.

(If you want the how-to-read-assembly primer first, it's here — but you don't need it. This post stands on its own. The examples there are x86-64 and everything below is ARM7TDMI; the syntax differs, the patterns don't.)

In my last post I kept dropping the same hint — "when I first opened Ghidra to analyze an older device's firmware..." — always in passing, always as an example. I never said which firmware, or what I found inside it. This is that story.

The Device

The firmware belongs to an iRiver N10. If you've never heard of it, that's fine—almost nobody has. It's a tiny Korean MP3 player from 2004, about the size of a stick of gum, with a 128×64 monochrome OLED screen and six buttons—three down each side of the body—instead of anything resembling a modern touch interface.

This particular one is a white pearl limited edition. It shimmers when light hits it. It was a Christmas gift, years ago, and it still works perfectly—which is exactly why I was never going to crack it open. No JTAG, no soldering, no chip-off reading. If I wanted its firmware, I had to get it without ever touching a screwdriver.

That constraint turned out to be the whole point. Reverse engineering usually gets framed as breaking in; this was the other version—understanding something you're not allowed to disturb. You can only look, and reason. It turns out you can get remarkably far that way.

Under the shell sits a Philips PNX0101—an ARM7TDMI core running at 60MHz, with 32KB of mask ROM, 512KB of on-chip flash, and 64KB of SRAM, plus a separate 128MB Samsung NAND chip on the board holding the firmware and your music. ARM7TDMI is old enough that there's almost no modern tooling that assumes it, and obscure enough that documentation is mostly scattered fragments. Which, if you're the kind of person who finds that exciting rather than exhausting, is the best possible situation. Nobody's going to hand you the answer. You have to find it.

First Contact: A Wall of Noise

The firmware ships as a file called N10.HEX. I got hold of one and opened it in a hex editor, expecting to at least see something—a header, some strings, a recognizable structure.

Nothing. Just entropy. Page after page of bytes that looked random, with one tiny exception: a readable fragment, ANS51y, sitting near the front—not a real magic number or signature, just six bytes that happened to survive the encoding as printable ASCII. The lone legible thing in a sea of noise, and ultimately a red herring. Everything else was encrypted.

This is the moment where a lot of projects die. The thing in front of you is opaque, and there's no obvious thread to pull. In my last post I wrote about the "wall of assembly" being overwhelming. This was worse—it was a wall of nothing, because you can't even read the assembly until you've undone the encryption.

My first instinct was the PC software. iRiver's old desktop program, "iriver Music Manager," is what originally pushed firmware onto these players, so the encryption logic had to live somewhere inside it. I spun up the software and went hunting—not just reading the executable, but watching it run, sniffing process memory while it worked, trying to catch the decryption in the act. And I came up with nothing usable. After two months of dead ends, I gave up and walked away.

Walking away turned out to matter, because the answer eventually arrived from a completely unrelated direction.

Some weeks later, idly, I got curious about something else entirely: did Rockbox—the open-source music-player firmware project—ever support any iRiver devices? I went to their wiki to find out, started clicking around, and stumbled onto their work on the iFP series. And there it was: a wonderfully patient cryptanalysis writeup. Someone had noticed repeating patterns in the encrypted firmware, someone else converted the bytes into images and spotted structure recurring every 51 bytes, and eventually they worked out that each 256-byte block is really five groups of 50 data bytes plus a checksum byte, capped by a block checksum—so 256 bytes of ciphertext carry 250 bytes of real data. They'd even published a decoder.

The iFP is not the N10. But I'd stared at this firmware long enough to notice that its menus and overall structure looked suspiciously like the iFP's—same family resemblance. So I took a gamble I fully expected to fail: I pointed the iFP decoder straight at the N10 image.

It decrypted the whole thing, cleanly, on the first try. The two device families shared the same encryption scheme, and a tool written for one just worked on the other. Two months of sniping at memory had gotten me nowhere; idle curiosity about an adjacent device cracked it open in an afternoon. (There's a specific kind of joy in a borrowed key turning in a lock it was never cut for.)

The wall of noise became a wall of code. Strings appeared. FORMAT, F/W UPGRADE, LANGUAGE. WMA Windows Media(tm) Audio. The thing was finally readable.

Or so I thought.

The Mistake That Broke Everything

I dumped the decrypted binary, loaded it straight into Ghidra, and started reading. And something was off.

The functions were there—some of them, anyway. But the first 512 bytes disassembled into complete nonsense: instructions that decoded to nothing sensible, "functions" with no discernible purpose. Ghidra's auto-analysis had drawn function boundaries in places that made no sense. And every absolute reference—function pointers, jump tables, anything that named a fixed address—landed on garbage. Pointers into nowhere. Strings that weren't strings.

If you've read my last post, you know I made a big deal about data movement: where data comes from, where it goes. Well, I'd broken exactly that, and it was entirely my own fault.

Here's the trap. When you load a raw binary into a disassembler, you have to tell it the base address—the memory address the firmware actually expects to live at when the chip runs it. ARM7TDMI starts executing from address 0x00000000 on reset. But my file had a 512-byte (0x200) irde header glued to the front, and I'd loaded it as-is with the base at zero. So Ghidra was reading that header as if it were ARM code (hence the nonsense at the start), and every real instruction sat 512 bytes higher than the firmware thought it did. Anything that referenced a fixed address was off by exactly 0x200.

The fix is conceptually trivial: strip the header so the code starts at 0x00000000 where it belongs. But the part I want you to take away isn't the fix. It's how I confirmed it, because that's the actual skill.

Before stripping anything, I picked one early PC-relative load and did the pipeline math by hand—not to watch it fail, but the opposite. PC-relative loads use a relative offset, so they resolve correctly whether or not the header is there. That made one a perfect measuring stick: if I could predict what it pointed at and the prediction held, I'd know exactly how the file was laid out. On ARM, when an instruction reads pc, it sees an address two instructions ahead—current + 8. With the header still attached, the first real instruction sat at 0x200:

asm
ldr r1, [pc, #0x1f4] ; pc = 0x200 + 8 = 0x208 ; target = 0x208 + 0x1f4 = 0x3fc

I went to that computed offset, read the 4 bytes sitting there, and checked: did it equal a value that made sense? It did. It was 0x8000820C—which, from the PNX0101's memory map, is CGU_SCR1, a clock configuration register. Boot code configures the clock almost immediately, so a clock register being loaded right at the start is exactly what you'd expect. (Strip the header and the same load resolves to 0x1fc, holding the same value—shifted by exactly the 0x200 I needed to remove.)

That's the whole trick. You don't verify a layout by trusting the tool. You find one place where you can predict what the answer should be, and you check whether reality agrees. When it does, the entire rest of the binary snaps into focus at once. When it doesn't, you've just saved yourself from hours of reading fiction.

Finding the Way In

With the header stripped and the layout finally correct, I could do what my last post was about: find the boundaries and trace the flow.

Boot code is a maze on purpose, and tracing it taught me how the device actually wakes up—in two distinct stages, which took me a while to separate.

The first stage is baked into the PNX0101 chip itself: a small mask ROM, burned in at the factory, unchangeable. Its job is narrow. It sets up the clock, initializes the NAND controller, and then jumps to 0x8000c8—an address in the chip's on-chip flash. That's it. It doesn't verify anything, it doesn't load the main firmware—it just gets the hardware breathing and hands off to the second stage.

The second stage is the real bootloader, living in that on-chip flash, and this is where the path forks. It verifies the firmware image, and depending on the result it goes one of two ways. If verification fails, it falls into a reset loop—jumping through 0x3d8 and 0x22c and back to 0xc8, which is the same entry point the mask ROM originally jumped to, just expressed as a relative address in my disassembly rather than the absolute 0x8000c8. In other words, failure means starting the whole thing over. If verification succeeds, it copies the main firmware out of NAND into RAM and hands control over:

asm
0x3cc: mov pc, #0x80 ; jump into the firmware now living in RAM at 0x80

It took me longer than I'd like to admit to untangle this—both to realize the reset loop and the success path were different branches rather than one sequence, and to notice where the mask ROM's job ended and the second-stage bootloader's began. None of this is documented anywhere. You recover it the same way you'd reconstruct a path through a dark house: follow each jump, note where you land, and slowly figure out which doors lead forward and which lead back to the start.

And tracing that flow corrected a belief I'd never thought to question. Devices of this era usually run their code straight out of NOR flash—execute-in-place, no copying required—so early on I'd labeled a region of the address space "NOR flash" without a second thought, and assumed that's where execution lived. But the boot flow I'd just traced didn't copy anything from a NOR region; it copied from NAND. So I did the blunt, decisive thing: I searched the entire firmware for instructions that touch NOR flash and counted them.

Zero. Not one. Then I counted NAND accesses: thousands.

There was no external NOR flash. There never had been. The only flash on this device is the 512KB inside the chip, and that's reserved for the bootloader—there's no external NOR holding the main firmware for the CPU to execute in place. Instead the bootloader reads the firmware out of NAND, copies it into RAM, and runs it there, which is exactly why the handoff is a jump to 0x80 in RAM rather than into some flash region. The "start of NOR" I'd so confidently labeled was a description of hardware that didn't exist. And I didn't disprove it by being clever. I disproved it by counting—the same move as the base address, just pointed at a different assumption.

This is the part that never makes it into the cleaned-up writeups. The map looks obvious once it's drawn. Drawing it does not feel obvious at all—and half of drawing it is discovering which of your own assumptions were furniture you'd imagined into the room.

The Twenty-One-Year-Old Bug

Here's the payoff. While reading through the settings code, I found a bug. A real one. And as far as I can tell, nobody had ever noticed it—not iRiver, not a single forum post, nothing online. It shipped with UMS 1.80 back in 2005 and had been sitting there, untouched and unreported, for twenty-one years.

The symptom is subtle enough that I understand how it stayed hidden: if you turn on the "3D Extreme" audio effect and then adjust the equalizer, your EQ settings silently reset to zero. It only triggers in that specific order, so most people would just shrug, re-set their EQ, and never connect it to anything. But the cause is unambiguous once you're in the assembly. A function was clearing a chunk of memory on the stack:

asm
; clears 0xa0 bytes starting at sp+0x58 mov r2, #0xa0 ; length to clear ... ; -> memset(sp+0x58, 0, 0xa0)

The problem: the EQ values lived just past the end of that region, starting at sp+0xf0. But a clear of 0xa0 bytes from sp+0x58 covers everything up to and including sp+0xf7—and 0xf0 through 0xf7 is the first eight bytes of the EQ buffer. So the clear ran eight bytes too long and zeroed the start of the equalizer settings as collateral damage. Classic buffer overrun, except the "attacker" was the firmware clobbering itself.

The fix is one constant. Change the length from 0xa0 to 0x98, so the clear stops exactly where it should:

asm
mov r2, #0x98 ; was 0xa0

In the actual bytes, that's a single patch: a020a0e3 becomes 9820a0e3. One byte. A bug that survived since 2005, fixed by editing one byte in a binary, because the assembly made the overrun impossible to miss once it was in front of me.

But the part that genuinely surprised me came when I asked the obvious follow-up question: why was the boundary wrong in the first place? Software doesn't usually ship a buffer overrun by accident in a place this central. So I pulled up a second firmware to compare against—the older "Manager" line, version 1.60—and lined the equalizer variables up side by side:

UMS 1.80: DBE @ 0x043d48 3D EQ @ 0x043d4c BASS @ 0x043d54 Manager 1.60: DBE @ 0x04353c 3D EQ @ 0x043540 BASS @ 0x043548

The addresses aren't just shifted by a constant—the whole layout is different. And that's striking, because almost everywhere else the two firmwares are nearly the same program: same structure, same routines, just relocated to different addresses. UMS is plainly built on Manager's bones. The equalizer is the one big exception—just about the only subsystem that was substantially rewritten instead of carried over. So when I see the EQ layout diverge while everything around it stays familiar, the conclusion writes itself: they rebuilt this one engine, and left the rest alone.

And here's the genuinely strange twist. The older Manager firmware (1.60) keeps its EQ logic split across several separate functions—one for normal EQ, one for Xtreme, one for 3D, one for bass enhancement. The newer UMS firmware (1.80) collapses all of that into a single combined function with the modes selected by branches. Lower version number, more modular code. Higher version number, more consolidation. The opposite of what you'd assume.

That inversion is the whole answer. In Manager's split design, each mode had its own function with its own stack layout, so the memset cleared a region that was safely sized for its function. When the modes were merged into one function for UMS, the stack layout shifted underneath that old clear—but the length stayed 0xa0. A boundary that used to be safe now reached eight bytes too far. The bug wasn't written from scratch. It was created in a refactor—the moment several safe pieces of code were fused into one without re-checking where the edges landed. Everything they left untouched still works perfectly. The one subsystem they rebuilt is the one that broke. That's not a coincidence you have to squint at; it's cause and effect, written in the diff between two binaries.

I find that quietly profound. The bug isn't a typo. It's a fossil of a design decision—a record of the exact moment someone optimized for size and lost track of a boundary. You can read the history of a codebase in the scars it leaves behind—as long as you're careful about which scars are real.

That last clause is there because I almost tripped over it myself. My first instinct was to build a story around dates—this firmware came years before that one, the team must have changed, and so on. I'd pulled those dates from a firmware database I happened to have. But when I checked them against iRiver's own original download listings, they didn't match; the database I'd trusted was simply wrong. So I'm not going to lean on a timeline I can't stand behind. The structure is the real evidence, and the structure doesn't need dates to make its case. The binary does carry one honest timestamp, though, if you want to know what era you're digging in: compiled into it is a WMA decoder SDK stamped September 19, 2004—not an estimate, just a date sitting quietly in the bytes, older than the person reading them.

And I want to be clear about the feeling here, because it's the reason I do any of this. It is not the feeling of being clever. It's the feeling of seeing—of the machine going transparent for a second, so that a problem that hid in plain sight for two decades becomes a small, obvious, fixable thing. That's what reading assembly buys you. Not power. Clarity.

What I Built, and What's Next

All of this turned into a small custom firmware I call SOLIN: a patched build that fixes the EQ bug, cleans up a mistranslated menu label, and stamps its own version string. (That last one had a fun complication—the version string sits inside the font data region, so editing the font silently breaks the version, and vice versa. The firmware is full of these little adjacencies where unrelated things share a neighborhood. Twenty-two-year-old software is a coral reef.)

And it runs. I re-encrypted the patched image, flashed it onto the actual device—the same white pearl N10 I was never willing to open—and watched it boot with my own version string on the screen. Then I did the thing I'd been waiting weeks to do: turned on 3D Extreme, opened the equalizer, and adjusted it. The settings stayed. Twenty-one years of that quiet little overrun, and it was simply gone.

I don't know how to convey how good that moment felt. Every step before it was inference: this should be the base address, this should be a boot handoff, this clear should be eight bytes too long. Flashing it is the moment inference meets the physical world and the physical world agrees with you. The device in your hand behaves the way your reading of the assembly said it would. That's the whole payoff of this kind of work—not the patch, but the confirmation that you understood the thing correctly.

I left some parts deliberately untouched. The firmware-upgrade routine—the code that actually receives and writes a new firmware image—I haven't modified at all, because that's my safety net: the way back if some future patch ever bricks the device. (There's no separate USB recovery mode to fall back on, which surprised me; the upgrade path is the recovery path. All the more reason not to touch it.) When you're working on hardware you can't open, "always leave yourself a way to recover" stops being advice and becomes a survival rule.

There's a thread I haven't pulled in this post. Static analysis—reading without running—took me a long way, but eventually I wanted to watch the boot code actually execute, register by register, on a chip I didn't physically have. So I wired up an emulation harness around the ARM7TDMI, using unicorn-engine as the backend, and started single-stepping the boot sequence while watching memory change in real time.

That was an adventure of its own: phantom stack pointers, polling loops that never seemed to end, and a confirmation of something static analysis had already hinted at—that on this chip there's no clean line between where code lives and where data lives. They share one undivided space. But that's the next post.

For now, the takeaway is the same as last time, just earned instead of asserted. You don't need to be an expert. You need to recognize patterns, predict what you should see, and check reality against your prediction. The encryption, the base address, the boot chain, the bug nobody had found in twenty-one years—none of it required genius. It required reading carefully and refusing to look away.

So when you hit your own wall of noise, don't close the tab. Find the one byte you can predict. Check it. And watch the whole thing come into focus.

✦ ASK AI

Ask about this post

Comments

No comments yet. Be the first!

Leave a Comment