All posts

Threat Research

A pristine file, a directory name, and arbitrary code

pkgconf’s --env option wraps every value it prints in single quotes and then puts unescaped, attacker-influenced data between them. Three primitives escalate from a hostile .pc file, through an escaping routine written for the wrong shell context, to command execution driven by a directory name alone.

By
Trident Research
Published
Reading time
9 min read
pkgconf / build tooling

Make a directory. Put a single quote in its name. Drop in a copy of zlib.pc — the genuine one, unmodified, byte for byte the file your distribution ships. Point a build at it that runs pkgconf --env and evaluates the result, and the directory name runs a command of your choosing. The package file never has to change.

pkgconf is the implementation of pkg-config that most modern distributions ship — the utility every build system asks where the headers live. One of its options, --env, prints package metadata as shell variable assignments, each value wrapped in single quotes. Nothing checks whether the value contains a quote of its own.

That is the entire defect, and it bites because of one rule about the shell that is easy to forget. Inside single quotes, a POSIX shell treats every character literally. There is no escape sequence, no backslash trick, nothing. The only thing that can end a single-quoted string is another single quote. So when a value you do not control is pasted between two of them, one stray quote closes the string early and everything after it stops being data:

Shell
1# what pkgconf means to emit 2FOO='some value' 3 4# what it emits when the value contains one quote 5FOO='some'; id; :'value' 6# ^^^^^ not data any more

We found this hand-verifying the survivors of an automated sweep across the fifty open-source packages everything else is built on — glibc, OpenSSL, CPython. Of twenty high-severity findings, two turned out to be exploitable, three more were real but low severity, and one we could not settle. This is the one that got worse the longer we looked at it: what began as a hostile .pc file turned out not to need a hostile file at all.

TL;DR

  • The bug. --env emits NAME='VALUE' and never escapes VALUE for that context. A quote in the value ends the string, and the rest of the line runs as commands when the output is evaluated.
  • The part that matters. One of the values pkgconf prints is pcfiledir, which it builds from the filesystem rather than reading from the file. A quote in the directory name is therefore enough, with the .pc file left byte-for-byte pristine.
  • A second, weaker path. CFLAGS and LIBS do get escaped, but with backslashes, which are meaningless inside single quotes. It breaks out and cannot get back in, so it only fires with help from the first path.
  • Exposure is narrow. You need --env plus --print-variables or --variable=, and a caller that evaluates the result. Ordinary pkg-config --cflags --libs — what autotools, Meson, CMake, cgo and the Rust crate all call — is unaffected. A public code search for real --env= usage returned nothing, though that search is not exhaustive and strips punctuation.
  • Status. All three paths confirmed by execution against pkgconf 3.0.0; the variables emission they need arrived in 2.3.0. No CVE and no vendor advisory that we are aware of. Testing was a pkgconf-lite build on macOS/arm64.

One format string, no escaping

Start with the simplest of the three paths, the one that prints ordinary variables. apply_env_variables() walks each queried package’s tuples and emits them:

C
1/* cli/core.c:536 */ 2char *val = pkgconf_variable_eval_str(client, &pkg->vars, tuple, NULL); 3pkgconf_output_fmt(client->output, PKGCONF_OUTPUT_STDOUT, 4 "%s='%s'\n", havebuf, val);

val is the fully expanded variable value, dropped between two literal quotes untouched. We pushed every printable ASCII byte through an ordinary .pc variable and not one came back escaped. Not the quote. Not even the space.

What makes the line interesting is the company it keeps. The variable name is normalized in the loop directly above it, uppercased with spaces and hyphens rewritten to underscores. The user-supplied prefix is validated in apply_env() at cli/core.c:551-556, which refuses to proceed unless every character is alphanumeric. Two of the three components on that line are defended against injection. The third, the one the .pc file wholly controls, goes through untouched.

So a variable set to x'; touch PWNED; :' produces exactly the shape from the opening:

Shell
1# in the .pc file 2evilvar=x'; touch PWNED; :' 3 4# emitted by: pkgconf --env=TEST --print-variables demo 5TEST_EVILVAR='x'; touch PWNED; :''

The trailing : is doing real work. It absorbs the emitter’s own closing quote, so the line still parses as a whole. Remember it.

The directory name is a variable too

Everything so far needs a .pc file you control. This is where that requirement disappears.

pcfiledir is not read from the .pc file. pkgconf builds it from the filesystem — it is the directory the file was found in — and adds it as an ordinary variable at libpkgconf/pkg.c:792-805. pkgconf’s own comment describes values like it as ones “which might get injected at runtime and are not sourced from the .pc file.” Then --print-variables sends it out through the same unescaped emitter as everything else.

So a quote in the directory name breaks out of the assignment while the package file stays entirely ordinary. We confirmed it end to end with a directory named lib'$(touch<TAB>PWNED_DIRNAME)' holding an unmodified, quote-free zlib.pc:

