
Consul Template's writeToFile helper opened an operator-supplied destination directly and followed linked path components, allowing rendered output to escape the intended directory and overwrite a preexisting file.
Consul Template's writeToFile helper opened an operator-supplied destination directly and followed linked path components, allowing rendered output to escape the intended directory and overwrite a preexisting file.
I found this issue while reviewing HashiCorp Consul Template, with a direct filesystem security question in mind:
If writeToFile receives a path that appears to be inside the intended directory, does it verify where the filesystem will actually write the data?
In this case, the answer was no.
The writeToFile template helper opened the final user-supplied path directly through os.Create() or os.OpenFile().
Those operations followed symbolic links, directory junctions, and equivalent filesystem redirections already present in the destination path.
That meant the path string could remain under the operator's intended root while the actual write landed somewhere else.
In my controlled proof of concept, a linked parent directory redirected rendered output outside the intended tree and caused a preexisting target file to be clobbered.
That issue became CVE-2026-14361.
HashiCorp bulletin: HCSEC-2026-20
IBM bulletin: CVE-2026-14361 security bulletin
CVE: CVE-2026-14361
Fixed in: 0.42.1
photo0
operator-supplied destination appears inside intended root -> attacker pre-positions linked parent or final path component -> writeToFile opens the path directly -> filesystem resolves the write outside the intended directory -> rendered secret is redirected -> preexisting target may be overwritten
Consul Template renders data from sources such as Consul and Vault.
The writeToFile helper lets a template write selected content to a separate local file while applying a requested owner, group, and permission mode.
HashiCorp's documentation specifically demonstrates the helper with PKI material:
private key -> writeToFile /my/path/to/cert.key
certificate authority -> writeToFile /my/path/to/cert.pem
certificate -> append to /my/path/to/cert.pem
That makes this more than an ordinary output helper.
The content crossing this boundary may include:
The important question was not whether writeToFile could create a requested filename.
The real question was:
Does the process write to the operator's intended filesystem location, or merely to whatever object the path resolves to at open time?
In vulnerable versions, it trusted the latter.
Write helpers are high-value security surfaces because they cross from application data into filesystem mutation.
The interesting failures are often not classic ../ traversal.
They are resolution failures:
That is especially important when the process runs with more filesystem privileges than the attacker.
A low-privileged local attacker may not be able to overwrite a sensitive file directly.
But if they can influence a path component under an intended write directory, a more privileged Consul Template process may perform the write for them.
That was the boundary I focused on.
I did not approach this as a generic path traversal review.
The supplied path did not need .. segments.
It could remain lexically inside the intended root the entire time.
The stronger question was:
Are linked destination components rejected before sensitive content is written?
That question matters for both:
The second case is especially useful because logs and configuration still show an innocent-looking path under the expected directory.
The filesystem resolves it somewhere else.
The root cause was direct path-based file creation without link-aware validation.
At the tested revision, writeToFile() selected one of two open paths.
Append mode used:
f, err = os.OpenFile(
path,
os.O_APPEND|os.O_WRONLY|os.O_CREATE,
perm,
)
Normal write mode used:
dirPath := filepath.Dir(path)
if _, err := os.Stat(dirPath); err != nil {
err := os.MkdirAll(dirPath, os.ModePerm)
if err != nil {
return "", err
}
}
f, err = os.Create(path)
Neither path rejected linked destination components before the file was opened.
That matters because:
os.Create(path) follows existing filesystem redirections and truncates the resolved fileos.OpenFile(path, ...) follows linked path components during append modeThe same path-based assumption continued after the write.
Ownership and permissions were applied using the path again:
err = os.Chown(path, uid, gid)
err = os.Chmod(path, perm)
That meant metadata operations were also tied to a mutable path name instead of the already-open file descriptor.
Because the attacker can prepare the redirection before writeToFile runs.
No probabilistic race is required for the basic attack.
The sequence is straightforward:
writeToFile opens it directlyThat is the whole vulnerability.
It is true that operating systems normally follow symlinks during path-based file opens.
That does not make this safe application behavior.
The security question is not:
"Did Go behave as documented?"
The real question is:
Did a helper writing sensitive rendered data verify that the resolved destination matched the operator's intended location?
In vulnerable versions, it did not.
That distinction matters because Consul Template may run:
Following attacker-positioned filesystem redirection in that context creates a real privilege and trust-boundary problem.
I built a standalone reproducer around the exact writeToFile behavior at the tested commit.
The controlled setup used:
writeToFileThe reproduction flow was: