Skip to main content

Free 30-min security demo Book Now

Offensive360 Offensive360
ZeroDays CVE-2026-72831
High CVE-2026-72831 CVSS 8.8 Grav CMS PHP

Flex Objects API Auth Bypass in Grav CMS

CVE-2026-72831 is a broken object-level authorization flaw in Grav's Flex Objects plugin that lets low-privilege admins hijack super-admin accounts.

Offensive360 Research Team
Affects: Flex Objects <= 1.4.6 (Grav <= 2.0.11)
Source Code View Patch

Overview

CVE-2026-72831 is a broken object-level authorization vulnerability residing in the Flex Objects plugin for Grav CMS, affecting all releases through version 1.4.6 (tested against Grav 2.0.11). The flaw lives inside FlexApiController::update(), the generic REST handler that services write requests routed through /api/v1/flex-objects/{type}. Unlike the dedicated UsersApiController and GroupsApiController — which enforce a layered permission model including api.users.write and admin.super gating — the generic controller performs only a coarse directory-level permission check, creating a silent bypass for every object type that ships with a Flex directory, including user-accounts and user-groups.

The practical consequence is severe: any authenticated account holding api.access, admin.login, and users.update permissions — a common configuration for delegated content editors or restricted site managers — can issue a crafted PUT request to overwrite a super-administrator’s password or inject admin.super into a group the attacker already belongs to. Neither action requires knowing the target’s current credentials, and neither triggers the safety checks that the purpose-built user API controllers would ordinarily apply. The attack path is deterministic, requires no race condition, and leaves minimal forensic noise compared to traditional privilege-escalation vectors.

Grav is a widely deployed flat-file CMS used across government, education, and enterprise documentation portals. Any multi-tenant installation — hosting agency dashboards, intranet knowledge bases, or customer portals — where operator-level accounts are granted to third parties should treat this as a critical operational risk and upgrade to Flex Objects 1.4.7 immediately.

Technical Analysis

The root cause is a classic case of inconsistent authorization enforcement across parallel API surfaces. Grav’s Flex framework exposes two distinct code paths for mutating Flex objects via the REST API:

  1. Type-specific controllers (UsersApiController, GroupsApiController) — these apply a full permission matrix before any write proceeds.
  2. The generic fallback controller (FlexApiController) — this checks only whether the caller has general Flex directory access for the requested type.

The vulnerable update() method in FlexApiController performs a single permission check:

// FlexApiController.php — VULNERABLE (Flex Objects <= 1.4.6)
public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
    $type   = $args['type'] ?? '';
    $key    = $args['key']  ?? '';

    /** @var FlexDirectory $directory */
    $directory = $this->getFlexDirectory($type);

    // Only checks generic Flex directory ACL — e.g. "user-accounts" write
    if (!$this->checkPermission($directory, 'write')) {
        return $this->createErrorResponse(403, 'Access denied');
    }

    $data   = $this->getPostData($request);
    $object = $directory->getObject($key);

    if (!$object) {
        return $this->createErrorResponse(404, 'Object not found');
    }

    // No check for: api.users.write, admin.super gating, field-level restrictions,
    // or whether the caller is attempting to modify a higher-privilege account.
    $object->update($data);
    $object->save();

    return $this->createSuccessResponse($object->jsonSerialize());
}

By contrast, the dedicated UsersApiController::update() applies the full guard sequence before touching any data:

// UsersApiController.php — CORRECT (reference implementation)
public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
    $username = $args['username'] ?? '';

    // Requires explicit api.users.write permission
    if (!$this->isAllowed('api.users.write')) {
        return $this->createErrorResponse(403, 'Insufficient permissions');
    }

    $targetUser = $this->getUserAccount($username);

    // Prevents non-super-admin callers from modifying super-admin accounts
    if ($targetUser->isSuperAdmin() && !$this->user->authorize('admin.super')) {
        return $this->createErrorResponse(403, 'Cannot modify super administrator');
    }

    $data = $this->getPostData($request);

    // Field-level filter: strips sensitive fields unless caller is super-admin
    if (!$this->user->authorize('admin.super')) {
        unset($data['groups'], $data['access']);
    }

    $targetUser->update($data);
    $targetUser->save();

    return $this->createSuccessResponse($targetUser->jsonSerialize());
}

An attacker bypasses all of that hardened logic by simply routing their request to /api/v1/flex-objects/user-accounts/{target-username} instead of /api/v1/users/{target-username}. The Flex router resolves user-accounts to the same underlying UserAccount Flex directory, so $object->update($data) ultimately writes to the same YAML file — but without any of the protective guards.

The group-escalation variant works identically: PUT /api/v1/flex-objects/user-groups/{group-slug} with a body containing {"access": {"admin": {"super": true}}} promotes the target group to super-admin status. Any account in that group immediately inherits unrestricted site access on next login.

