fix(sflib): prevent path traversal in optValueToData (@file)#2015
fix(sflib): prevent path traversal in optValueToData (@file)#2015sebastiondev wants to merge 1 commit into
Conversation
The @file shorthand supported by SpiderFoot.optValueToData() called open() on the raw user-controlled path. Because option values are settable through the web UI (e.g. _useragent, sfp_portscan_tcp.ports, sfp_customfeed.url) and persisted to the configuration database, an authenticated operator (or anyone able to reach the unauthenticated default web UI listener) could request reads of arbitrary files reachable by the SpiderFoot process — including /etc/passwd, SSH keys, or the scan database — by setting an option such as '@/etc/passwd'. This patch resolves the requested path against the SpiderFoot data directory (SpiderFootHelpers.dataPath()) and refuses any read that escapes it via absolute or relative traversal. Files placed inside the data directory by the operator continue to load as before, preserving the documented '@/path/to/file' workflow for legitimate fixture files shipped or generated alongside the SpiderFoot installation. The existing optValueToData filename test is updated to use a fixture created inside the data directory; an additional test asserts that /etc/hosts cannot be read via the @ prefix.
|
The intention was to load arbitrary files. You can also abuse |
|
Thanks for taking a look, and for pushing back on the framing — both points are fair. On "the intention was to load arbitrary files": noted. I kept the On code execution via
The URL branch of If the chain is something like (a) URL-branch SSRF → poison cache → (b) Tests on the current diff: |
|
The real issue is lack of authentication by default. Although the password parsing code can theoretically handle multiple
Many self.sf.cachePut("internet_tlds", self.config['_internettlds']) cache/95c5dab788d19e124540cb1e96e6277f0871c648f4b3f2526fa1f765 In settings: change: to: sfp_tool_whatweb change: to: Create a scan with the The |
Summary
SpiderFoot.optValueToData()insflib.pytreats any option value beginning with@as a file path and reads it from disk with no path restrictions:Several built-in config options (most notably
_useragentand_internettlds) are passed throughoptValueToData()at scan start (seesfscan.py:196andsfscan.py:202). Because the saved-settings endpoint accepts arbitrary string values for these options, an authenticated user — or any user at all on a default Docker deployment, which is unauthenticated — can set_useragentto@/etc/passwd(or any absolute/relative path), start a scan, and have the file contents loaded as the user-agent string. While the loaded contents aren't echoed back through/optsdirectly, they flow into outbound HTTP requests as theUser-Agentheader, and any attacker-controlled HTTP destination receives the file content. The setting is also visible via the settings export.passwd-protected deployments (authenticated arbitrary local file disclosure)SpiderFoot.optValueToData()—sflib.py:142open(fname, "r").read()withfnamederived fromval.split('@')[1]/savesettingsand/savesettingsraw(sfwebui.py:1088,sfwebui.py:1155), persisted viadbh.configSet, then consumed bysfscan.py:196when a scan startsWhy this is actually exploitable
Three things turn this into a real issue rather than a theoretical one:
Dockerfileexposes port 5001 andsf.py:497-538only enables CherryPy'sauth_digestif~/.spiderfoot/passwdexists on disk. The officialdocker run -p 5001:5001 spiderfootinvocation documented in the Dockerfile does not mount apasswdfile, so the web UI is reachable with no credentials.open(). The original code doesval.split('@')[1]— absolute paths,..traversal, and symlinks all work._useragentis sent as theUser-Agentheader by every outbound request the scan makes, so an attacker who controls (or merely observes traffic to) any host the scan targets receives the file contents. Even without that, the value is retrievable through the settings export.The most damaging targets on a typical install are
~/.spiderfoot/passwd(HA1 digest hashes — Digest HA1 is unsaltedMD5(user:realm:password)and is offline-crackable), private SSH keys when SpiderFoot runs as a regular user, and/etc/shadowwhen it runs as root inside the container.Fix
Restrict
@-prefixed file reads to the SpiderFoot data directory (SpiderFootHelpers.dataPath(), defaulting to~/.spiderfoot/). Resolution usesos.path.realpath()on both sides andos.path.commonpath()to defeat symlink and..tricks. Relative paths are anchored to the data dir; absolute paths must already point inside it.Also switched
val.split('@')[1]toval.split('@', 1)[1]so filenames containing@aren't silently truncated.The fix is scoped to
optValueToData()and does not affect URL handling (http:///https://values) or plain-string passthrough. No other call sites ofoptValueToDatawere modified.Known docs follow-up (not part of this PR)
sf.py:78andmodules/sfp_portscan_tcp.py:52both document@/home/bob/useragents.txtas an example, which will stop working since/home/bob/is outside the data dir. Users with this layout should move the file into~/.spiderfoot/(or pass an absolute path inside it). Happy to follow up with a docs PR if useful — kept it out of this diff to keep the security change minimal and easy to review.Tests
Added a unit test for the refusal case and updated the existing positive test to place its fixture inside
SpiderFootHelpers.dataPath():New test:
I also manually verified the following payloads all return
Noneon the patched build (and confirmed each one returns the file's contents on the parent commit9746aa1):Proof of Concept
Tested against a vanilla checkout (
docker run -p 5001:5001 spiderfoot— nopasswdfile mounted). Replace the host as appropriate.On the patched build, step 3 logs
Refusing to load option file outside SpiderFoot data directory (...): /etc/passwdand the scan proceeds with_useragentset toNone, so no file contents leave the box.On
passwd-protected deployments the same flow works after presenting the Digest credentials — the bug is exploitable by any authenticated user, including read-only operators who happen to have settings access.Adversarial review
Before submitting, I tried to disprove this:
/optspage response. OneGET /optsfollowed byPOST /savesettingsrawis a two-request exploit; there is no out-of-band secret.sf.py:497-538only flipstools.auth_digest.onto true if apasswdfile already exists on disk, and the official Dockerfile creates no such file. Freshdocker rundeployments are open.configFileupload reads attacker-supplied content (not server files), and tool-path options likenmappathare gated byos.path.isfile(exe)and feedsubprocess, not arbitraryread(). Neither gives a plain file-disclosure primitive the way@<path>does.sf.py:507parses a multi-lineuser:passwordfile. And on Docker the threat model includes remote unauthenticated attackers, which clearly aren't "the operator."User-Agentheader on every outbound HTTP request the scan makes (SpiderFoot.fetchUrl()uses_useragent). An attacker-controlled scan target receives them. They're also retrievable via the settings export.The fix is intentionally narrow: it only constrains the
@-file-read branch ofoptValueToData(). URL handling and plain string values are untouched, and there's no behavior change for files that already live in~/.spiderfoot/.Discovered by the Sebastion AI GitHub App.