
Consul Template validated where a symlink pointed during template evaluation, but its later dependency fetch read the original path. Retargeting the link between those operations turned an in-sandbox file reference into an out-of-sandbox file disclosure.
Consul Template validated where a symlink pointed during template evaluation, but its later dependency fetch read the original path. Retargeting the link between those operations turned an in-sandbox file reference into an out-of-sandbox file disclosure.
I found this issue while reviewing HashiCorp Consul Template, with a very specific security question in mind:
If sandbox_path validates a symlink during template evaluation, does the later file read stay bound to that same validated target?
In this case, the answer was no.
The file template helper resolved the path and enforced the configured sandbox during template evaluation. But after that check, it created a file dependency using the original raw path.
That dependency was fetched later.
If an attacker retargeted the symlink in the gap between validation and dependency fetch, Consul Template could read an out-of-sandbox file. If the link was restored before the next render, sandbox validation passed again and the cached external content was still rendered.
That issue became CVE-2026-5061.
HashiCorp bulletin: HCSEC-2026-12
IBM bulletin: CVE-2026-5061 security bulletin
CVE: CVE-2026-5061
Fixed in: 0.42.0
photo0
attacker-controlled in-sandbox symlink -> sandbox validation resolves to safe target -> dependency stores original raw path -> attacker retargets link outside sandbox -> dependency fetch reads external file -> attacker restores safe link -> next validation passes -> cached external content is rendered
Consul Template is a template rendering tool for Consul and Vault data.
It can run continuously, watch dependencies for changes, and render data into files or environment variables for applications to consume.
The file template helper reads a local file and inserts its contents into rendered output.
Because local file reads can expose process-readable secrets, Consul Template provides sandbox_path as a boundary for this helper.
The documented security property is straightforward:
file must fall inside the configured sandboxThat makes sandbox_path a real security boundary.
The important question was not whether the path looked like it was under the sandbox.
The real question was:
Does the file that gets read remain the same file that passed sandbox validation?
In vulnerable versions, it did not.
Filesystem sandboxes often fail at the gap between path validation and file access.
The common pattern is:
That assumption is unsafe when an attacker can modify a symlink or equivalent filesystem redirection between the two operations.
Consul Template made this surface especially interesting because template evaluation and dependency fetching were separate stages.
That separation created the right security question:
Is the dependency fetch bound to the validated target, or does it resolve the attacker-controlled path again?
That was the boundary I focused on.
The root cause was a time-of-check to time-of-use mismatch between:
template/funcs.godependency/file.goAt the tested revision, fileFunc() did this:
normalized := strings.TrimSpace(s)
err := pathInSandbox(sandboxPath, normalized)
if err != nil {
return "", err
}
d, err := dep.NewFileQuery(s)
The validation helper correctly resolved symlinks before checking containment:
sandboxResolved, err := filepath.EvalSymlinks(filepath.Clean(sandbox))
targetResolved, err := filepath.EvalSymlinks(filepath.Clean(path))
rel, err := filepath.Rel(sandboxResolved, targetResolved)
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("'%s' is outside of sandbox", path)
}
So the sandbox check itself understood the resolved target.
But that resolved target was discarded.
NewFileQuery() stored the original string instead:
return &FileQuery{
stopCh: make(chan struct{}, 1),
path: s,
}, nil
Then the later dependency fetch read that path again:
data, err := os.ReadFile(d.path)
That is the whole vulnerability.
The code checked one filesystem resolution, then later used another.
Because a symlink is mutable.
The attacker does not need to make an out-of-sandbox target pass pathInSandbox() directly.
They only need to change what the already-approved raw path resolves to after validation but before Fetch() performs the read.
The sequence is:
pathInSandbox()FileQuery records the raw link pathos.ReadFile(d.path) resolves the link againThat last step is important.
Restoring the safe link did not remove the already-fetched secret from the dependency cache.
The important distinction is bypass of an explicit security control.
This was not merely:
"the file changed while Consul Template was watching it"
Watching files for changes is expected behavior.
The real issue was:
a path that passed the documented sandbox restriction could later be used to read a file outside that sandbox
That is a direct trust-boundary failure.
The application had already made a security decision:
But the later fetch was not tied to the target that justified that decision.
That is what turned ordinary filesystem mutability into a vulnerability.
I built a standalone reproducer around the exact evaluation and dependency-fetch sequence.
The controlled setup used:
The reproduction flow was:
file helper so sandbox validation succeeds and the raw path is registered as a dependency.os.ReadFile(d.path) to read through the redirected link.The observed behavior was:
That hash comparison mattered.