//nefariousplan

CVE-2026-40517: fN Was the One r2 Command That Skipped the base64 Envelope

patterns

cve

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.

radare2 generates r2 commands from PDB content. That is the design.

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:

case '\0': // "idp"
    r_core_cmd0 (core, ".idpi*");
    break;

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.

That 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.

The bug was in print_gvars's second cb_printf

CVE-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):

case 1:
case '*':
case 'r': // r2 script
    filtered_name = r_name_filter_dup (r_str_trim_head_ro (name));
    pdb->cb_printf ("f pdb.%s = 0x%" PFMT64x " # %d %.*s\n",
        filtered_name,
        (img_base + omap_remap (...)),
        gdata->symtype,
        PDB_SIZEOF_SECTION_NAME,
        sctn_header->name);
    pdb->cb_printf ("\"fN pdb.%s %s\"\n", filtered_name, name);
    free (filtered_name);
    break;

Two cb_printfs. The first emits f pdb.<filtered_name> = 0x<addr> # ... 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.

The second emits "fN pdb.<filtered_name> <name>" to attach the original demangled symbol name as the flag's realname. <name> 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.

The PoC payload is a name with a closing quote

The researcher's payload is one PDB symbol name:

x" ;!open -a Calculator #

After 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:

"fN pdb.x____open__a_Calculator_ x" ;!open -a Calculator #"

When the r2 parser reads it:

  1. "fN pdb.x____open__a_Calculator_ x" is one quoted command. It runs as fN pdb.x____open__a_Calculator_ x. No-op.
  2. separator.
  3. ; ends the previous command, starts a new one.
  4. !open -a Calculator is an r2 command. The ! operator runs the rest in /bin/sh. macOS open -a Calculator opens Calculator.
  5. # starts an r2 comment. The trailing " lives inside the comment, harmless.

No 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.

The fix is not new code. It is the existing convention applied.

The merged patch (radareorg/radare2#25731) is two hunks. The first lands in libr/bin/format/pdb/pdb.c:

-               pdb->cb_printf ("\"fN pdb.%s %s\"\n", filtered_name, name);
+               char *b64name = r_base64_encode_dyn ((const ut8 *)name, strlen (name));
+               if (b64name) {
+                       pdb->cb_printf ("fN pdb.%s base64:%s\n", filtered_name, b64name);
+                       free (b64name);
+               }

The second lands in libr/core/cmd_flag.inc.c, where the fN command receives the realname:

-               r_flag_item_set_realname (core->flags, item, realname);
+               if (r_str_startswith (realname, "base64:")) {
+                       char *dec = (char *)r_base64_decode_dyn (realname + 7, -1, NULL);
+                       if (dec) {
+                               r_flag_item_set_realname (core->flags, item, dec);
+                               free (dec);
+                       } else {
+                               R_LOG_ERROR ("Failed to decode base64-encoded realname");
+                       }
+               } else {
+                       r_flag_item_set_realname (core->flags, item, realname);
+               }

The 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:

libr/anal/codemeta.c:443:           "CCu base64:%s @ 0x%" PFMT64x "\n"
libr/anal/p/anal_callargs.c:271:    "'@0x%08"PFMT64x"'CCu base64:%s"
libr/core/cbin.c:3704:              "'@0x%" PFMT64x "'CCu base64:%s\n"
libr/core/cmd_log.inc.c:200:        "CCu base64:%s @ 0x%"PFMT64x"\n"
libr/core/cmd_anal.inc.c:11966:     "agn \"%s\" base64:%s\n"
libr/core/canal.c:1587, 1612, 1661, 1961:   body_b64 = r_str_prepend (body_b64, "base64:")
libr/core/agraph.c:3929:            "base64:%s"
libr/core/corelog.c:43:             "T base64:%s\n"

Decoders 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.

The 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.

The defender types the command. The PDB chooses what runs.

Malware 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.

This 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.

The 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.<sanitized> <format> <member_names> 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.

fN was the holdout

The 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.

The 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.

PoC and writeup: radareorg/radare2#25730, radareorg/radare2#25731, blog.calif.io/p/mad-bugs-discovering-a-0-day-in-zero.

The commit message names two commands the codebase had already hardened. It does not name the parsers nobody has audited yet.