ghidra
This commit is contained in:
1 parent
6a6553077b
commit
f71a2a3512
23 files changed
+7772
-67
No files matched your search
@@ -24,3 +24,5 @@ etc/sldl-macos.yaml
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
maple-py/captures
|
||||
maple-py/node_modules
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ghidra": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"--script",
|
||||
"/mnt/c/Users/ntr/Downloads/GhidraMCP-release-1-4/GhidraMCP-release-1-4/bridge_mcp_ghidra.py",
|
||||
"--ghidra-server",
|
||||
"http://127.0.0.1:8080/"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,14 @@ usb-reset dst="/Volumes/MAPLE":
|
||||
COPYFILE_DISABLE=1 rsync -rtvh --no-perms --no-owner --no-group --modify-window=2 maple-py/data/MAPLE/Contents/ {{dst}}/Contents/
|
||||
COPYFILE_DISABLE=1 rsync -rtvh --no-perms --no-owner --no-group --modify-window=2 maple-py/data/MAPLE/PIONEER/ {{dst}}/PIONEER/
|
||||
|
||||
# Build the maple AceMo export.pdb and sync it onto the Windows USB (drive F: by
|
||||
# default) over rekordbox's export.pdb, so it can be tested on the player against
|
||||
# the audio/USBANLZ already on the stick. Runs from WSL via powershell.exe.
|
||||
usb-win dst="/mnt/f":
|
||||
cd maple-py && uv run python tools/build_acemo.py
|
||||
@test -d {{dst}}/PIONEER/rekordbox || (echo "no {{dst}}/PIONEER/rekordbox - is the USB mounted at {{dst}}?"; exit 1)
|
||||
rsync -tvh --no-perms --no-owner --no-group maple-py/captures/maple_acemo.pdb {{dst}}/PIONEER/rekordbox/export.pdb
|
||||
|
||||
maple-setup:
|
||||
bash ./etc/maple_setup.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# maple-py — rekordbox `export.pdb` writer + reverse-engineering notes
|
||||
|
||||
Python tooling to generate a rekordbox USB export (`PIONEER/rekordbox/export.pdb`
|
||||
+ `exportExt.pdb` + `USBANLZ` analysis files) so a CDJ/XDJ can read a
|
||||
self-assembled music library. The writer lives in `src/maple/pdb/`.
|
||||
|
||||
This README also documents what we learned reverse-engineering rekordbox's own
|
||||
exporter, because the on-disk format has real unknowns / magic numbers that the
|
||||
public kaitai schema (`src/kaitai/rekordbox_pdb.py`) only partially decodes.
|
||||
|
||||
## The format, briefly
|
||||
|
||||
`export.pdb` is a **DeviceSQL** database: a flat file of 4096-byte **pages**,
|
||||
grouped into 20 **tables** (tracks, artists, albums, playlists, artwork,
|
||||
colors, columns, history, and several still-unknown ones). Page 0 is the file
|
||||
header + table directory; each table is a linked list of pages. See
|
||||
`src/maple/pdb/{writer,pager,rows,strings}.py`.
|
||||
|
||||
## Reverse-engineering findings (rekordbox 7.0.5, Windows)
|
||||
|
||||
### Where the export code lives
|
||||
- The exporter is in **`rekordbox.exe`** (the 89 MB main binary), **not**
|
||||
`rekordboxAgent.exe`. Confirmed by string scan: only `rekordbox.exe` contains
|
||||
`USBANLZ`, `PIONEER`, `djmd…` table names, `.pdb`. It's a JUCE C++ app.
|
||||
- `export.pdb` is written by rekordbox's **bundled DeviceSQL "Page Manager"**
|
||||
engine — functions prefixed `dsql*` / `edb_dyn_*` (error strings:
|
||||
`dsqlOpenDeviceDB`, `edb_dyn_CriticalSectionOn ERROR`,
|
||||
`Page Manager (Close/)Open returns ERROR`).
|
||||
- **Key consequence:** the page-level "magic numbers" maple guesses
|
||||
(page flags `0x64`/`0x44`, `0x1fff` markers, `0x3ec`, free/used sizes) are
|
||||
**DeviceSQL internals**, not rekordbox-specific. They are NOT immediate
|
||||
constants in the code — they're produced by the page-manager logic, so they
|
||||
must be read out of the `dsql*`/`edb_dyn_*` functions, not grep'd.
|
||||
|
||||
### A rekordbox USB export writes three databases
|
||||
| file | format | writer |
|
||||
|------|--------|--------|
|
||||
| `export.pdb` | DeviceSQL (CDJ reads this) | DeviceSQL Page Manager |
|
||||
| `exportExt.pdb` | DeviceSQL extended | same |
|
||||
| `exportLibrary.db` | **SQLite** (rekordbox's own) | bundled `sqlite3_*` |
|
||||
| `USBANLZ/**/ANLZ*.DAT/.EXT/.2EX` | per-track waveform/beatgrid | main app |
|
||||
|
||||
### `export.pdb` serializer call stack (live `WriteFile` backtrace → Ghidra)
|
||||
Ghidra static address = `0x140000000 + module_offset` (Ghidra image base is
|
||||
`0x140000000`; the ASLR runtime base varies per launch — never hardcode it,
|
||||
read `module+offset` from Frida).
|
||||
|
||||
```
|
||||
0x1420e8804 calls WriteFile ─┐
|
||||
0x1420d7358 │ DeviceSQL write plumbing
|
||||
0x1420d6e74 │ (transactions, page I/O)
|
||||
0x1420d62a4 │
|
||||
0x1420eac94 │
|
||||
0x1420e07d8 │
|
||||
0x1420de140 ─┘
|
||||
FUN_141d75c20 ← dsqlOpenDeviceDB / Page Manager (export.pdb-specific)
|
||||
0x141cad100
|
||||
0x141cac390
|
||||
0x141b8b4c0
|
||||
0x141aca850
|
||||
0x1423c9ab0 ← outer export driver
|
||||
```
|
||||
Other anchors found by string xref: `FUN_14119d2d0` (export orchestrator,
|
||||
builds `/PIONEER/rekordbox/export.pdb`, walks "Contents"), `FUN_140f37e50`
|
||||
(builds `PIONEER`/`USBANLZ` paths).
|
||||
|
||||
### Gotchas
|
||||
- **Incremental writes:** rekordbox rewrites only the *changed* pages (seeked
|
||||
`WriteFile`s), so a Frida capture is partial. The complete file is on the USB
|
||||
(`F:\PIONEER\rekordbox\export.pdb`) — copy it from there for a full diff.
|
||||
- **CIG / injection:** `rekordboxAgent.exe` refuses live Frida injection
|
||||
(Code Integrity Guard). The pdb writer (main `rekordbox.exe`) is hookable, but
|
||||
only via **child-gating at spawn** — Windows has **no spawn-gating**.
|
||||
|
||||
## Tooling
|
||||
|
||||
### Static analysis (Ghidra, headless, in WSL)
|
||||
- JDK 21 (`~/jdk/jdk-21.0.11+10`), Ghidra 12.1.2. Ghidra rejects JDK 25 — use 21.
|
||||
- Analyzed project: `~/ghidra_proj` (project `rbx`, program `rekordbox.exe`).
|
||||
Full analysis ≈ 1 h with `GHIDRA_HEADLESS_MAXMEM=10G -max-cpu 8`.
|
||||
- Re-run a script without re-analyzing:
|
||||
```bash
|
||||
"$GHIDRA/support/analyzeHeadless" ~/ghidra_proj rbx -process rekordbox.exe \
|
||||
-noanalysis -scriptPath ~/ghidra_scripts -postScript FindPdb.java
|
||||
```
|
||||
Scripts: `~/ghidra_scripts/FindPdb.java` (string+constant xref search),
|
||||
`DecPdb.java` (decompile an address list), `CallTree.java` (call-tree drill).
|
||||
|
||||
### Dynamic capture (Frida, on Windows)
|
||||
- `tools/rbx_pdb_capture.js` — Frida agent. Hooks `NtCreateFile`/`NtWriteFile`/
|
||||
`NtClose` + `NtCreateSection`/`NtMapViewOfSection` (memory-mapped writes).
|
||||
Reconstructs files under `PIONEER`/`*.pdb` to `captures/`, prints each write's
|
||||
backtrace as `rekordbox.exe+0xOFFSET` (→ Ghidra `0x140000000 + offset`).
|
||||
- `tools/capture.mjs` — Node host (`npm i frida`). Attaches to all rekordbox
|
||||
processes, streams `[pid]`-tagged output live, stays alive, **child-gates** so
|
||||
the spawn writer is hooked at birth. Clears `captures/` on start.
|
||||
```powershell
|
||||
npm i frida # once
|
||||
node tools\capture.mjs --spawn # launch rekordbox under the gate, then Export to USB
|
||||
```
|
||||
|
||||
## Diffing maple's output vs rekordbox
|
||||
`compare_pdb.py` parses two pdbs with the kaitai schema and summarises tables/
|
||||
rows. Reference real exports: `data/PIONEER/rekordbox/export.pdb`,
|
||||
`data/acemo/PIONEER/rekordbox/export.pdb`.
|
||||
|
||||
## Status / next steps
|
||||
- Writer fully located; capture + diff pipeline works.
|
||||
- To decode a specific page magic number: search the `dsql*`/`edb_dyn_*`
|
||||
functions (image region `0x142000000+`) for the page-header builder.
|
||||
- To fix a rejected export: diff a complete USB `export.pdb` against maple's
|
||||
output field-by-field; resolve any wrong value via the matching `dsql` function.
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
{
|
||||
"name": "maple-py",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"frida": "^17.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/frida": {
|
||||
"version": "17.11.0",
|
||||
"resolved": "https://registry.npmjs.org/frida/-/frida-17.11.0.tgz",
|
||||
"integrity": "sha512-ZymNV1mr8lWMP2vo1Ed4D7w1/8VQRVeoZ439sCZN/vKjj/E0UhfQP4BYsRAUrCQZ4nBzfRNN4BouA+WvOZ1z5w==",
|
||||
"hasInstallScript": true,
|
||||
"license": "LGPL-2.0 WITH WxWindows-exception-3.1",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"prebuild-install": "^7.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.92.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
|
||||
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
|
||||
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"frida": "^17.11.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# re/ — rekordbox `export.pdb` reverse-engineering
|
||||
|
||||
Shared record of the Ghidra/Frida work behind maple's pdb writer. See the
|
||||
top-level `README.md` for the high-level findings; this dir holds the raw
|
||||
artifacts so others can reproduce and extend the analysis.
|
||||
|
||||
## What's here
|
||||
|
||||
`scripts/` — Ghidra headless scripts (run against an analyzed `rekordbox.exe`):
|
||||
- `FindPdb.java` — locate the writer via string + magic-constant xrefs
|
||||
- `DecPdb.java` — decompile an explicit list of addresses
|
||||
- `CallTree.java` — walk a call tree, flag file-I/O / 4096-using functions
|
||||
- `FindHash.java` — hunt the per-track hash: decompile `formatForSearch` + callers + content-string xrefs
|
||||
|
||||
`findings/` — script output (decompiled C + xref maps):
|
||||
- `findpdb.txt` — string xrefs → the export functions; magic-constant hit counts
|
||||
- `decpdb.txt` — decompiled `export.pdb` serializer call stack (DeviceSQL Page Manager)
|
||||
- `calltree.txt` — call tree under the export orchestrator `FUN_14119d2d0`
|
||||
- `hash.txt` — `formatForSearch` + callers + `djmdContent`/`ContentID` xrefs
|
||||
|
||||
## Reproduce (not committed: the binary + Ghidra project)
|
||||
|
||||
`rekordbox.exe` and the analyzed Ghidra project are **not** in the repo
|
||||
(copyright + size). To rebuild from your own rekordbox install (Windows path
|
||||
visible from WSL):
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=~/jdk/jdk-21.0.11+10 # Ghidra needs JDK 21
|
||||
GH=/path/to/ghidra_12.1.2_PUBLIC
|
||||
# one-time: import + analyze (~1h, 10G heap, 8 cores)
|
||||
GHIDRA_HEADLESS_MAXMEM=10G "$GH/support/analyzeHeadless" ~/ghidra_proj rbx \
|
||||
-import "/mnt/c/Program Files/rekordbox/rekordbox 7.0.5/rekordbox.exe" \
|
||||
-overwrite -max-cpu 8 -analysisTimeoutPerFile 7200
|
||||
# then run any script without re-analyzing:
|
||||
"$GH/support/analyzeHeadless" ~/ghidra_proj rbx -process rekordbox.exe -noanalysis \
|
||||
-scriptPath re/scripts -postScript FindPdb.java
|
||||
```
|
||||
|
||||
Runtime→Ghidra address map: `static = 0x140000000 + module_offset` (Ghidra image
|
||||
base `0x140000000`; ASLR runtime base varies — read `rekordbox.exe+0xOFFSET` from
|
||||
the Frida agent, never hardcode it).
|
||||
|
||||
## Live capture (Frida, Windows)
|
||||
|
||||
`../tools/capture.mjs` (Node, `npm i frida`) + `../tools/rbx_pdb_capture.js`
|
||||
(agent). Child-gates so the CIG-protected writer is hooked at spawn:
|
||||
`node tools/capture.mjs --spawn`.
|
||||
|
||||
## Open questions
|
||||
|
||||
- per-track `0x14` (28-bit) — varies per track, not a plain crc32; likely a
|
||||
stored `djmdContent` value. `0x18`/`0x1a` are a per-export constant.
|
||||
- `index_shift` (row `0x02`) — positional (`~0x20`/row, resets per page) but the
|
||||
exact formula isn't pinned. maple writes `0`; rekordbox doesn't.
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,85 @@
|
||||
// Walk the call tree down from the export orchestrator and surface the page
|
||||
// serializer: flag functions that call file-I/O APIs or use the 4096 page size,
|
||||
// then decompile those candidates. Output -> ~/ghidra_proj/calltree.txt
|
||||
import java.io.PrintWriter;
|
||||
import java.util.*;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.app.decompiler.*;
|
||||
import ghidra.program.model.listing.*;
|
||||
import ghidra.program.model.scalar.Scalar;
|
||||
|
||||
public class CallTree extends GhidraScript {
|
||||
static final String ROOT = "0x14119d2d0";
|
||||
static final int MAX_DEPTH = 4;
|
||||
|
||||
boolean hasPageConst(Function f) {
|
||||
InstructionIterator it = currentProgram.getListing().getInstructions(f.getBody(), true);
|
||||
while (it.hasNext()) {
|
||||
Instruction insn = it.next();
|
||||
for (int op = 0; op < insn.getNumOperands(); op++)
|
||||
for (Object o : insn.getOpObjects(op))
|
||||
if (o instanceof Scalar) {
|
||||
long v = ((Scalar) o).getUnsignedValue();
|
||||
if (v == 0x1000L || v == 4096L) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String ioCalls(Function f) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Function c : f.getCalledFunctions(monitor)) {
|
||||
String n = c.getName().toLowerCase();
|
||||
if (n.contains("write") || n.contains("createfile") || n.contains("fopen")
|
||||
|| n.contains("fwrite") || n.contains("mapviewof") || n.contains("setfilepointer")
|
||||
|| n.contains("flushview") || n.contains("fclose"))
|
||||
sb.append(c.getName()).append(" ");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
PrintWriter out = new PrintWriter("/home/ntr/ghidra_proj/calltree.txt");
|
||||
FunctionManager fm = currentProgram.getFunctionManager();
|
||||
Function root = fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(ROOT));
|
||||
|
||||
Map<Function, Integer> depth = new LinkedHashMap<>();
|
||||
Deque<Function> q = new ArrayDeque<>();
|
||||
depth.put(root, 0); q.add(root);
|
||||
while (!q.isEmpty() && !monitor.isCancelled()) {
|
||||
Function f = q.poll();
|
||||
int d = depth.get(f);
|
||||
if (d >= MAX_DEPTH) continue;
|
||||
for (Function c : f.getCalledFunctions(monitor))
|
||||
if (!depth.containsKey(c)) { depth.put(c, d + 1); q.add(c); }
|
||||
}
|
||||
out.println("call tree from " + root.getName() + " depth<=" + MAX_DEPTH + ": " + depth.size() + " funcs\n");
|
||||
|
||||
List<Function> candidates = new ArrayList<>();
|
||||
for (Map.Entry<Function, Integer> e : depth.entrySet()) {
|
||||
Function f = e.getKey();
|
||||
boolean page = hasPageConst(f);
|
||||
String io = ioCalls(f);
|
||||
String flags = (page ? "[4096]" : "") + (io.isEmpty() ? "" : "[io:" + io.trim() + "]");
|
||||
out.println(String.format("d%d %-12s size=%-6d %s %s",
|
||||
e.getValue(), f.getEntryPoint(), f.getBody().getNumAddresses(), f.getName(), flags));
|
||||
if (page || !io.isEmpty()) candidates.add(f);
|
||||
}
|
||||
|
||||
DecompInterface di = new DecompInterface();
|
||||
di.openProgram(currentProgram);
|
||||
out.println("\n===== CANDIDATE DECOMPILES (" + candidates.size() + ") =====");
|
||||
for (Function f : candidates) {
|
||||
if (monitor.isCancelled()) break;
|
||||
out.println("\n//===== " + f.getName() + " @ " + f.getEntryPoint() + " =====");
|
||||
DecompileResults r = di.decompileFunction(f, 60, monitor);
|
||||
if (r != null && r.decompileCompleted())
|
||||
out.println(r.getDecompiledFunction().getC());
|
||||
else
|
||||
out.println("// decompile failed");
|
||||
}
|
||||
out.flush(); out.close();
|
||||
println("wrote calltree.txt: " + depth.size() + " funcs, " + candidates.size() + " candidates");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Decompile the pdb export functions found via string xrefs.
|
||||
import java.io.PrintWriter;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.app.decompiler.*;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
|
||||
public class DecPdb extends GhidraScript {
|
||||
// export.pdb serializer call stack from a live Frida WriteFile backtrace.
|
||||
// module+offset -> Ghidra static = 0x140000000 + offset (base confirmed).
|
||||
static final String[] ADDRS = {
|
||||
"0x1420e883e", // calls WriteFile
|
||||
"0x1420d73a6",
|
||||
"0x1420d6e92",
|
||||
"0x1420d6368",
|
||||
"0x1420eae07",
|
||||
"0x1420e0852",
|
||||
"0x1420de1c4",
|
||||
"0x141d75d21", // export.pdb-specific frame
|
||||
"0x141cad188",
|
||||
"0x141cac585",
|
||||
"0x141b8ba75",
|
||||
"0x141acaf8c",
|
||||
"0x1423c9b3b",
|
||||
};
|
||||
@Override public void run() throws Exception {
|
||||
PrintWriter out = new PrintWriter("/home/ntr/ghidra_proj/decpdb.txt");
|
||||
DecompInterface di = new DecompInterface();
|
||||
di.openProgram(currentProgram);
|
||||
for (String a : ADDRS) {
|
||||
Address addr = currentProgram.getAddressFactory().getAddress(a);
|
||||
Function f = currentProgram.getFunctionManager().getFunctionContaining(addr);
|
||||
if (f == null) { out.println("// no function at " + a); continue; }
|
||||
out.println("\n//================ " + f.getName() + " @ " + f.getEntryPoint() + " ================");
|
||||
DecompileResults r = di.decompileFunction(f, 90, monitor);
|
||||
if (r != null && r.decompileCompleted())
|
||||
out.println(r.getDecompiledFunction().getC());
|
||||
else
|
||||
out.println("// decompile failed: " + (r != null ? r.getErrorMessage() : "null"));
|
||||
}
|
||||
out.flush(); out.close();
|
||||
println("wrote decpdb.txt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Hunt for the track-row builder / the per-track 8-byte hash (row offsets
|
||||
// 0x14 u4, 0x18 u2, 0x1a u2) and index_shift (0x02). Decompiles the string-hash
|
||||
// function formatForSearch + its callers, and dumps xrefs of track/content/
|
||||
// search-related strings. Output -> ~/ghidra_proj/hash.txt
|
||||
import java.io.PrintWriter;
|
||||
import java.util.*;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.app.decompiler.*;
|
||||
import ghidra.program.model.listing.*;
|
||||
import ghidra.program.model.symbol.*;
|
||||
|
||||
public class FindHash extends GhidraScript {
|
||||
PrintWriter out;
|
||||
DecompInterface di;
|
||||
Set<String> done = new HashSet<>();
|
||||
FunctionManager fm;
|
||||
ReferenceManager rm;
|
||||
|
||||
void dec(Function f, String why) {
|
||||
if (f == null) return;
|
||||
if (!done.add(f.getEntryPoint().toString())) return;
|
||||
out.println("\n//===== " + f.getName() + " @ " + f.getEntryPoint() + " [" + why + "] =====");
|
||||
DecompileResults r = di.decompileFunction(f, 60, monitor);
|
||||
out.println(r != null && r.decompileCompleted() ? r.getDecompiledFunction().getC() : "// decompile failed");
|
||||
}
|
||||
|
||||
Function fn(String a) {
|
||||
return fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(a));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
out = new PrintWriter("/home/ntr/ghidra_proj/hash.txt");
|
||||
di = new DecompInterface(); di.openProgram(currentProgram);
|
||||
fm = currentProgram.getFunctionManager();
|
||||
rm = currentProgram.getReferenceManager();
|
||||
|
||||
// 1. formatForSearch (string hash) + its callers
|
||||
Function fmt = fn("0x141a1afc0");
|
||||
dec(fmt, "formatForSearch");
|
||||
if (fmt != null) {
|
||||
int n = 0;
|
||||
for (Reference ref : rm.getReferencesTo(fmt.getEntryPoint())) {
|
||||
if (n++ >= 10) break;
|
||||
dec(fm.getFunctionContaining(ref.getFromAddress()), "caller of formatForSearch");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. strings that hint at the track/content row builder -> xref functions
|
||||
String[] kw = {"djmdContent", "SearchStr", "formatForSearch", "AnalysisDataPath",
|
||||
"ContentID", "rb_data_status", "rb_local", "dsqlInsert", "AddTrack"};
|
||||
DataIterator dit = currentProgram.getListing().getDefinedData(true);
|
||||
int shown = 0;
|
||||
while (dit.hasNext() && !monitor.isCancelled()) {
|
||||
Data d = dit.next();
|
||||
if (d == null || !d.hasStringValue()) continue;
|
||||
Object v = d.getValue();
|
||||
if (!(v instanceof String)) continue;
|
||||
String s = (String) v;
|
||||
for (String k : kw) {
|
||||
if (s.contains(k)) {
|
||||
out.println("\n// STRING @" + d.getAddress() + " : " + s);
|
||||
int n = 0;
|
||||
for (Reference ref : rm.getReferencesTo(d.getAddress())) {
|
||||
if (n++ >= 5) break;
|
||||
Function c = fm.getFunctionContaining(ref.getFromAddress());
|
||||
out.println("// xref in " + (c != null ? c.getName() + " @" + c.getEntryPoint() : ref.getFromAddress()));
|
||||
}
|
||||
shown++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shown >= 30) break;
|
||||
}
|
||||
out.flush(); out.close();
|
||||
println("wrote hash.txt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Locate the rekordbox .pdb serializer via known strings + magic-number constants.
|
||||
// Run headless: analyzeHeadless <proj> rbx -process rekordbox.exe -noanalysis \
|
||||
// -scriptPath ~/ghidra_scripts -postScript FindPdb.java
|
||||
// Output goes to ~/ghidra_proj/findpdb.txt (and the log).
|
||||
import java.io.PrintWriter;
|
||||
import java.util.*;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.*;
|
||||
import ghidra.program.model.scalar.Scalar;
|
||||
import ghidra.program.model.symbol.*;
|
||||
|
||||
public class FindPdb extends GhidraScript {
|
||||
|
||||
// Strings that mark the export code paths.
|
||||
static final String[] KEYWORDS = {
|
||||
"export.pdb", "exportExt.pdb", "USBANLZ", "PIONEER",
|
||||
"ANLZ", "HOT CUE BANK", "ORIGINAL ARTIST", "rekordbox/export"
|
||||
};
|
||||
|
||||
// Magic numbers maple currently guesses (various widths/combinations).
|
||||
static final long[] MAGICS = {
|
||||
42343L, 54995L, // 0xA567 / 0xD6D3 per-track markers
|
||||
0xD6D3A567L, 0xA567D6D3L, // combined 32-bit forms
|
||||
788224L, // 0x000C0700 track bitmask
|
||||
1004L, // 0x03EC "strange page" marker
|
||||
0x64L, 0x44L, 0x24L, // page-flag bytes
|
||||
19048L, 30967L // the schema author's original guesses
|
||||
};
|
||||
|
||||
PrintWriter out;
|
||||
|
||||
void emit(String s) { println(s); if (out != null) out.println(s); }
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
out = new PrintWriter("/home/ntr/ghidra_proj/findpdb.txt");
|
||||
Listing listing = currentProgram.getListing();
|
||||
ReferenceManager rm = currentProgram.getReferenceManager();
|
||||
FunctionManager fm = currentProgram.getFunctionManager();
|
||||
|
||||
emit("===== STRING XREFS =====");
|
||||
DataIterator di = listing.getDefinedData(true);
|
||||
while (di.hasNext() && !monitor.isCancelled()) {
|
||||
Data d = di.next();
|
||||
if (d == null || !d.hasStringValue()) continue;
|
||||
Object val = d.getValue();
|
||||
if (!(val instanceof String)) continue;
|
||||
String s = (String) val;
|
||||
for (String kw : KEYWORDS) {
|
||||
if (s.contains(kw)) {
|
||||
emit("\nSTRING @ " + d.getAddress() + " : " + s.replace("\n", "\\n"));
|
||||
ReferenceIterator ri = rm.getReferencesTo(d.getAddress());
|
||||
int n = 0;
|
||||
while (ri.hasNext() && n++ < 25) {
|
||||
Reference r = ri.next();
|
||||
Function f = fm.getFunctionContaining(r.getFromAddress());
|
||||
emit(" xref from " + r.getFromAddress()
|
||||
+ (f != null ? " in " + f.getName() + " @ " + f.getEntryPoint() : " (no func)"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit("\n===== CONSTANT XREFS =====");
|
||||
Set<Long> targets = new HashSet<>();
|
||||
for (long m : MAGICS) targets.add(m);
|
||||
Map<Long, Integer> counts = new TreeMap<>();
|
||||
InstructionIterator ii = listing.getInstructions(true);
|
||||
while (ii.hasNext() && !monitor.isCancelled()) {
|
||||
Instruction insn = ii.next();
|
||||
for (int op = 0; op < insn.getNumOperands(); op++) {
|
||||
for (Object o : insn.getOpObjects(op)) {
|
||||
if (o instanceof Scalar) {
|
||||
long v = ((Scalar) o).getUnsignedValue();
|
||||
if (targets.contains(v)) {
|
||||
Function f = fm.getFunctionContaining(insn.getAddress());
|
||||
emit(String.format("CONST 0x%X (%d) @ %s %s in %s", v, v,
|
||||
insn.getAddress(), insn.toString(),
|
||||
f != null ? f.getName() + " @ " + f.getEntryPoint() : "(no func)"));
|
||||
counts.merge(v, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
emit("\n===== CONSTANT HIT COUNTS =====");
|
||||
for (Map.Entry<Long, Integer> e : counts.entrySet())
|
||||
emit(String.format("0x%X (%d): %d hits", e.getKey(), e.getKey(), e.getValue()));
|
||||
|
||||
out.flush();
|
||||
out.close();
|
||||
emit("\nwrote /home/ntr/ghidra_proj/findpdb.txt");
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@ class Color:
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Key:
|
||||
id: int
|
||||
name: str
|
||||
id2: int | None = None # mirrors id in real exports
|
||||
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
id: int
|
||||
@@ -164,3 +171,4 @@ class Database:
|
||||
artwork: list[Artwork] = field(default_factory=list)
|
||||
genres: list[Genre] = field(default_factory=list)
|
||||
colors: list[Color] = field(default_factory=list)
|
||||
keys: list[Key] = field(default_factory=list)
|
||||
@@ -41,11 +41,12 @@ def build_strange_page(page_index: int, page_type: int, next_page: int) -> bytes
|
||||
|
||||
|
||||
class PageBuilder:
|
||||
def __init__(self, page_index: int, page_type: int, max_rows: int = 0):
|
||||
def __init__(self, page_index: int, page_type: int, max_rows: int = 0, data_flag: int = 0x24):
|
||||
self.page_index = page_index
|
||||
self.page_type = page_type
|
||||
self.next_page = page_index # caller patches before finalize
|
||||
self.max_rows = max_rows
|
||||
self.data_flag = data_flag # page_flags for a populated data page
|
||||
self.row_bytes: list[bytes] = []
|
||||
self.row_offsets: list[int] = [] # relative to heap_pos
|
||||
self.heap_cursor = 0 # position relative to heap_pos
|
||||
@@ -76,11 +77,11 @@ class PageBuilder:
|
||||
if free_size < 0:
|
||||
raise ValueError("page overflow")
|
||||
|
||||
# 0x00 for data pages with rows, 0x44 for empty ones. The maple-rs
|
||||
# writer uses this pair and produces pdbs that load on the player;
|
||||
# rekordbox-exported references use 0x24 which also works, so both
|
||||
# are valid -- pick the simpler form.
|
||||
page_flags = 0x00 if n > 0 else 0x44
|
||||
# Data pages with rows: rekordbox uses 0x24 (and 0x34 for some tables --
|
||||
# see writer's PAGE_FLAG_34). maple previously wrote 0x00, which is
|
||||
# missing the 0x20|0x04 bits every real rekordbox data page sets, and
|
||||
# the players reject it. Empty data pages stay 0x44.
|
||||
page_flags = self.data_flag if n > 0 else 0x44
|
||||
|
||||
header = b""
|
||||
header += pack("<I", 0) # magic
|
||||
|
||||
@@ -169,6 +169,13 @@ def serialize_genre(g: Genre) -> bytes:
|
||||
return _pad2(row)
|
||||
|
||||
|
||||
def serialize_key(key_id: int, name: str, id2: int | None = None) -> bytes:
|
||||
"""key_row: u4 id, u4 id2 (mirrors id in the reference), devicesql name."""
|
||||
if id2 is None:
|
||||
id2 = key_id
|
||||
return _pad2(pack("<II", key_id, id2) + encode_devicesql(name))
|
||||
|
||||
|
||||
def serialize_history_playlist(playlist_id: int, name: str) -> bytes:
|
||||
"""history_playlist_row: u4 id, devicesql name."""
|
||||
return _pad2(pack("<I", playlist_id) + encode_devicesql(name))
|
||||
|
||||
@@ -18,8 +18,7 @@ from maple.pdb.rows import (
|
||||
serialize_column,
|
||||
serialize_genre,
|
||||
serialize_history_blob,
|
||||
serialize_history_entry,
|
||||
serialize_history_playlist,
|
||||
serialize_key,
|
||||
serialize_playlist_entry,
|
||||
serialize_playlist_tree,
|
||||
serialize_track,
|
||||
@@ -52,48 +51,19 @@ FILE_HEADER_SIZE = 28 # 0,page_size,num_tables,next_unused,unknown,sequence,gap
|
||||
TABLE_ENTRY_SIZE = 16 # page_type,empty_candidate,first_page,last_page = 4*u4
|
||||
|
||||
|
||||
def _build_chain(page_type: int, row_iter, start_index: int) -> tuple[list[bytes], int, int]:
|
||||
"""Return (page_bytes_list, first_page, last_page).
|
||||
# Page types whose populated data pages rekordbox tags 0x34 (vs the 0x24 the
|
||||
# rest get). Matched byte-for-byte against a real rekordbox export.
|
||||
PAGE_FLAG_34 = frozenset({0, 2, 3, 13, 17, 19}) # tracks, artists, albums, artwork, browse_filter_index, history
|
||||
|
||||
Every chain begins with a "strange" placeholder page (flags=0x64)
|
||||
mirroring the reference; data pages with rows follow. Tables with no
|
||||
rows emit only the strange page.
|
||||
"""
|
||||
rows = list(row_iter)
|
||||
out: list[bytes] = []
|
||||
|
||||
if not rows:
|
||||
out.append(build_strange_page(start_index, page_type, next_page=start_index))
|
||||
return out, start_index, start_index
|
||||
|
||||
data_pages: list[PageBuilder] = []
|
||||
page_idx = start_index + 1
|
||||
current = PageBuilder(page_idx, page_type)
|
||||
for row in rows:
|
||||
if not current.try_add(row):
|
||||
data_pages.append(current)
|
||||
page_idx += 1
|
||||
current = PageBuilder(page_idx, page_type)
|
||||
if not current.try_add(row):
|
||||
raise ValueError(f"row of size {len(row)} too large for an empty page")
|
||||
data_pages.append(current)
|
||||
|
||||
out.append(build_strange_page(start_index, page_type, next_page=start_index + 1))
|
||||
for i, p in enumerate(data_pages):
|
||||
if i == len(data_pages) - 1:
|
||||
p.next_page = p.page_index
|
||||
else:
|
||||
p.next_page = p.page_index + 1
|
||||
out.append(p.finalize())
|
||||
|
||||
return out, start_index, page_idx
|
||||
# "Core" tables always get a real data page (even when empty). The rest stay
|
||||
# strange-only when empty. Matched against a real rekordbox export.
|
||||
CORE_TABLES = frozenset({0, 1, 2, 3, 5, 6, 13, 16, 17, 18, 19})
|
||||
|
||||
|
||||
def write_pdb(db: Database, path: str | Path) -> None:
|
||||
# Layout: page 0 is the file header page. Data pages begin at page_index 1.
|
||||
chains: list[tuple[int, list[bytes], int, int]] = [] # (page_type, pages, first, last)
|
||||
next_page_idx = 1
|
||||
|
||||
# Layout (matching rekordbox): page 0 is the file header. Pages 1..2*N are
|
||||
# reserved 2-per-table -- strange page at the odd index 1+2k, first data page
|
||||
# at the even index 2+2k. Overflow data pages come from the pool at 2*N+1.
|
||||
serialized_tracks = [serialize_track(t) for t in db.tracks]
|
||||
serialized_artists = [serialize_artist(a) for a in db.artists]
|
||||
serialized_albums = [serialize_album(a) for a in db.albums]
|
||||
@@ -106,18 +76,19 @@ def write_pdb(db: Database, path: str | Path) -> None:
|
||||
# so the player's Color filter has a non-empty value set.
|
||||
colors = db.colors if db.colors else STANDARD_COLORS
|
||||
serialized_colors = [serialize_color(c) for c in colors]
|
||||
serialized_keys = [serialize_key(k.id, k.name, k.id2) for k in db.keys]
|
||||
# unknown_17/unknown_18 row format isn't decoded by the kaitai schema;
|
||||
# we emit the reference's byte-for-byte rows (four little-endian u2s).
|
||||
serialized_unknown_17 = [pack("<HHHH", *r) for r in UNKNOWN_17_ROWS]
|
||||
serialized_unknown_18 = [pack("<HHHH", *r) for r in UNKNOWN_18_ROWS]
|
||||
|
||||
# Both working reference pdbs have at least one history_* row each.
|
||||
# Emit a single dummy session so the player sees the export as
|
||||
# "complete". Date pulled from any track if available.
|
||||
# A real rekordbox export leaves history_playlists and history_entries
|
||||
# EMPTY (only the `history` table itself carries a row). maple used to
|
||||
# fabricate a dummy "HISTORY 001" playlist + entry referencing a track,
|
||||
# which the player doesn't expect -- drop them and match the reference.
|
||||
today = db.tracks[0].date_added if db.tracks else "2026-01-01"
|
||||
first_track_id = db.tracks[0].id if db.tracks else 1
|
||||
serialized_history_playlists = [serialize_history_playlist(1, "HISTORY 001")]
|
||||
serialized_history_entries = [serialize_history_entry(first_track_id, 1, 1)]
|
||||
serialized_history_playlists: list[bytes] = []
|
||||
serialized_history_entries: list[bytes] = []
|
||||
serialized_history = [serialize_history_blob(today)]
|
||||
|
||||
rows_by_type: dict[int, list[bytes]] = {
|
||||
@@ -125,6 +96,7 @@ def write_pdb(db: Database, path: str | Path) -> None:
|
||||
PAGE_TYPE_GENRES: serialized_genres,
|
||||
PAGE_TYPE_ARTISTS: serialized_artists,
|
||||
PAGE_TYPE_ALBUMS: serialized_albums,
|
||||
PAGE_TYPE_KEYS: serialized_keys,
|
||||
PAGE_TYPE_COLORS: serialized_colors,
|
||||
PAGE_TYPE_PLAYLIST_TREE: serialized_pl_tree,
|
||||
PAGE_TYPE_PLAYLIST_ENTRIES: serialized_pl_entries,
|
||||
@@ -136,15 +108,40 @@ def write_pdb(db: Database, path: str | Path) -> None:
|
||||
PAGE_TYPE_HISTORY_ENTRIES: serialized_history_entries,
|
||||
PAGE_TYPE_HISTORY: serialized_history,
|
||||
}
|
||||
# Emit all 20 page-type chains; tables we don't populate get a single empty page.
|
||||
for ptype in ALL_PAGE_TYPES:
|
||||
rows = rows_by_type.get(ptype, [])
|
||||
pages, first, last = _build_chain(ptype, rows, next_page_idx)
|
||||
chains.append((ptype, pages, first, last))
|
||||
next_page_idx = last + 1
|
||||
num_tables = len(ALL_PAGE_TYPES) # 20
|
||||
overflow_next = 1 + 2 * num_tables # first overflow page (41)
|
||||
pages_by_index: dict[int, bytes] = {} # page_index -> 4096-byte page
|
||||
table_dir: list[tuple[int, int, int]] = [] # (page_type, first_page, last_page)
|
||||
|
||||
num_tables = len(chains)
|
||||
next_unused_page = next_page_idx
|
||||
for k, ptype in enumerate(ALL_PAGE_TYPES):
|
||||
strange_idx = 1 + 2 * k
|
||||
data_idx = 2 + 2 * k
|
||||
rows = rows_by_type.get(ptype, [])
|
||||
data_flag = 0x34 if ptype in PAGE_FLAG_34 else 0x24
|
||||
|
||||
# Optional table with no rows: strange page only (its even page stays blank).
|
||||
if not rows and ptype not in CORE_TABLES:
|
||||
pages_by_index[strange_idx] = build_strange_page(strange_idx, ptype, next_page=strange_idx)
|
||||
table_dir.append((ptype, strange_idx, strange_idx))
|
||||
continue
|
||||
|
||||
# First data page at the reserved even index; overflow pages from the pool.
|
||||
builders: list[PageBuilder] = [PageBuilder(data_idx, ptype, data_flag=data_flag)]
|
||||
for row in rows:
|
||||
if not builders[-1].try_add(row):
|
||||
nb = PageBuilder(overflow_next, ptype, data_flag=data_flag)
|
||||
overflow_next += 1
|
||||
if not nb.try_add(row):
|
||||
raise ValueError(f"row of size {len(row)} too large for an empty page")
|
||||
builders.append(nb)
|
||||
|
||||
pages_by_index[strange_idx] = build_strange_page(strange_idx, ptype, next_page=data_idx)
|
||||
for i, b in enumerate(builders):
|
||||
b.next_page = b.page_index if i == len(builders) - 1 else builders[i + 1].page_index
|
||||
pages_by_index[b.page_index] = b.finalize()
|
||||
table_dir.append((ptype, strange_idx, builders[-1].page_index))
|
||||
|
||||
next_unused_page = max(overflow_next, 1 + 2 * num_tables)
|
||||
|
||||
file_header = b""
|
||||
file_header += pack("<I", 0) # magic
|
||||
@@ -157,7 +154,7 @@ def write_pdb(db: Database, path: str | Path) -> None:
|
||||
assert len(file_header) == FILE_HEADER_SIZE
|
||||
|
||||
tables_block = b""
|
||||
for ptype, _pages, first, last in chains:
|
||||
for ptype, first, last in table_dir:
|
||||
tables_block += pack("<I", ptype)
|
||||
tables_block += pack("<I", 0) # empty_candidate
|
||||
tables_block += pack("<I", first)
|
||||
@@ -170,11 +167,12 @@ def write_pdb(db: Database, path: str | Path) -> None:
|
||||
raise ValueError("too many tables for one page")
|
||||
page0 = header_full + b"\x00" * pad_to_page
|
||||
|
||||
blank = b"\x00" * PAGE_SIZE
|
||||
out_path = Path(path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_path.open("wb") as f:
|
||||
f.write(page0)
|
||||
for _ptype, pages, _first, _last in chains:
|
||||
for p in pages:
|
||||
assert len(p) == PAGE_SIZE
|
||||
f.write(p)
|
||||
for idx in range(1, next_unused_page): # reserved-but-unused pages written blank
|
||||
page = pages_by_index.get(idx, blank)
|
||||
assert len(page) == PAGE_SIZE
|
||||
f.write(page)
|
||||
@@ -25,5 +25,5 @@ def test_smoke(tmp_path: Path):
|
||||
assert data[:4] == b"\x00\x00\x00\x00"
|
||||
# page_size at offset 4
|
||||
assert int.from_bytes(data[4:8], "little") == 4096
|
||||
# num_tables at offset 8
|
||||
assert int.from_bytes(data[8:12], "little") == 5
|
||||
# num_tables at offset 8 -- rekordbox always emits all 20 page-type tables
|
||||
assert int.from_bytes(data[8:12], "little") == 20
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Build a maple export.pdb from the SAME tracks as the real rekordbox AceMo
|
||||
export, so the two can be diffed and the maple pdb tested on a player against
|
||||
the audio/USBANLZ already on the USB.
|
||||
|
||||
Reads metadata out of data/acemo/PIONEER/rekordbox/export.pdb (the real export)
|
||||
and reconstructs an identical-content maple Database, then writes
|
||||
captures/maple_acemo.pdb.
|
||||
"""
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from kaitaistruct import KaitaiStream
|
||||
from kaitai.rekordbox_pdb import RekordboxPdb
|
||||
from maple.model import Album, Artist, Artwork, Database, Key, Track
|
||||
from maple.pdb.writer import write_pdb
|
||||
|
||||
REAL = ROOT / "data/acemo/PIONEER/rekordbox/export.pdb"
|
||||
OUT = ROOT / "captures/maple_acemo.pdb"
|
||||
PT = {t.value: t.name for t in RekordboxPdb.PageType}
|
||||
|
||||
|
||||
def _rows(tbl):
|
||||
out, ref = [], tbl.first_page
|
||||
while True:
|
||||
pg = ref.body
|
||||
if pg.is_data_page:
|
||||
for rg in pg.row_groups:
|
||||
for r in rg.rows:
|
||||
if r.present:
|
||||
out.append(r.body)
|
||||
if ref.index == tbl.last_page.index:
|
||||
break
|
||||
ref = pg.next_page
|
||||
return out
|
||||
|
||||
|
||||
def _txt(v):
|
||||
try:
|
||||
return v.body.text
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def main():
|
||||
pdb = RekordboxPdb(is_ext=False, _io=KaitaiStream(BytesIO(REAL.read_bytes())))
|
||||
by = {PT.get(t.type.value): _rows(t) for t in pdb.tables}
|
||||
|
||||
db = Database()
|
||||
for a in by.get("artists", []):
|
||||
db.artists.append(Artist(id=a.id, name=_txt(a.name)))
|
||||
for a in by.get("albums", []):
|
||||
db.albums.append(Album(id=a.id, name=_txt(a.name), artist_id=a.artist_id))
|
||||
for k in by.get("keys", []):
|
||||
db.keys.append(Key(id=k.id, name=_txt(k.name), id2=k.id2))
|
||||
for a in by.get("artwork", []):
|
||||
db.artwork.append(Artwork(id=a.id, path=_txt(a.path)))
|
||||
for t in by.get("tracks", []):
|
||||
db.tracks.append(Track(
|
||||
id=t.id, title=_txt(t.title), artist_id=t.artist_id, album_id=t.album_id,
|
||||
filename=_txt(t.filename), file_path=_txt(t.file_path), file_size=t.file_size,
|
||||
sample_rate=t.sample_rate, sample_depth=t.sample_depth, bitrate=t.bitrate,
|
||||
duration=t.duration, track_number=t.track_number, year=t.year,
|
||||
disc_number=t.disc_number, tempo=t.tempo, comment=_txt(t.comment),
|
||||
date_added=_txt(t.date_added), release_date=_txt(t.release_date),
|
||||
mix_name=_txt(t.mix_name), analyze_path=_txt(t.analyze_path),
|
||||
analyze_date=_txt(t.analyze_date), artwork_id=t.artwork_id, genre_id=t.genre_id))
|
||||
|
||||
write_pdb(db, OUT)
|
||||
sz = OUT.stat().st_size
|
||||
print(f"wrote {OUT} ({sz} bytes, page-mult={sz % 4096 == 0}) "
|
||||
f"tracks={len(db.tracks)} artists={len(db.artists)} albums={len(db.albums)} "
|
||||
f"keys={len(db.keys)} artwork={len(db.artwork)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,104 @@
|
||||
// Frida host (Node ESM) for the pdb-capture agent.
|
||||
// - Attaches the agent (rbx_pdb_capture.js) to rekordbox processes.
|
||||
// - Streams ALL their output to THIS console (agent lines are [pid]-tagged).
|
||||
// - STAYS ALIVE via the Node event loop -- no frida-CLI REPL/EOF problem.
|
||||
// - Uses CHILD-GATING (Windows-supported; spawn-gating is not) so children of a
|
||||
// gated process are instrumented AT BIRTH. This is how we reach the
|
||||
// rekordboxAgent that writes export.pdb: it's CIG-protected and refuses
|
||||
// post-hoc injection, but as a freshly-spawned child it can be hooked.
|
||||
// - Clears the captures/ folder on start.
|
||||
//
|
||||
// Setup (Windows, repo root, once): npm i frida
|
||||
// Run:
|
||||
// node tools\capture.mjs --spawn (RECOMMENDED: launch rekordbox under the
|
||||
// gate so every child incl. the DB agent is
|
||||
// hooked at birth. Close rekordbox first.)
|
||||
// node tools\capture.mjs (attach to running procs + gate their
|
||||
// future children; restart rekordbox to
|
||||
// respawn the agent as a gated child.)
|
||||
|
||||
import * as fridaNs from "frida";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frida = fridaNs.default ?? fridaNs;
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const REKORDBOX = "C:\\Program Files\\rekordbox\\rekordbox 7.0.5\\rekordbox.exe";
|
||||
const AGENT = fs.readFileSync(path.join(__dirname, "rbx_pdb_capture.js"), "utf8");
|
||||
const CAP = path.join(__dirname, "..", "captures");
|
||||
const SPAWN = process.argv.includes("--spawn");
|
||||
|
||||
function clearCaptures() {
|
||||
fs.mkdirSync(CAP, { recursive: true });
|
||||
let n = 0;
|
||||
for (const f of fs.readdirSync(CAP)) {
|
||||
try { fs.rmSync(path.join(CAP, f), { force: true, recursive: true }); n++; } catch (e) {}
|
||||
}
|
||||
console.log(`[host] cleared ${n} file(s) from ${CAP}`);
|
||||
}
|
||||
|
||||
function isRbx(name) { return /rekordbox/i.test(name || ""); }
|
||||
|
||||
// Attach, load the agent, and gate this process's future children too.
|
||||
async function instrument(device, pid, label) {
|
||||
try {
|
||||
const session = await device.attach(pid);
|
||||
session.detached.connect(reason => console.log(`[host] detached ${label} (${pid}): ${reason}`));
|
||||
const script = await session.createScript(AGENT);
|
||||
script.logHandler = (level, text) => console.log(text);
|
||||
script.message.connect(msg => {
|
||||
if (msg.type === "error")
|
||||
console.error(`[host] ${label} (${pid}) script error:`, msg.stack || msg.description || msg);
|
||||
});
|
||||
await script.load();
|
||||
try { await session.enableChildGating(); } catch (e) { /* not always available */ }
|
||||
console.log(`[host] instrumented ${label} (${pid})`);
|
||||
return session;
|
||||
} catch (e) {
|
||||
console.error(`[host] FAILED to instrument ${label} (${pid}): ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
clearCaptures();
|
||||
const device = await frida.getLocalDevice();
|
||||
|
||||
// Children of any gated process are suspended at birth -> instrument, resume.
|
||||
device.childAdded.connect(async child => {
|
||||
const name = child.identifier || child.path || String(child.pid);
|
||||
try {
|
||||
console.log(`[host] child spawned: ${name} (${child.pid})`);
|
||||
if (isRbx(name)) await instrument(device, child.pid, "child:" + name);
|
||||
} catch (e) {
|
||||
console.error(`[host] child handler error (${child.pid}): ${e.message}`);
|
||||
} finally {
|
||||
try { await device.resume(child.pid); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
if (SPAWN) {
|
||||
console.log(`[host] spawning ${REKORDBOX}`);
|
||||
const pid = await device.spawn([REKORDBOX]);
|
||||
await instrument(device, pid, "main"); // enables child gating on main
|
||||
await device.resume(pid);
|
||||
console.log(`[host] resumed main rekordbox (${pid}); children caught at birth`);
|
||||
} else {
|
||||
for (const p of await device.enumerateProcesses()) {
|
||||
if (isRbx(p.name)) await instrument(device, p.pid, p.name);
|
||||
}
|
||||
console.log("[host] attached to running rekordbox processes (+child gating).");
|
||||
console.log("[host] >>> the export.pdb writer (rekordboxAgent) refuses live injection.");
|
||||
console.log("[host] >>> RESTART rekordbox -- the new agent spawns as a GATED CHILD and");
|
||||
console.log("[host] >>> gets hooked at birth. Then Export and watch for [SAVED] ...export.pdb");
|
||||
console.log("[host] >>> (or re-run with --spawn to launch rekordbox under the gate.)");
|
||||
}
|
||||
|
||||
console.log("[host] ready. Ctrl-C to stop.");
|
||||
process.stdin.resume();
|
||||
process.on("SIGINT", () => { console.log("\n[host] stopping."); process.exit(0); });
|
||||
}
|
||||
|
||||
main().catch(e => { console.error("[host] fatal:", e); process.exit(1); });
|
||||
@@ -0,0 +1,209 @@
|
||||
// Capture rekordbox's .pdb writes with Frida (run on the WINDOWS host).
|
||||
//
|
||||
// Hooks at the ntdll layer (NtCreateFile / NtWriteFile / NtClose) so it catches
|
||||
// file IO no matter whether the app calls kernel32, kernelbase, or the CRT.
|
||||
// Logs EVERY file opened for writing (so you can see temp names / the real
|
||||
// filenames even if the .pdb filter is wrong), reconstructs files whose path
|
||||
// matches SAVE_RE byte-for-byte, and saves them to OUT_DIR via Frida's File API.
|
||||
// Prints a backtrace the first time each saved file is written -> the
|
||||
// module+offset of the serializer to open in Ghidra.
|
||||
//
|
||||
// Usage (Windows PowerShell, rekordbox running):
|
||||
// mkdir C:\Users\ntr\code\maple\maple-py\captures # OUT_DIR must exist
|
||||
// frida-ps | Select-String rekordbox # find all PIDs
|
||||
// frida -n rekordbox.exe -l tools\rbx_pdb_capture.js # or -p <pid>
|
||||
// then trigger "Export to USB". If you see [w-open] lines but the export file
|
||||
// isn't among them, widen SAVE_RE. If you see NOTHING, attach to another PID.
|
||||
//
|
||||
// Set VERBOSE = true to log every write-open path; false logs only SAVE_RE hits.
|
||||
|
||||
var OUT_DIR = "C:\\Users\\ntr\\code\\maple\\maple-py\\captures\\"; // must exist; Frida won't mkdir
|
||||
var SAVE_RE = /export.*\.pdb|\.pdb$|PIONEER/i; // which opened files to reconstruct + save
|
||||
var VERBOSE = true; // log ALL write-opens, not just SAVE_RE
|
||||
|
||||
var PID = Process.id;
|
||||
var TAG = "[" + PID + "] ";
|
||||
var DEBUG = true; // log every interesting open + all mapping activity
|
||||
var DEBUG_RE = /export|\.pdb|PIONEER|rekordbox|Contents|USBANLZ/i; // what DEBUG opens to print
|
||||
var ntdll = Process.getModuleByName("ntdll.dll");
|
||||
function nt(name) { return ntdll.getExportByName(name); }
|
||||
|
||||
// Resolve + confirm a hook target so we can see in the console what attached.
|
||||
function resolve(name) {
|
||||
var a = ntdll.findExportByName(name);
|
||||
console.log(TAG + "hook " + name + (a ? " -> " + a : " MISSING"));
|
||||
return a;
|
||||
}
|
||||
|
||||
|
||||
// handle (string) -> { path, pos, end, chunks:[{pos,ab}], save, traced }
|
||||
var files = {};
|
||||
var counters = {};
|
||||
|
||||
function baseName(path) {
|
||||
var parts = path.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
|
||||
return (parts[parts.length - 1] || "capture.bin").replace(/[^A-Za-z0-9._-]/g, "");
|
||||
}
|
||||
|
||||
// Read the wide path out of an OBJECT_ATTRIBUTES* (x64 layout).
|
||||
function readObjName(pObjAttr) {
|
||||
if (pObjAttr.isNull()) return null;
|
||||
var pUni = pObjAttr.add(16).readPointer(); // OBJECT_ATTRIBUTES.ObjectName
|
||||
if (pUni.isNull()) return null;
|
||||
var len = pUni.readU16(); // UNICODE_STRING.Length (bytes)
|
||||
if (!len) return null;
|
||||
var buf = pUni.add(8).readPointer(); // UNICODE_STRING.Buffer
|
||||
if (buf.isNull()) return null;
|
||||
return buf.readUtf16String(len / 2);
|
||||
}
|
||||
|
||||
// NtCreateFile / NtOpenFile: args[0]=PHANDLE out, [1]=ACCESS_MASK, [2]=OBJECT_ATTRIBUTES*
|
||||
function hookOpen(name) {
|
||||
var addr = resolve(name);
|
||||
if (!addr) return;
|
||||
Interceptor.attach(addr, {
|
||||
onEnter: function (args) {
|
||||
this.pHandle = args[0];
|
||||
this.access = args[1].toUInt32();
|
||||
this.path = readObjName(args[2]);
|
||||
},
|
||||
onLeave: function (retval) {
|
||||
if (!this.path) return;
|
||||
var WRITE = 0x40000000 /*GENERIC_WRITE*/ | 0x10000000 /*GENERIC_ALL*/ |
|
||||
0x2 /*FILE_WRITE_DATA*/ | 0x4 /*FILE_APPEND_DATA*/;
|
||||
var isWrite = (this.access & WRITE) !== 0;
|
||||
var save = SAVE_RE.test(this.path);
|
||||
// DEBUG: show every interesting open (incl. read-only) + its access mask,
|
||||
// so we can see if/how export.pdb is opened even when our filters miss it.
|
||||
if (DEBUG && DEBUG_RE.test(this.path))
|
||||
console.log(TAG + "[open] access=0x" + this.access.toString(16)
|
||||
+ (isWrite ? " W" : " r") + (save ? " *SAVE" : "") + " " + this.path);
|
||||
if (!isWrite && !save) return;
|
||||
var handleStr = this.pHandle.readPointer().toString();
|
||||
files[handleStr] = { path: this.path, pos: 0, end: 0, chunks: [], save: save, traced: false };
|
||||
if (!DEBUG && (save || VERBOSE)) console.log(TAG + "[w-open] " + this.path);
|
||||
},
|
||||
});
|
||||
}
|
||||
hookOpen("NtCreateFile");
|
||||
hookOpen("NtOpenFile");
|
||||
|
||||
// NtWriteFile: [0]=handle [5]=buffer [6]=length [7]=PLARGE_INTEGER ByteOffset
|
||||
Interceptor.attach(resolve("NtWriteFile"), {
|
||||
onEnter: function (args) {
|
||||
var f = files[args[0].toString()];
|
||||
if (!f) return;
|
||||
var n = args[6].toUInt32();
|
||||
if (n <= 0) return;
|
||||
|
||||
var pos = f.pos;
|
||||
var pOff = args[7];
|
||||
if (!pOff.isNull()) {
|
||||
var hi = pOff.add(4).readU32();
|
||||
if (hi !== 0xffffffff) pos = hi * 0x100000000 + pOff.readU32(); // skip sentinels
|
||||
}
|
||||
|
||||
if (f.save) {
|
||||
f.chunks.push({ pos: pos, ab: args[5].readByteArray(n) });
|
||||
if (!f.traced) {
|
||||
f.traced = true;
|
||||
console.log(TAG + "[trace] writer for " + f.path + ":");
|
||||
Thread.backtrace(this.context, Backtracer.ACCURATE)
|
||||
.slice(0, 14)
|
||||
.forEach(function (a) {
|
||||
// module+offset maps straight to Ghidra: static = 0x140000000 + offset
|
||||
var m = Process.findModuleByAddress(a);
|
||||
var loc = m ? (m.name + "+0x" + a.sub(m.base).toString(16)) : a.toString();
|
||||
console.log(" " + loc + " " + DebugSymbol.fromAddress(a));
|
||||
});
|
||||
}
|
||||
}
|
||||
f.pos = pos + n;
|
||||
if (f.pos > f.end) f.end = f.pos;
|
||||
},
|
||||
});
|
||||
|
||||
// Shared: write a captured buffer to OUT_DIR with a PID-tagged, de-duped name.
|
||||
function saveBuffer(path, arrayBuffer, size) {
|
||||
var name = baseName(path);
|
||||
var c = counters[name] || 0;
|
||||
counters[name] = c + 1;
|
||||
var dest = OUT_DIR + "p" + PID + "_" + name + (c === 0 ? "" : "." + c);
|
||||
var fh = new File(dest, "wb");
|
||||
fh.write(arrayBuffer);
|
||||
fh.close();
|
||||
console.log(TAG + "[SAVED] " + dest + " (" + size + " bytes)");
|
||||
}
|
||||
|
||||
// NtClose: reconstruct + flush saved files written via NtWriteFile
|
||||
function flush(handleStr) {
|
||||
var f = files[handleStr];
|
||||
if (!f) return;
|
||||
delete files[handleStr];
|
||||
if (!f.save || f.end === 0) return;
|
||||
var out = new Uint8Array(f.end);
|
||||
for (var i = 0; i < f.chunks.length; i++) out.set(new Uint8Array(f.chunks[i].ab), f.chunks[i].pos);
|
||||
saveBuffer(f.path, out.buffer, f.end);
|
||||
}
|
||||
Interceptor.attach(resolve("NtClose"), { onEnter: function (args) { flush(args[0].toString()); } });
|
||||
|
||||
// ---- memory-mapped writes (the .pdb is likely built via a file mapping) ----
|
||||
// CreateFileMapping/MapViewOfFile funnel through these ntdll calls. We link
|
||||
// section -> backing file (NtCreateSection takes the file handle we already
|
||||
// track), record the mapped view, and snapshot its memory when it's unmapped.
|
||||
var sections = {}; // sectionHandle -> { path, save, maxSize }
|
||||
var views = {}; // baseAddr string -> { base, size, path, save, maxSize }
|
||||
|
||||
function hookSections() {
|
||||
var cs = resolve("NtCreateSection");
|
||||
if (cs) Interceptor.attach(cs, {
|
||||
onEnter: function (args) {
|
||||
this.pSection = args[0];
|
||||
this.pMax = args[3]; // PLARGE_INTEGER MaximumSize (may be null)
|
||||
this.fileHandle = args[6].toString();// HANDLE FileHandle
|
||||
},
|
||||
onLeave: function (ret) {
|
||||
if (ret.toInt32() !== 0) return; // not STATUS_SUCCESS
|
||||
var f = files[this.fileHandle];
|
||||
if (!f) return; // only file-backed sections we track
|
||||
var maxSize = (this.pMax && !this.pMax.isNull()) ? this.pMax.readU32() : 0;
|
||||
sections[this.pSection.readPointer().toString()] = { path: f.path, save: f.save, maxSize: maxSize };
|
||||
},
|
||||
});
|
||||
|
||||
var mv = resolve("NtMapViewOfSection");
|
||||
if (mv) Interceptor.attach(mv, {
|
||||
onEnter: function (args) {
|
||||
this.section = args[0].toString();
|
||||
this.pBase = args[2]; // PVOID* BaseAddress
|
||||
this.pViewSize = args[6]; // PSIZE_T ViewSize
|
||||
},
|
||||
onLeave: function (ret) {
|
||||
if (ret.toInt32() < 0) return; // negative NTSTATUS = error
|
||||
var s = sections[this.section];
|
||||
if (!s) return;
|
||||
var base = this.pBase.readPointer();
|
||||
var size = (this.pViewSize && !this.pViewSize.isNull()) ? this.pViewSize.readU32() : 0;
|
||||
views[base.toString()] = { base: base, size: size, path: s.path, save: s.save, maxSize: s.maxSize };
|
||||
if (s.save || VERBOSE) console.log(TAG + "[mmap] " + s.path + " @ " + base + " view=" + size + " max=" + s.maxSize);
|
||||
},
|
||||
});
|
||||
|
||||
function captureView(base) {
|
||||
var v = views[base.toString()];
|
||||
if (!v) return;
|
||||
delete views[base.toString()];
|
||||
if (!v.save) return;
|
||||
var size = v.maxSize || v.size;
|
||||
if (!size) { var r = Process.findRangeByAddress(base); size = r ? r.size : 0; }
|
||||
if (!size) return;
|
||||
try { saveBuffer(v.path, Memory.readByteArray(base, size), size); }
|
||||
catch (e) { console.log(TAG + "[mmap] read failed @ " + base + ": " + e); }
|
||||
}
|
||||
|
||||
var um = ntdll.findExportByName("NtUnmapViewOfSection");
|
||||
if (um) Interceptor.attach(um, { onEnter: function (args) { captureView(args[1]); } });
|
||||
}
|
||||
hookSections();
|
||||
|
||||
console.log(TAG + "ntdll + mmap hooks installed -- trigger 'Export to USB' in rekordbox now");
|
||||
Reference in new issue
Block a user