Summary
A plugin recipient (age1<name>1…) or identity (AGE-PLUGIN-<NAME>-1…) string carries the plugin name in the bech32 HRP. PluginRecipient.ExtractPluginName / PluginIdentity.ExtractPluginName return that name without validating its character set, and PluginConnection interpolates it into age-plugin-<name> which is handed to Process.Start (Age/Plugin/PluginConnection.cs).
Bech32.Decode validates the checksum, mixed-case rule, and the data character set — but not the HRP character set. So a name containing a path separator (/, or \ on Windows) passes through intact. Because .NET's Process.Start (with UseShellExecute = false) treats a FileName containing a directory separator as a path relative to the working directory rather than doing a PATH lookup, a crafted recipient/identity can make AgeSharp execute an arbitrary binary.
Impact
Arbitrary code execution. An attacker who gets a victim to (a) use an attacker-supplied recipient (encryption) or identity (decryption) — identity files are routinely shared for decryption — and (b) place a file at a predictable relative path (e.g. age-plugin-<x>/<y> under the victim's working directory) achieves code execution when the victim runs an age operation. The age-plugin- prefix prevents absolute-path injection, but CWD-relative execution via / is sufficient.
Proof of concept
Both strings below are valid bech32 with valid checksums (an attacker computes the checksum trivially):
- Recipient
age1pwn/pwn1qypqx5g6myp → ExtractPluginName returns pwn/pwn → binary path age-plugin-pwn/pwn.
- Identity
AGE-PLUGIN-PWN/PWN-1QY06FDQA → same pwn/pwn.
Confirmed with a local test: the separator survives decoding into the name used for Process.Start.
Root cause
No allowlist validation on the plugin name; Bech32.Decode does not restrict HRP characters.
Reference implementation
Upstream age validates this. plugin/encode.go validPluginName restricts names to abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-._ (no path separators), enforced in both ParseRecipient and ParseIdentity. There is an explicit CLI test for it (cmd/age/testdata/plugin.txt: "check that path separators are rejected", with an age-plugin-pwn/pwn payload).
Suggested fix
- Enforce a
validPluginName-style allowlist ([A-Za-z0-9+\-._], i.e. no /, \, or other separators/specials) in ExtractPluginName for both PluginRecipient and PluginIdentity, throwing on violation so the malicious string is rejected at parse time, before any Process.Start.
- Add regression tests using the PoC strings above (recipient and identity), asserting rejection.
Context
Found while working on the plugin confirm fixes (#6). Not introduced by that work — this is a pre-existing gap.
Summary
A plugin recipient (
age1<name>1…) or identity (AGE-PLUGIN-<NAME>-1…) string carries the plugin name in the bech32 HRP.PluginRecipient.ExtractPluginName/PluginIdentity.ExtractPluginNamereturn that name without validating its character set, andPluginConnectioninterpolates it intoage-plugin-<name>which is handed toProcess.Start(Age/Plugin/PluginConnection.cs).Bech32.Decodevalidates the checksum, mixed-case rule, and the data character set — but not the HRP character set. So a name containing a path separator (/, or\on Windows) passes through intact. Because .NET'sProcess.Start(withUseShellExecute = false) treats aFileNamecontaining a directory separator as a path relative to the working directory rather than doing aPATHlookup, a crafted recipient/identity can make AgeSharp execute an arbitrary binary.Impact
Arbitrary code execution. An attacker who gets a victim to (a) use an attacker-supplied recipient (encryption) or identity (decryption) — identity files are routinely shared for decryption — and (b) place a file at a predictable relative path (e.g.
age-plugin-<x>/<y>under the victim's working directory) achieves code execution when the victim runs an age operation. Theage-plugin-prefix prevents absolute-path injection, but CWD-relative execution via/is sufficient.Proof of concept
Both strings below are valid bech32 with valid checksums (an attacker computes the checksum trivially):
age1pwn/pwn1qypqx5g6myp→ExtractPluginNamereturnspwn/pwn→ binary pathage-plugin-pwn/pwn.AGE-PLUGIN-PWN/PWN-1QY06FDQA→ samepwn/pwn.Confirmed with a local test: the separator survives decoding into the name used for
Process.Start.Root cause
No allowlist validation on the plugin name;
Bech32.Decodedoes not restrict HRP characters.Reference implementation
Upstream age validates this.
plugin/encode.govalidPluginNamerestricts names toabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-._(no path separators), enforced in bothParseRecipientandParseIdentity. There is an explicit CLI test for it (cmd/age/testdata/plugin.txt: "check that path separators are rejected", with anage-plugin-pwn/pwnpayload).Suggested fix
validPluginName-style allowlist ([A-Za-z0-9+\-._], i.e. no/,\, or other separators/specials) inExtractPluginNamefor bothPluginRecipientandPluginIdentity, throwing on violation so the malicious string is rejected at parse time, before anyProcess.Start.Context
Found while working on the plugin
confirmfixes (#6). Not introduced by that work — this is a pre-existing gap.