Shell
1TEST_PCFILEDIR='/tmp/x/lib'$(touch<TAB>PWNED_DIRNAME)'' 2# '----------' '' 3# closed string runs outside quotes empty string

Count the quotes. The directory name contributes two and the emitter contributes two, so nothing is left dangling — the same balancing trick the : did above, except here the attacker gets it for free. The shell closes a string, finds $( ) sitting outside quotes and runs it, then swallows a trailing empty string. No syntax error, no warning, no non-zero exit.

Why this one is different

These are routes we are enumerating, not attacks we observed. But the other two paths require an attacker to supply a malicious .pc file, and this one only requires them to influence a path component: an archive that expands to a directory of its own choosing, a CI workspace named after a branch or pull-request title, a clone directory — any of those, with genuine distro-shipped .pc files sitting inside.

The third path, escaped for the wrong shell

CFLAGS and LIBS leave by a different door, and that one does escape. It is the least useful of the three, but it is the most interesting mistake.

The fragment renderer runs the value through the quote_spans[] table at libpkgconf/fragment.c:1051, prefixing dangerous characters with a backslash, before printing it with the same %s='%s' shape at cli/core.c:441. That emitter needs --cflags or --libs alongside --env.

Backslash escaping is the correct strategy for an unquoted shell word. By the rule at the top of this article, it has no power whatsoever inside single quotes, where a backslash is just a backslash. So \' is not an escaped quote: it is a literal backslash followed by a quote that closes the string. The mitigation inverts into the vulnerability — two individually reasonable escaping models, composed so that one cancels the other.

On its own, though, it goes nowhere. Every quote it emits gets escaped, so it can break out of quoting but never back in, and an eval with unbalanced quotes is a parse error that runs nothing. It needs a closing quote from somewhere else. The variables path, which escapes nothing, is happy to supply one. The paths are complementary rather than independent.

What survives, and what you cannot use

Three emitters, and they do not agree with one another. We enumerated each byte by byte rather than trusting the table in the source, because the differences decide how a payload has to be written.

  • Ordinary variable values. Nothing is escaped at all. The one byte that does not survive is #, and not because of escaping: the parser reads it as opening a comment and drops the rest of the line.
  • Synthesized paths. pcfiledir always passes through convert_path_to_value(), which escapes the space and only the space. Its own comment says as much: “only spaces covered atm.” A prefix read straight from the file is not converted at all.
  • Fragment path. The renderer backslash-escapes a specific set of characters, the space among them — which is why a payload here needs $IFS or a tab to separate its arguments. Three that matter for shell injection are not escaped: $ at 0x24, ( at 0x28 and ) at 0x29. The table covers the obvious separators and then stops a byte short of the interesting ones, so command substitution passes through intact.

That leaves two real constraints on a directory-name payload. It cannot contain /, which is a filesystem limit and rules out absolute paths, so the command runs relative to wherever the eval happens. And it cannot use ${...}, because pkgconf interpolates its own variables first and resolves ${IFS} as an undefined pkgconf variable that expands to nothing. A bare $IFS works, and a literal tab works better.

A third constraint turns out not to exist, and we spent an afternoon respecting it. We had ruled out ; because PKG_CONFIG_PATH is split on it, and pkgconf does split the search path with strtok at libpkgconf/path.c:211. But the separator it uses is ; only on Windows; on POSIX it is :. On a POSIX host the semicolon is free, which means the payload does not need command substitution at all:

Shell
1# directory name; the zlib.pc inside is untouched 2lib';touch<TAB>PWNED;' 3 4TEST_PCFILEDIR='/tmp/x/lib';touch<TAB>PWNED;''

That version executed under sh, bash, ksh and zsh alike on the test host, though unlike the command-substitution form it is noisy: the emitter’s trailing empty string becomes an empty command word, so the shell complains — after the payload has already run. All testing was macOS/arm64, so treat the separator behavior as POSIX-specific rather than universal.

Why it looks inert when it is not

Two things will make a working payload report nothing, and both cost us time.

The first is the shell you test in. zsh does no word splitting on unquoted parameter expansions by default, so an $IFS separator collapses and the substituted command cannot resolve a binary. Test the fragment path under sh or you will wrongly conclude it is dead. That is not a reason to consider zsh safe: the directory-name path stops depending on word splitting once $IFS is swapped for a literal tab, and it fired under all four shells we tried. The swap is specific to that vector, because the .pc parser splits fragment values on whitespace and a tab cannot ride inside a fragment in the first place.

The second is subtler, and we blamed the wrong thing for a while. Adding --print-variables to an --env invocation silently truncates the dependency walk. Two entirely benign files, one of which Requires: the other:

Shell
1$ pkgconf --env=T --cflags main 2T_CFLAGS='-DFROM_MAIN -DFROM_DEP' 3 4$ pkgconf --env=T --cflags --print-variables main 5T_CFLAGS='-DFROM_MAIN' # the dependency's flags are simply gone

Zero exit status, no diagnostic. We first hit this alongside an unterminated quote in a variable value and assumed the quote had broken the parse. It had not; the selector is the cause.

High impact, small radius

The honest characterization is arbitrary code execution, in a binary present on an enormous number of systems, behind an option almost nobody appears to use. Every clause in that sentence is load bearing.

Ordinary pkg-config --cflags --libs is not affected, because pkgconf does not wrap that output in quotes and the quoting responsibility sits with the caller. That covers essentially every build system that shells out to it. Our code search for genuine --env= usage returned nothing at all; every apparent hit was --env-only, an unrelated flag that restricts lookup to PKG_CONFIG_PATH. That search is not exhaustive and it strips punctuation before indexing, so read it as an indication the invocation is rare, not proof nobody calls it.

Against that, a hostile .pc file is plainly in-scope input for this project by its own history: CVE-2023-24056 was unbounded variable expansion from a crafted file, fixed in 1.9.4, and a buffer overflow in tuple dequoting — the kind of thing a .pc file feeds it — was fixed back in 1.5.3, recorded in NEWS as a security fix with no CVE attached. pkgconf publishes no carve-out saying package files are trusted input. The pcfiledir path is what makes it worth fixing promptly anyway, because it lowers the bar from supplying a file to naming a folder.

Now the things we did not do. The build was pkgconf-lite via Makefile.lite rather than the full meson build, and we tested no distribution-packaged binary. pkgconf is the system pkg-config on Fedora, RHEL 8+, Arch, Alpine, FreeBSD and Debian trixie, but that list is an inference from those distributions shipping it, not from us reproducing anything there. It also needs a version floor: variable emission through --env arrived in 2.3.0, and on anything older the fragment path is all there is — which, on its own, cannot fire.

Why the transitive version fails

One escalation we expected to work does not — and the reason is the truncation bug above, not anything about the payload.

We tried to carry the payload in on a transitive Requires: dependency rather than the package the build actually names, which would widen the attack surface considerably. --env does carry transitive fragments, but only while neither --print-variables nor --variable= is present — and one of those is exactly what the variables path needs in order to supply the balancing quote. A dependency’s own variables, including its pcfiledir, are never emitted at all.

The two requirements are mutually exclusive, so an attacker has to reach the package the build actually names, not something further down its dependency graph. That narrows the exposure usefully, and it is the only good news in this post. It is also an accident: a truncation bug, not a boundary anyone designed, so we would not count on it surviving a fix to either.

One substitution, and pick a side

Escape for the context the data actually lands in. If the output stays in the form NAME='VALUE', then every single quote inside VALUE has to become the standard POSIX sequence '\'', which closes the string, contributes a literal quote and reopens it. Nothing else has any meaning inside single quotes, so that is the only substitution required — applied at both emitters, the variables path and the rendered fragment buffer.

The one thing not to do is layer it on top of the existing backslash escaping. The two models are mutually exclusive and stacking them would mangle the flags. Either emit unquoted and keep quote_spans[], or emit single-quoted with '\'' substitution and no backslash escaping. Picking one consistently is what resolves the defect; adding characters to the span table is a secondary detail that does not fix the quote case at all.

  • Reject or escape raw newlines. They pass through the variables path untouched, which lets a value forge additional assignment lines in the block no matter how the quoting is settled.
  • Cover the synthesized path in the regression test. pcfiledir is the one variable a maintainer is least likely to think of as attacker-controlled, precisely because pkgconf makes it up rather than reading it.
  • Stop evaluating the output. We are not aware of a patched version, and we have no upstream disclosure status to report. Parse the assignments rather than evaluating them, or use --cflags --libs and do your own quoting.

None of that is hard, which is the point. --env output has never been shell-safe, and one of the values in it is the name of a folder somebody else may have chosen for you.

Trident Research

All posts
defi / oracle bounds
Threat Research10 min

Freezing a six-figure crypto vault

A few percent of disagreement between two contracts can freeze every deposit and withdrawal in a live crypto vault — and this one went unnoticed for six weeks.

cisco / unified cm
Threat Research8 min

A phone system, one request, and root

An unauthenticated SSRF in Cisco Unified CM’s WebDialer chains to a JSP webshell and root-level compromise of enterprise voice infrastructure. Cisco patched it on June 3; within weeks attackers were dropping webshells over Tor, and CISA gave federal agencies until June 28 to fix it.

Stay ahead of the next exploit.

Trident finds chains like this before attackers do — continuous web & API pentesting correlated with cloud attack-path analysis.