-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## radare2 generates r2 commands from PDB content. That is the design.\n\n`idp` loads symbols from a PE binary's matching PDB file. It is one of the first commands a Windows reverse engineer types after opening a binary in radare2: confirm the PDB resolved, pull in symbol names, work the static analysis from there. Without arguments, the command is short:\n\n```c\ncase '\\0': // \"idp\"\n r_core_cmd0 (core, \".idpi*\");\n break;\n```\n\n`r_core_cmd0(core, \".idpi*\")` runs `idpi*`, captures its stdout, and feeds each line of that output back into the r2 command parser. The leading dot is the operator that does the feeding. `idpi*` is the RAD-mode formatter for PDB info; it walks the PDB's global-symbol stream and emits one or more r2 commands per symbol, intended to be re-executed inside r2 to set flags, define types, and rename symbols.\n\nThat is content-is-command at the architectural level. The PDB file is the content. The interpreter is r2 itself. The bridge between them is `print_gvars` in `libr/bin/format/pdb/pdb.c`, whose job is to convert binary file content into r2 commands. Every field that crosses that bridge is a candidate for the same family of bug. The codebase knew this. It built an encoding envelope for the case: a `base64:` prefix attached to whatever operand needs to ship a user-controlled string through a command-line context, decoded inside the receiving handler.\n\n## The bug was in `print_gvars`'s second `cb_printf`\n\nCVE-2026-40517 is the report. Pre-patch, the global-symbol RAD-mode handler emitted two lines per symbol. The hunk that the PR replaced (reconstructed from the diff and the surrounding source):\n\n```c\ncase 1:\ncase '*':\ncase 'r': // r2 script\n filtered_name = r_name_filter_dup (r_str_trim_head_ro (name));\n pdb->cb_printf (\"f pdb.%s = 0x%\" PFMT64x \" # %d %.*s\\n\",\n filtered_name,\n (img_base + omap_remap (...)),\n gdata->symtype,\n PDB_SIZEOF_SECTION_NAME,\n sctn_header->name);\n pdb->cb_printf (\"\\\"fN pdb.%s %s\\\"\\n\", filtered_name, name);\n free (filtered_name);\n break;\n```\n\nTwo `cb_printf`s. The first emits `f pdb. = 0x # ...` to define a flag at the symbol's address. `filtered_name` came from `r_name_filter_dup`, which replaces every byte that is not a valid r2 identifier character (a quote, a space, a newline, anything outside the function's valid-name lookup table) with `_`. Defended.\n\nThe second emits `\"fN pdb. \"` to attach the original demangled symbol name as the flag's realname. `` is the raw demangled name straight from the PDB binary's GSYM stream, after `r_bin_demangle_msvc` and a fallback `strdup`. Not defended. The wrapping `\"...\"` is r2's quoted-command syntax, intended to let realnames containing operators like `::` or `<` survive the r2 command parser. That syntax does nothing once an attacker can place a literal `\"` inside the quoted region.\n\n## The PoC payload is a name with a closing quote\n\nThe researcher's payload is one PDB symbol name:\n\n```\nx\" ;!open -a Calculator #\n```\n\nAfter `r_name_filter_dup` produces the filtered form `x____open__a_Calculator_`, the second `cb_printf` renders this line into the stream that `.idpi*` will execute:\n\n```\n\"fN pdb.x____open__a_Calculator_ x\" ;!open -a Calculator #\"\n```\n\nWhen the r2 parser reads it:\n\n1. `\"fN pdb.x____open__a_Calculator_ x\"` is one quoted command. It runs as `fN pdb.x____open__a_Calculator_ x`. No-op.\n2. ` ` separator.\n3. `;` ends the previous command, starts a new one.\n4. `!open -a Calculator` is an r2 command. The `!` operator runs the rest in `/bin/sh`. macOS `open -a Calculator` opens Calculator.\n5. `#` starts an r2 comment. The trailing `\"` lives inside the comment, harmless.\n\nNo newline character is needed. The CVE-2026-40517 description's reference to \"newline characters in symbol names\" is an over-narrow read of the trigger; any closing-quote-plus-`;`-plus-`!` sequence reaches the same primitive. Newlines work, but they are not the precondition.\n\n## The fix is not new code. It is the existing convention applied.\n\nThe merged patch (`radareorg/radare2#25731`) is two hunks. The first lands in `libr/bin/format/pdb/pdb.c`:\n\n```diff\n- pdb->cb_printf (\"\\\"fN pdb.%s %s\\\"\\n\", filtered_name, name);\n+ char *b64name = r_base64_encode_dyn ((const ut8 *)name, strlen (name));\n+ if (b64name) {\n+ pdb->cb_printf (\"fN pdb.%s base64:%s\\n\", filtered_name, b64name);\n+ free (b64name);\n+ }\n```\n\nThe second lands in `libr/core/cmd_flag.inc.c`, where the `fN` command receives the realname:\n\n```diff\n- r_flag_item_set_realname (core->flags, item, realname);\n+ if (r_str_startswith (realname, \"base64:\")) {\n+ char *dec = (char *)r_base64_decode_dyn (realname + 7, -1, NULL);\n+ if (dec) {\n+ r_flag_item_set_realname (core->flags, item, dec);\n+ free (dec);\n+ } else {\n+ R_LOG_ERROR (\"Failed to decode base64-encoded realname\");\n+ }\n+ } else {\n+ r_flag_item_set_realname (core->flags, item, realname);\n+ }\n```\n\nThe second hunk is the convention. The producer encodes the operand with a `base64:` prefix; the consumer detects the prefix and decodes. This idiom is not novel in radare2. A grep across `libr/` finds it everywhere:\n\n```\nlibr/anal/codemeta.c:443: \"CCu base64:%s @ 0x%\" PFMT64x \"\\n\"\nlibr/anal/p/anal_callargs.c:271: \"'@0x%08\"PFMT64x\"'CCu base64:%s\"\nlibr/core/cbin.c:3704: \"'@0x%\" PFMT64x \"'CCu base64:%s\\n\"\nlibr/core/cmd_log.inc.c:200: \"CCu base64:%s @ 0x%\"PFMT64x\"\\n\"\nlibr/core/cmd_anal.inc.c:11966: \"agn \\\"%s\\\" base64:%s\\n\"\nlibr/core/canal.c:1587, 1612, 1661, 1961: body_b64 = r_str_prepend (body_b64, \"base64:\")\nlibr/core/agraph.c:3929: \"base64:%s\"\nlibr/core/corelog.c:43: \"T base64:%s\\n\"\n```\n\nDecoders for the same prefix sit in `cmd_flag.inc.c` (now in three places after the patch), `cmd_log.inc.c`, `cmd_mount.inc.c`, `cmd_open.inc.c`, `cmd_write.inc.c`, `cmd_anal.inc.c`, `socket/run.c`, `main/radare2.c`, and `cmd.c`. The help text for the `agn` command literally documents the convention: `agn title1 base64:Ym9keTE=`, \"add a node with the body specified as base64\". The PR author's commit message picks two of these by name: \"introduces `base64:` prefix for `fN` command, in line with other commands such as CCu and agn.\" The fix is the third application of an existing pattern to a place the existing pattern was missed.\n\nThe interesting question is not why the realname slot was unsafe. It is why the realname slot was the slot that did not get the envelope when the rest of the codebase did. The function that emits it is `print_gvars`, in a file whose copyright reads `2014-2026`. The PDB parser is not a corner of radare2; it is reached by `idp`, one of the most common commands in a Windows reverse-engineering session, and `idp` runs it under `r_core_cmd0(core, \".idpi*\")`, which means the parser's output is not just printed to a screen, it is fed back to the interpreter line by line. The producer side never adopted what every adjacent producer had adopted. The consumer side never enforced what an attacker would later prove was load-bearing.\n\n## The defender types the command. The PDB chooses what runs.\n\nMalware analysts run radare2 against samples. That is not an exotic workflow. A PE binary arrives via phishing, an EDR alert, an IR engagement, a customer-supplied firmware image; the analyst opens it in r2 to triage. If the binary references a PDB and the analyst has the PDB available (a stripped one served by the symbol server, a malicious one shipped alongside the binary, a customer-supplied debug build), `idp` is the first command. It is in muscle memory. The PoC's calculator is the analyst's calculator. The PoC's `!` is the analyst's shell.\n\nThis is the detector is the target shape: a tool whose job description is parsing untrusted binaries, whose users are the people most likely to load attacker-authored input, with a code path the defender invokes by reflex. The exploit primitive does not depend on the analyst running r2 as root; the attacker gets whatever the analyst's account has, which on a malware-analysis workstation is usually a sandboxed VM with full network egress, a sample-handling directory, and credentials cached for whatever the analyst was going to look at next. The detector and the target swap roles at the moment the analyst types the command they typed yesterday and the day before.\n\nThe patch closes CVE-2026-40517. It does not retroactively unship every other place in `libr/bin/` where an external file's content is shipped through `cb_printf` as part of a generated r2 command. RAD mode is a design, not a feature; every parser in the binary backend that emits RAD output is a candidate for this same shape. `libr/bin/format/pdb/pdb.c` carries another RAD-mode `cb_printf` at line 1252 in the type-format printer, emitting `pf. ` from PDB-type-stream contents; the patch did not touch it because the call site uses `r_str_sanitize_sdb_key` and r2's `pf` parser does not trip on the same characters. Whether the format and member-name slots are similarly safe under adversarial PDB type streams is a question the patch did not answer. Nobody has audited what other `cb_printf` calls in the binary backend ship raw external bytes to a command-executing consumer. The next CVE in this codebase will be that audit's first finding.\n\n## fN was the holdout\n\nThe commit message names `CCu` and `agn`. The other producers, `ano`, `afn`, `T`, the `agraph` body slot, `dbg.profile`, the `e` config setter, `wv`, `o`, `mg`, the `js` runner, are all at slightly different distances from the PDB parser's failure mode and all share the convention. The fix that closes CVE-2026-40517 is the encoding convention the codebase had already applied to every other r2 command that ships user content. fN was the call site that did not get it.\n\nThe CVE description names `CWE-78`, OS Command Injection. It is. The narrower truth is that the realname operand of `fN` was the only producer in radare2's RAD-mode toolkit shipping raw user-controlled bytes through the command parser without the prefix the codebase had standardized on. The fix is the convention. Whether other parsers in `libr/bin/` ship raw bytes the same way is the next person's audit.\n\nPoC and writeup: [radareorg/radare2#25730](https://github.com/radareorg/radare2/issues/25730), [radareorg/radare2#25731](https://github.com/radareorg/radare2/pull/25731), [blog.calif.io/p/mad-bugs-discovering-a-0-day-in-zero](https://blog.calif.io/p/mad-bugs-discovering-a-0-day-in-zero).","closing_line":"The commit message names two commands the codebase had already hardened. It does not name the parsers nobody has audited yet.","hook_md":"The radare2 PDB parser's `print_gvars` function emits two lines of r2 commands per global symbol when running in RAD mode. The first line interpolates the symbol name through `r_name_filter_dup`. The second line interpolates the same name raw, wrapped in r2's quoted-command syntax. Both lines are written into a stream that the `idp` command will execute. A malware analyst loads a PE binary, types `idp`, and the PDB chooses what r2 runs next. The patch base64-encodes the second line. Every other r2 command in the codebase that ships user content into a command line has been doing that for years.","post_id":65,"slug":"radare2-cve-2026-40517-fn-skipped-the-base64-envelope","title":"CVE-2026-40517: fN Was the One r2 Command That Skipped the base64 Envelope","type":"initial","unreadable_sentence":"The codebase wrote the encoding envelope. fN was the call site that did not get it."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaj1fbgAKCRDeZjl4jgkQ JtzGAP9dWn+parVRhGGsJhoZVN7wr2M5/Q+c/jWtucKCaXA+qgD/U2tzwIvFYKds d/WGINEykb2iMn20AXw+VQunOCW4wQI= =X2Hu -----END PGP SIGNATURE-----