A crafted ELF binary can embed radare2 command syntax inside a DWARF DW_TAG_formal_parameter name. When radare2 runs aaa, that name is imported into the type database. Later, afsv / afsvj build a pfq command to print argument values and evaluate the raw name without sanitization.
That means an argument name containing |! can reach local shell command execution when the analyst runs afsvj at a call site. This is not memory corruption. It is a direct command injection.
| Product | radare2 |
|---|---|
| Verified Revision | c8d4c65990b9e0dd74b28f7c676e2aaab11a497d |
| Trigger | Open a crafted ELF, run aaa, then execute afsv or afsvj at a call site |
| Target Components | libr/anal/dwarf_process.c / libr/core/cmd_anal.inc.c |
| CWE | CWE-78: OS Command Injection |
| Discovery Method | Static analysis of the DWARF import path and the command construction path in afsvj, with GPT-5.4-Cyber assistance |
The attacker model is simple: the attacker only needs to provide an ELF file that will be opened in radare2. The victim follows a routine analysis flow, runs aaa, then uses afsv or afsvj at a function call site.
afsvj is especially interesting because it is a JSON output command and looks like a harmless display or automation helper. That makes it a plausible sink in wrapper scripts and plugins too.
The core issue is that a DWARF-derived argument name is first stored as data, then later reused as part of a command string. The vulnerable boundary is not the DWARF parser itself, but the layer that bridges debug info into a display command.
The bug can be reduced to three steps.
| Phase | Description |
|---|---|
| 1 | The argument name extracted from DWARF is stored in the type DB as-is |
| 2 | afsvj reads that value back and passes it into print_fcn_arg() |
| 3 | print_fcn_arg() evaluates an r2 command string that still contains the unsanitized name |
In libr/anal/dwarf_process.c, import_dwarf_function_fallback() walks formal parameters extracted from DWARF and writes them into the type DB as func.<typed_name>.arg.N.
arg_name = var->name ? strdup(var->name) : "argN";
arg_key = "func.<typed_name>.arg.<N>";
arg_val = "<type>,<arg_name>";
sdb_set(types, arg_key, arg_val, 0);
The important detail is that the function-side typed_name is sanitized as a C identifier, but arg_name is stored raw. So if the DWARF DW_AT_name contains something like x|!touch /tmp/r2_poc #, that exact string survives in the type DB.
According to the built-in help, afsv prints a function signature using the current register and stack state. Internally, cmd_afsv() resolves the call target when the current instruction is a call or jump, then passes each recovered argument into print_fcn_arg().
if (current_op_is_call_or_jmp) {
pcv = aop->jump;
}
fcn_name = resolve_callee_name(pcv);
list = r_core_get_func_args(core, fcn_name);
for each arg in list:
print_fcn_arg(core, arg->type, arg->name, arg->fmt, arg->src, ...);
The actual sink is print_fcn_arg(), which used to construct a pfq command containing the argument name name and pass it to r_core_cmd_strf().
cmd = "pfq " + prefix + fmt + " " + name + " @ " + addr;
res = r_core_cmd_strf(core, cmd);
In radare2, |! is interpreted as the pipe-to-shell operator. That means a malicious argument name containing command metacharacters can turn a display helper into shell execution. For example:
$ x|!touch /tmp/r2_poc #
Once that string is embedded into the command, the trailing @ 0x... portion is commented out, and the effect is essentially touch /tmp/r2_poc.
The PR includes a Python script that generates a minimal ELF containing the malicious DWARF data.
DW_TAG_formal_parameter name inside the ELFaaa so the DWARF information is imported into the type DBafsvj$ r2 -qc 'aaa; s 0x4000c2; ar rdi=0x4000b0; afsvj' /tmp/dwarf.elf
This is the same invocation style shown in the PR. The ar rdi=... step matters because afsvj evaluates arguments using the current register and stack state. The original PR used open -a Calculator on macOS, but for a proof-of-concept, touch /tmp/r2_poc is enough.
aaa alone does not trigger execution. The dangerous sink lives in afsv / afsvj.
safe_name = r_str_sanitize_r2(name);
cmd = "pfq " + prefix + fmt + " " + safe_name;
res = r_core_call_str_at(core, addr, cmd);
| Dimension | Description |
|---|---|
| Execution Context | Local command execution with the privileges of the user running radare2 |
| Trigger Conditions | Open an attacker-controlled ELF, run aaa, then execute afsv / afsvj |
| Blast Radius | Desktop reverse engineering workflows, scripted triage, and helper tools that rely on JSON output |
| Nature | A local command injection reachable from what appears to be a display or inspection command |
| Date | Event |
|---|---|
| 2026-04-15 | Vulnerability confirmed and fix PR #25821 opened |
| 2026-04-15 | Review recommends the r_core_call_str_at()-based fix pattern |
| 2026-04-15 | PR merged and the fix lands in master |
| 2026-04-16 | This write-up added |