VNX-363: Race condition enabling link following (symlink check, then open by name)
A path is tested for being a symbolic link and then opened, written or re-permissioned by name a few lines later. The test and the use are two lookups, so a local attacker can swap a regular file for a link to /etc/passwd or a key file between them, and the program follows it
| Field | Value |
|---|---|
| Rule ID | VNX-363 |
| Severity | Medium |
| SARIF level | warning |
| Kind | sast |
| Languages | Go, Java, JavaScript / TypeScript, PHP, Python, Ruby |
| CWE | CWE-363 |
| CAPEC | CAPEC-97 |
| ATT&CK | T1557 |
| CWSS vector | CWSS:1.0/TI:M/AP:RU/AL:S/IC:N/FC:LT/RP:RU/RL:S/AV:L/AS:N/IN:A/SC:A/BI:M/DI:H/EX:M/EC:N/P:C |
| Tags | race-condition symlink toctou cwe-363 |
| Source | internal/sast/rules/vnx-363.rego |
Overview
VNX-363 maps to CWE-363: Race Condition Enabling Link Following. A path is tested for being a symbolic link and then opened, written or re-permissioned by name a few lines later. The test and the use are two lookups, so a local attacker can swap a regular file for a link to /etc/passwd or a key file between them, and the program follows it. Reported, with the use on the same path within ten lines of the check: Python os.path.islink(p) or p.is_symlink() then open(p), os.open(p) without O_NOFOLLOW, os.chmod/os.chown, shutil.copy*(p) or a pathlib p.open/read/write/chmod; Go os.Lstat(p) whose result is tested against os.ModeSymlink then os.Create/Open/OpenFile/ReadFile/WriteFile/Chmod/Chown(p); Java Files.isSymbolicLink(p) then a java.nio.file.Files read, write or open of p without LinkOption.NOFOLLOW_LINKS, or new FileOutputStream/FileWriter/FileInputStream/RandomAccessFile(p or p.toFile()); Node fs.lstat/lstatSync(p) tested with isSymbolicLink() then an fs read, write, open, chmod or chown of p without O_NOFOLLOW; PHP is_link($p) then fopen, file_put_contents, file_get_contents, readfile, chmod, chown, touch or copy of $p; Ruby File.symlink?(p) then File/IO.open, read, write, chmod or chown of p. Not reported: an open with O_NOFOLLOW or NOFOLLOW_LINKS, a use of a different path, os.readlink/readlinkSync (reading the link itself), unlink of the link, a check and use through a directory handle (os.Root, openat), comments and string literals.
Why This Matters
Testing whether a path is a symbolic link and then opening it by name leaves a window in which a local attacker can swap the regular file for a link to /etc/passwd or a key file. The program passed the check and follows the link anyway.
What Gets Flagged
# FLAGGED: islink() check, then open by name
if os.path.islink(dest):
raise ValueError("refusing to follow a link")
with open(dest, "wb") as fh:
fh.write(payload)
// FLAGGED
if (!is_link($cache)) {
file_put_contents($cache, $data);
}
What Is Not Flagged
# the kernel refuses the link at open time
fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
Files.newOutputStream(p, LinkOption.NOFOLLOW_LINKS);
Reading the link itself (readlink), unlinking it, a use of a different path, directory-handle APIs (os.Root, openat), comments and strings are not reported. A call wrapped over lines is read as one statement, so NOFOLLOW_LINKS on a continuation line is honoured.
Remediation
- Open with
O_NOFOLLOW(orLinkOption.NOFOLLOW_LINKS) and check the opened descriptor withfstat. - Work relative to a directory handle you trust (
openat, Goos.Root). - Keep files a privileged process writes in directories other users cannot write.
References
Suppressing this rule
Put a nosec comment on the line, or on line 1 to cover the whole file. Use the comment marker of the file’s language (#, //, -- or ;):
# nosec vnx-363
// nosec vnx-363
Or record an ignore rule: vulnetix ignore add --rule vnx-363 --reason "...". See Suppressing findings.