Impact

An attacker with a low-privilege administrative account — roles commonly granted to content editors, translators, or agency sub-contractors — can achieve full site takeover without any user interaction from a privileged account. Concretely:

  • Password reset on any account: The attacker can set a known password on the primary super-administrator account and log in directly.
  • Group privilege escalation: Granting admin.super to an attacker-controlled group is persistent across updates and survives credential rotations on other accounts.
  • Data exfiltration and persistence: Post-compromise, the attacker has unrestricted access to all site content, plugin configuration, API keys stored in site YAML, and the underlying filesystem through Grav’s admin file manager.

The CVSS 8.8 (High) score reflects the network-reachable attack vector, low complexity, and high impact across confidentiality, integrity, and availability, offset only by the requirement for an authenticated starting position (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H).

How to Fix It

Upgrade immediately. The authoritative fix is to update the Flex Objects plugin to version 1.4.7. On a standard Grav installation:

# Via Grav Package Manager (GPM)
bin/gpm update flex-objects

# Or via the Grav Admin panel:
# Plugins → Flex Objects → Update to 1.4.7

The patch enforces type-aware permission delegation inside FlexApiController::update(). When the target directory is user-accounts or user-groups, the generic controller now delegates to — or replicates — the full guard chain from the type-specific controllers:

// FlexApiController.php — PATCHED (Flex Objects >= 1.4.7, simplified)
public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
    $type      = $args['type'] ?? '';
    $directory = $this->getFlexDirectory($type);

    if (!$this->checkPermission($directory, 'write')) {
        return $this->createErrorResponse(403, 'Access denied');
    }

    // Delegate to type-specific authorization when a dedicated handler exists
    $typeController = $this->resolveTypeController($type);
    if ($typeController !== null) {
        return $typeController->update($request, $response, $args);
    }

    // Generic path only reached for types without dedicated controllers
    $data   = $this->getPostData($request);
    $key    = $args['key'] ?? '';
    $object = $directory->getObject($key);

    if (!$object) {
        return $this->createErrorResponse(404, 'Object not found');
    }

    $object->update($data);
    $object->save();

    return $this->createSuccessResponse($object->jsonSerialize());
}

If upgrading immediately is not feasible, disable the Flex Objects REST API at the plugin configuration level (flex-objects.yaml: api.enabled: false) as an interim mitigation. Also audit all operator-class accounts for the users.update permission and revoke it from any account that does not strictly require it.

Our Take

This vulnerability is a textbook example of what happens when a framework provides multiple API surfaces for the same underlying resource but applies security controls only on the “primary” surface. Generic CRUD endpoints are a persistent blind spot in CMS and framework security: developers build them for convenience, apply a reasonable-seeming top-level check, and assume the type-specific controllers handle the edge cases — never accounting for callers who route around them entirely.

From an enterprise security posture perspective, this class of flaw — CWE-863 (Incorrect Authorization) / OWASP API Security Top 10: API5 Broken Function Level Authorization — is particularly dangerous because it doesn’t look wrong at the code level in isolation. The generic controller is doing something for authorization. The problem is only visible when you reason about the full topology of routes that reach the same data, which is exactly the kind of cross-function analysis that manual code review frequently misses under time pressure.

Organizations running multi-tenant Grav deployments with delegated admin accounts should treat any update to permission-sensitive API controllers as a mandatory security review gate, not a routine maintenance item.

Detection with SAST

SAST tools detect this class of vulnerability by modeling authorization enforcement as a data-flow property rather than a syntactic pattern. Specifically, Offensive360’s engine flags cases matching the following profile under CWE-863 and CWE-285:

  • A controller method that writes to a privileged object type (user accounts, role/group records, permission stores) reachable via a generic route parameter (e.g., $args['type']) without a type-discriminating authorization branch prior to the write call.
  • Authorization coverage gap: the tool maps every code path from authenticated HTTP entry points to ->save() / ->update() calls on Flex objects and checks whether the path passes through a permission assertion that is at least as restrictive as the most constrained type-specific controller serving the same resource.
  • Route topology analysis: the engine builds a route-to-handler graph and flags any route that can resolve to a sensitive resource type but shares a handler with routes lacking type-specific guards.

Custom rules targeting Grav’s Flex framework should specifically watch for FlexDirectory::getObject() followed by ->update() or ->save() calls in any controller that accepts the directory type as a user-controlled argument without branching on type before the write.

References

#broken-access-control #authorization-bypass #privilege-escalation #cms

Detect this vulnerability class in your codebase

Offensive360 SAST scans your source code for CVE-2026-72831-class vulnerabilities and thousands of other patterns — across 60+ languages.