Skip to content

Stored XSS via correlation result titles (OSINT-collected scan data rendered unescaped) #2012

Description

@geo-chen

Summary

SpiderFoot collects OSINT data from many untrusted external sources during a scan (a crawled page's content, a server's version banner, WHOIS records, document metadata, hostnames of co-hosted/affiliated systems, and so on). When the correlation engine produces a result, it builds the correlation title by substituting these raw, attacker-influenceable event values into a rule headline template (for example "Cloud storage bucket found open: {data}"). That title is stored and later returned by the /scancorrelations JSON endpoint without any HTML escaping, and the scan view template injects it directly into the DOM with jQuery .append().

An attacker who controls a resource that SpiderFoot scans (their own server banner, WHOIS record for a domain that appears as an affiliate, a document served by the target, an open bucket name, etc.) can place an HTML/JavaScript payload in a field that ends up in a correlation title. When the operator opens the Correlations tab for that scan, the payload executes in the operator's browser. SpiderFoot ships with authentication disabled by default, and the operator's session can read the Settings page, which stores plaintext API keys for many third-party services. The XSS therefore allows exfiltration of those stored API keys and full control of the authenticated UI (start/stop/delete scans, change settings).

This is a sibling-sink to an existing server-side fix: the scan data element endpoint /scaneventresults does call html.escape() on the same event values (sfwebui.py line 1775), but the correlation endpoint and its render path were missed.

Details

  1. Correlation title is built from raw event data with no escaping.

spiderfoot/correlation.py, build_correlation_title() (lines 897-927):

title = rule['headline']
if isinstance(title, dict):
    title = title['text']

fields = re.findall(r"{([a-z\.]+)}", title)
for m in fields:
    try:
        v = self.event_extract(data[0], m)[0]
    except Exception:
        self.log.error(f"Field requested was not available: {m}")
    title = title.replace("{" + m + "}", v.replace("\r", "").split("\n")[0])
return title

v is a raw scan-event value (OSINT-collected). It is substituted into the title with no HTML escaping. create_correlation() (lines 929-956) then stores this title via correlationResultCreate().

38 of the shipped correlation rules embed raw event data into the headline, e.g.:

  • correlations/cloud_bucket_open.yaml: headline: "Cloud storage bucket found open: {data}"
  • correlations/open_port_version.yaml: headline: "Software version revealed on open port: {data}" (server version banner, attacker-controlled)
  • correlations/data_from_docmeta.yaml: headline: "Interesting data was found within document meta data: '{child.data}'"
  • correlations/human_name_in_whois.yaml / email_in_whois.yaml: WHOIS fields
  • correlations/http_errors.yaml: headline: "Multiple failure HTTP codes found at {entity.data}"
  1. The server returns the title unescaped.

sfwebui.py, scancorrelations() (lines 1722-1743):

for row in corrdata:
    retdata.append([row[0], row[1], row[2], row[3], row[4], row[5], row[6], row[7]])
return retdata

row[1] is the correlation title. No html.escape() is applied to any column. Contrast the sibling endpoint scaneventresults() (lines 1771-1785), which DOES escape the same OSINT values:

retdata.append([
    lastseen,
    html.escape(row[1]),   # <- data element value escaped here
    html.escape(row[2]),
    ...
])
  1. The template injects the unescaped title into the DOM.

spiderfoot/templates/scaninfo.tmpl, browseCorrelations() (lines 459-492):

sf.fetchData('${docroot}/scancorrelations', {'id': instanceId}, function(data) {
    ...
    for (var i = 0; i < data.length; i++) {
        table += "<tr>";
        table += "<td><a style='cursor: pointer' onClick='toggleCorrelation(\"" + instanceId + "\",\"" + data[i][0] + "\")'>" + data[i][1] + "</a>&nbsp;&nbsp;";   // line 471: data[i][1] = title, raw
        table += "<i class='glyphicon glyphicon-question-sign' ... data-title=\"" + data[i][5] + "\"></i>"; // line 472
        ...
    }
    ...
    $("#mainbody").append(table);   // line 492: DOM injection of raw HTML string
});

data[i][1] (the title) is concatenated straight into an HTML string and passed to jQuery .append(), which parses and inserts it as HTML. A title containing <img src=x onerror=...> becomes a live element and the onerror handler runs.

PoC

Steps (no special configuration required; default install, default no-auth):

  1. Boot SpiderFoot: python sf.py -l 127.0.0.1:5001

  2. An attacker arranges for a scanned resource to produce an event value containing an HTML payload. For example, with the open_port_version rule, the attacker runs a service whose banner is:

    Apache/2.4<img src=x onerror=alert(document.cookie)>

    or for cloud_bucket_open, an open bucket whose name is:

    <img src=x onerror=alert(document.cookie)>evil-bucket

    When SpiderFoot scans the attacker resource and the corresponding correlation rule fires, the correlation title becomes ... open: <img src=x onerror=alert(document.cookie)>evil-bucket.

  3. The operator opens the scan and clicks the Correlations tab. The browser fetches /scancorrelations, which returns the title unescaped, and scaninfo.tmpl injects it via .append(). The onerror handler executes in the operator's session.

The full server path was confirmed end-to-end. Driving the real build_correlation_title(), the real DB layer, and the real running HTTP endpoint produced:

Server response from GET /scancorrelations?id=<scan>:

[["937e8c39...","Cloud storage bucket found open: <img src=x onerror=alert(document.cookie)>evil-bucket","cloud_bucket_open","HIGH","Open cloud bucket","An open cloud storage bucket was found.","id: cloud_bucket_open\n...",1]]

The same event value via GET /scaneventresults?id=<scan> is escaped (&lt;img src=x onerror=alert(document.cookie)&gt;evil-bucket), confirming the correlation path is the missed sink.

Feeding the real server response through the exact scaninfo.tmpl:471 concatenation yields the HTML string handed to jQuery .append():

<td><a ... onClick='toggleCorrelation("...","937e8c39...")'>Cloud storage bucket found open: <img src=x onerror=alert(document.cookie)>evil-bucket</a>...</td>

which contains a live <img src=x onerror=...> element.

Impact

Stored cross-site scripting. The attacker controls a resource that SpiderFoot collects data from (a server banner, an open bucket, a WHOIS record for a domain that shows up as an affiliate, document metadata served by the target, a hostname, etc.). The victim is the SpiderFoot operator who views the scan's correlations. Because SpiderFoot ships with authentication disabled by default and runs same-origin with the Settings page that stores plaintext third-party API keys, the executing JavaScript can read and exfiltrate those API keys and fully drive the authenticated UI (create, stop, delete scans, modify settings). The scope is changed (the injected data crosses from scan content into the operator's trusted UI origin), hence the elevated CVSS.

Affected Versions: 4.0.0 and earlier

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions