Skip to content

Commit 08de5b2

Browse files
committed
ECOPROJECT-4719 | fix: Prevent symlink-based path traversal in VDDK tarball extraction
Resolves CVE vulnerability where chained symlinks could write files outside the extraction directory. An attacker could craft a tarball with: 1. a/x → .. (symlink passes lexical validation) 2. a/x/evil.sh (follows symlink, writes to ../evil.sh outside destDir) This enables arbitrary file write as UID 1001, allowing: - Config file overwrites in /var/lib/agent/ - Persistent code execution via /app/.cache - Exfiltration of vCenter admin credentials Fix: Before creating files/directories, resolve parent path symlinks with filepath.EvalSymlinks and verify the resolved path remains inside destDir. Legitimate VDDK .so version symlinks are still supported. Signed-off-by: Aviel Segev <asegev@redhat.com>
1 parent f90a4ca commit 08de5b2

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

internal/services/vddk.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,26 @@ func extractTarGz(r io.Reader, destDir string) error {
176176
return fmt.Errorf("illegal file path: %s", targetPath)
177177
}
178178

179+
// ECOPROJECT-4719 | Before creating files/directories, resolve symlinks in the parent path
180+
// to prevent chained symlink attacks (e.g., a/x -> .., then a/x/evil)
181+
if header.Typeflag == tar.TypeDir || header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeSymlink {
182+
parentDir := filepath.Dir(targetPath)
183+
if parentDir != destDir {
184+
resolvedParent, err := filepath.EvalSymlinks(parentDir)
185+
if err == nil {
186+
// Parent exists - verify resolved path is still strictly inside destDir.
187+
// If it resolves to destDir itself, that means a symlink has traversed
188+
// back to the root, which indicates an escape attempt.
189+
if filepath.Clean(resolvedParent) == filepath.Clean(destDir) {
190+
return fmt.Errorf("symlink escape detected: %s resolves to %s (root)", parentDir, resolvedParent)
191+
}
192+
if !pathInsideDest(destDir, resolvedParent) {
193+
return fmt.Errorf("symlink escape detected: %s resolves to %s", parentDir, resolvedParent)
194+
}
195+
}
196+
}
197+
}
198+
179199
switch header.Typeflag {
180200
case tar.TypeDir:
181201
// create directory

internal/services/vddk_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,87 @@ var _ = Describe("VddkService", func() {
260260
})
261261
})
262262

263+
Describe("Security: Path Traversal Prevention", func() {
264+
It("blocks chained symlink attack", func() {
265+
tarGz := test.BuildTarGz(
266+
test.TarEntry{
267+
Path: "a/x",
268+
LinkTarget: "..",
269+
},
270+
test.TarEntry{
271+
Path: "a/x/evil.sh",
272+
Content: "malicious payload",
273+
},
274+
)
275+
filename := "VMware-vix-disklib-8.0.3-23950268.x86_64.tar.gz"
276+
_, err := srv.Upload(context.Background(), filename, bytes.NewReader(tarGz))
277+
Expect(err).To(HaveOccurred())
278+
Expect(err.Error()).To(ContainSubstring("symlink escape detected"))
279+
})
280+
281+
It("blocks absolute symlink escape", func() {
282+
tarGz := test.BuildTarGz(
283+
test.TarEntry{
284+
Path: "malicious",
285+
LinkTarget: "/etc/passwd",
286+
},
287+
)
288+
filename := "VMware-vix-disklib-8.0.3-23950268.x86_64.tar.gz"
289+
_, err := srv.Upload(context.Background(), filename, bytes.NewReader(tarGz))
290+
Expect(err).To(HaveOccurred())
291+
Expect(err.Error()).To(ContainSubstring("illegal symlink target"))
292+
})
293+
294+
It("blocks relative symlink pointing outside destDir", func() {
295+
tarGz := test.BuildTarGz(
296+
test.TarEntry{
297+
Path: "a/b/c",
298+
LinkTarget: "../../../etc/passwd",
299+
},
300+
)
301+
filename := "VMware-vix-disklib-8.0.3-23950268.x86_64.tar.gz"
302+
_, err := srv.Upload(context.Background(), filename, bytes.NewReader(tarGz))
303+
Expect(err).To(HaveOccurred())
304+
Expect(err.Error()).To(ContainSubstring("illegal symlink target"))
305+
})
306+
307+
It("allows legitimate VDDK internal symlinks", func() {
308+
// VDDK tarballs contain .so version symlinks like libcares.so -> libcares.so.2
309+
tarGz := test.BuildTarGz(
310+
test.TarEntry{
311+
Path: "vmware-vix-disklib-distrib/lib64/libvixDiskLib.so.8.0.3",
312+
Content: "library-content",
313+
},
314+
test.TarEntry{
315+
Path: "vmware-vix-disklib-distrib/lib64/libvixDiskLib.so",
316+
LinkTarget: "libvixDiskLib.so.8.0.3",
317+
},
318+
)
319+
filename := "VMware-vix-disklib-8.0.3-23950268.x86_64.tar.gz"
320+
_, err := srv.Upload(context.Background(), filename, bytes.NewReader(tarGz))
321+
Expect(err).NotTo(HaveOccurred())
322+
323+
// Verify symlink was created correctly
324+
link := filepath.Join(dataDir, "vddk", "vmware-vix-disklib-distrib", "lib64", "libvixDiskLib.so")
325+
target, err := os.Readlink(link)
326+
Expect(err).NotTo(HaveOccurred())
327+
Expect(target).To(Equal("libvixDiskLib.so.8.0.3"))
328+
})
329+
330+
It("blocks directory traversal with clean paths", func() {
331+
tarGz := test.BuildTarGz(
332+
test.TarEntry{
333+
Path: "../../etc/shadow",
334+
Content: "malicious",
335+
},
336+
)
337+
filename := "VMware-vix-disklib-8.0.3-23950268.x86_64.tar.gz"
338+
_, err := srv.Upload(context.Background(), filename, bytes.NewReader(tarGz))
339+
Expect(err).To(HaveOccurred())
340+
Expect(err.Error()).To(ContainSubstring("illegal file path"))
341+
})
342+
})
343+
263344
Describe("extractVersion", func() {
264345
// extractVersion is unexported; we test via Upload with different filenames and tar layouts
265346
It("parses version from VMware-vix-disklib-X.Y.Z-... filename", func() {

0 commit comments

Comments
 (0)