A Denon Prime GO is a $1,000 standalone DJ controller. No laptop, no phone; you put music on a USB stick, plug it in, and play. It has a touchscreen, two decks, a battery, and absolutely no reason to let you run your own software on it.
It does anyway. You tap the version number five times, put a zip file on a flash drive, and it runs whatever you gave it.
I’ve been documenting inMusic’s firmware for a couple of years now. Denon DJ, Numark, Akai, HeadRush, Rane all share a codebase, so work on one device tends to apply to a dozen. Everything in this post lives in my documentation site. But the way into the device is my favorite thing I’ve found in it, partly because of how it works and partly because of a bug I found in inMusic’s own version of it.
Let me walk you through it.
What’s Actually Inside One
If you’ve read my Night Owl DVR post, the shape of this will feel familiar: consumer appliance, embedded Linux, undocumented USB-triggered path into the OS. The difference is that this time I didn’t need a chip clip and a programmer. Everything I needed was already downloadable.
Engine OS is a buildroot (before update 5.0.0) or Yocto (update 5.0.0+) system on a Rockchip RK3288 (some devices use a different CPU), with inMusic’s own software on top of it. The important piece is /usr/Engine/Engine, the app you’re actually looking at when you use the device:
| Property | Value |
| Format | ELF 32-bit, ARM:LE:32:v8 |
| Size | ~44 MB (.text alone is ~30 MB) |
| Real Functions | ~46,500 |
| Stripped | Yes, but RTTI, .ARM.exidx and assertion strings survive |
| Internal Name | planck |
That “stripped, but” row is the whole story of this project. The binary has no symbols, but the release build still carries C++ RTTI, an ARM exception unwind table with one entry per function, and assertion strings with full source paths still in them. That’s enough to reconstruct about 40% of the function names, which is plenty. I wrote up the whole method separately.
The UI is Qt/QML, and Qt stores QML in the binary as plaintext. Component source, property bindings, and the JavaScript behind the buttons are all readable with strings. That matters in a minute.
Getting the Firmware Open
inMusic publishes firmware updates as plain downloads. On older images, two commands get you a root filesystem:
mpcimg extract FIRMWARE.img output
binwalk -e output.img
mpcimg is from the MPC-LiveXplore project. Akai is the same parent company, so the container format is shared.
Newer devices sign their firmware, which breaks mpcimg. Those are a Flattened Image Tree with a vendor header bolted on the front, sitting before the usual 0xD00DFEED magic:
41 5A 30 78 01 ...
That decodes to AZ0x: the internal board family, AZ01 and AZ05 on everything I’ve seen. The header carries a version string, an image name, and the list of model IDs the update applies to. SCLIVE2-4.1.0-Update.img has 0x04 at offset 0x24, then four NUL-terminated strings: JC11S, JP11S, JP20, JP21. Four devices, one image.
For signed images, use a current binwalk and let it recurse:
binwalk -eM SCLIVE2-4.1.0-Update.img
Version 2.x couldn’t get through these. 3.1.1 could. That cost me an afternoon, so: check your binwalk version before you conclude a format is undocumented.
The signing does stop you modifying an update image. It does not stop anything below.
The Hidden Tab
Somewhere in that plaintext QML is this:
readonly property int secretTapsNeeded: 5
property int secretTaps: secretTapsNeeded
signal tapSecret()
onTapSecret: {
if(secretTaps > 0) {
secretTaps -= 1;
if(secretTaps === 0) {
showAdvancedTab = true;
}
}
}
That’s it. A counter. Tap something five times, an Advanced tab appears next to Control Center / Layout / User Profile / Settings.
Two things emit that signal, both invisible touch areas over text you already see:
objectName | What it Renders |
Version | Engine OS 5.0.4 (git hash) |
ProductName | The product name heading |
So: go into settings, tap the Engine OS version string five times, and a new tab shows up. No cable, no solder, no disassembly. There’s no per-product condition on that counter. I checked all three 5.0.4 Engine builds and every one of the 16 products has secretTapsNeeded: 5, both tap targets, and the launcher binary on disk.
The tab has some genuinely useful stuff on it: serial numbers, product code, startup timing, a system monitor, page cache stats, a frame timer, and Reboot To Computer Mode. The first entry is Start Production Test Application.
I’ve confirmed the tab on real hardware on a Prime GO and an SC6000, both on 5.0.4 and 4.3.4. I haven’t checked older firmware, and three of the Mixstream images won’t extract for me yet, so treat those as unknown rather than absent.
The Part I Actually Like
There’s a second way in, and it’s the one that shipped for the factory: hold a button combination while the unit boots.
Here’s the thing: Engine never reads the buttons. It can’t; the control surface is a separate microcontroller. So at startup, Engine asks the MCU one yes/no question over MIDI SysEx:
function requestPowerOnButtonState() {
Midi.sendSysEx("F0 00 02 0B 7F 12 42 00 00 F7") // JP21
}
Command 0x42. If byte 9 of the reply is 1, Engine writes TestApp to /tmp/engine-quit-reason and exits. /usr/Engine/Scripts/engine reads that file and runs /usr/bin/test-app-launcher. All 16 products implement the same handshake, only the manufacturer and device ID bytes differ.
Which means the button combination itself lives in the MCU firmware. And the MCU firmware ships inside the root filesystem, under /usr/Engine/Firmware/. So I could just read it.
On the Prime GO, test mode is a clean three-button AND:
uint CheckTestModeCombo(void) {
uVar1 = GetButtonState(0x28);
uVar2 = GetButtonState(0x29);
uVar3 = GetButtonState(0x2f);
return uVar1 & uVar2 & uVar3;
}
On the SC6000, the same three reads are there. Look at what happens to them:
bVar1 = GetButtonState(0x32); *DAT_08012e58 = bVar1 ^ 1;
bVar1 = GetButtonState(0x33); *DAT_08012e58 = bVar1 ^ 1; // overwrites
bVar1 = GetButtonState(0x34); *DAT_08012e58 = bVar1 ^ 1; // overwrites
if (*DAT_08012e58 != 0) SetTestModeFlag(1);
Each read overwrites the result instead of ANDing it. Only the last one has any effect. Someone wrote a three-button combination and shipped a one-button combination.
I checked this in the disassembly rather than trusting the decompiler, and the two other combinations in the same function do it correctly. They load the previous value and AND it in first. So it’s not a house style. It’s a bug, in exactly the sort of code nobody ever revisits because the failure mode is “the secret combination worked.”
One honest caveat: those numbers are bit positions in the MCU’s scan matrix, not MIDI notes, and I don’t have a scan-index-to-physical-button map for any device yet. Holding single front panel buttons on an SC6000 doesn’t trigger test mode, and 0x32–0x34 are read in this one boot check and nowhere else in the firmware, so they may not be front panel buttons at all. The bug is real. Which physical buttons it makes redundant, I can’t tell you yet.
While you’re in there, two other index pairs on the SC6000 are worth knowing because they change what you see: 0x26+0x27 spins in a loop before the test mode check, so the unit looks hung, and 0x15+0x16 sweeps all 80 LEDs to full brightness and then boots normally.
What the Launcher Does
Both doors lead to the same place: /usr/bin/test-app-launcher. It walks every mount on the device, finds test app packages, lists them on screen, and runs the one you pick.
There are two package formats, and the scanners are completely independent. One drive can carry both.
V1 has no manifest at all. Every piece of metadata comes from directory names:
<drive>/TestAppsCatalog/<product>/<os-version>/<app-version>/<something>.taimg
It descends only into directories at each level, then takes the first regular file ending in exactly .taimg. The first level, upper-cased, becomes the product list; the same name with TestApp appended becomes the executable. So jp11 gives you products: [JP11] and JP11TestApp.
V2 is an archive with a manifest inside. This is the interesting one:
- Iterate the root of the drive.
- For every regular file whose extension is
.zip, open it withlibarchive. - Look for an entry whose path is exactly
manifest.yaml. - Parse it as YAML.
- Every entry under
testAppsbecomes a launcher record.
Two details that took reading the binary to get right, and that I had wrong in my own docs for a while:
- It is not looking for a file called
TestAppsCatalog.zip. Any.zipin the drive root gets opened.TestAppsCatalogis the V1 directory name and has no extension. - The manifest is matched with a plain
strcmpagainstmanifest.yaml, so it has to be at the root of the archive.TestAppsCatalog/manifest.yamlwill never be found.
And one that’s just funny: only the file extension is checked, never the container format. The archive is opened with archive_read_support_format_all and archive_read_support_filter_all. So anything libarchive can read works: tar, 7z, whatever, as long as you name it .zip.
Building One
The whole payload is a zip with a YAML file in it:
testApps:
- version: 1.0.0
osVersionID: 2023.02.11
products:
- JP11
signedImage: False
basePath: test-apps/jp11
relativeExePath: JP11TestApp
launcher-XXXXXX: SomeLauncher
name: JP11 Test App
products is the hardware ID list. basePath is the folder inside the archive. relativeExePath is what gets executed. osVersionID has to match what the launcher reads out of /etc/os-release, and that changed between major versions. On 4.x (ID=buildroot) it’s VERSION_ID, like 2023.02.11; on 5.x (ID=az0x) it’s VERSION_CODENAME, like scarthgap. List entries for both and one of them matches.
I have no idea what launcher-XXXXXX is for. It’s in the schema, the parser reads it, and I’ve never seen it matter.
Note relativeExePath. It’s a path to execute. It does not have to be a compiled ARM binary. A shell script works fine, which makes this an unusually comfortable place to poke at a device. And signedImage: False means exactly what it says: on this path there is no signature, no hash, and no check of any kind on what you handed it.
For what it’s worth, I pointed mine at a script that installs an sshd unit and gives me a console on my own hardware. That’s the payoff: a real shell on a device that ships without one. I’m not going to hand out a turnkey copy of it, but there’s nothing clever in it either; once arbitrary execution is on the table, it’s just shell.
The Signed Path Is Not A Bypass
There’s a second format, and I want to be precise about it because it would be easy to oversell.
Setting signedImage: True sends the payload through /usr/bin/az01-signed-fs instead. Those .taimg files are a container I’ve been calling AZSI, after its magic bytes:
0x00 "AZSI"
0x04 version (1)
0x0C signed_len signature covers [0, signed_len)
0x10 strtab_off / strtab_len
0x18 data_off / data_len squashfs
0x28 hash_off / hash_len dm-verity hash tree
0x3C sig_count 264-byte records at signed_len
Each 264-byte record is an 8-byte string table offset plus a 256-byte RSA-2048 signature. It’s a squashfs image with a dm-verity tree over it, RSA-signed, mounted read-only through device mapper. That’s a properly built verified-boot pipeline for removable media, and it works. I reversed the format well enough to build my own containers and get inMusic’s real az01-signed-fs binary to accept them under emulation and on real hardware.
Here’s the part that stops it being a headline. That 8-byte offset points at a key name, and the key name selects /usr/lib/az0x/pubkeys/<name>.pub on the device. My containers verify because I put my own public key in that directory. To do that, you need root on the device already, or you need to patch the firmware and inject your own key.
So I didn’t break the signing. I read the format, and it holds up. It’s just that the unsigned path sits right next to it, needs no key at all, and is the one the launcher reaches first.
What I Take From This
I don’t think this is a scandal. A production test hook is a completely reasonable thing to build. Somebody on a factory line needs to check 80 LEDs and every fader on a unit that has no keyboard, and shipping the harness in the firmware is how that gets done.
What’s interesting is the gap between the two halves. One team built RSA-2048 over dm-verity for loading code from a flash drive. The unsigned path, on the same launcher, one boolean away, takes any zip file in the drive root and runs what’s inside. The strong mechanism exists. It just isn’t the one that has to be used.
That’s the same shape as most of the security work I end up doing. It’s rarely a broken algorithm. It’s a good mechanism sitting next to an easier path, with nothing forcing anyone through the good one.
Three things worth taking away, whether or not you’ll ever open a DJ controller:
Strings are the whole ballgame. Qt shipped the QML as plaintext. The release build kept its assertion messages, its source paths, and its RTTI. Every name in this post came out of data the compiler had no obligation to leave behind and left anyway.
Check your tool versions before you conclude something is hard. binwalk 2.x said the signed images were opaque. binwalk 3.1.1 opened them in one command.
The failure mode nobody tests is the one where it works anyway. That SC6000 button check does fire, just on fewer buttons than intended. It passed whatever testing it got, because the test was “does the combination open test mode,” and it does.
Everything here is on my documentation site, and the repository takes issues and pull requests. I still don’t have a scan-index-to-button map for any device, UART pinouts for most of the range, or working extraction for the Mixstream images. If you have hardware I don’t and half an hour to spare, I’d love the help.