Skip to content

Instantly share code, notes, and snippets.

@ginkoid
Created July 18, 2022 22:53
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ginkoid/46a6baf9b2479019359af96f50db92e8 to your computer and use it in GitHub Desktop.
Save ginkoid/46a6baf9b2479019359af96f50db92e8 to your computer and use it in GitHub Desktop.
GPUShop2 Writeup

GPUShop2 Writeup

We're given two websites:

  • gpushop2: a shop built with Laravel where customers can buy the flag using ETH.
  • paymeflare: the shop's reverse proxy and payment processor, built with Laravel and HAProxy.

The flag costs 1337 ETH. At current prices, that's about 2 million US dollars. We unfortunately aren't that rich, so we'll need to find another way to pay for our flag.

Flag item

We can register for paymeflare and add our own sites to it. paymeflare functions as a reverse proxy; it adds a secret token (unique for each site) as the x-pay request header. To verify that a request passed through paymeflare, gpushop2 checks the header value. So, if we can find this value, we can impersonate paymeflare!

Googler Impersonation

We login to paymeflare with Google OAuth. The app identifies users with a Google JWT in the authorization header. Before hitting Laravel, each request to paymeflare passes through HAProxy, which validates the JWT signature using Google's RSA key.

Interestingly, Laravel then just blindly trusts the pre-validated token in the header without completing any of its own signature verification:

$parts = explode('.', $token);
$data = base64_decode(str_replace(['_', '-'], ['/', '+'], $parts[1]));
$data = json_decode($data, true);

$user = $this->provider->createModel();
$user->id = $data['sub'];

The Bug

We need to find a way to bypass this. Optimally, we would want a way to send two tokens: one which HAProxy reads and another which Laravel reads. If we manage to figure that out, we can let HAProxy validate a properly signed token while making Laravel decode our malicious token.

It happens that, when we send two values joined by a comma for the authorization header, we get exactly this!

authorization: Bearer .base64({ sub: <anything we want> })., Bearer <valid JWT from Google>

Our request to paymeflare now looks like this:

sequenceDiagram
    participant Client
    participant HAProxy
    participant Laravel
    Client->>HAProxy: Authorization: Bearer abc, Bearer xyz
    note left of HAProxy: Validates signature on xyz
    HAProxy->>Laravel: Authorization: Bearer abc, Bearer xyz
    note left of Laravel: Reads user info from abc

Now that we can authenticate as anyone, we need to figure out who to impersonate. Remember that the goal here is to get the x-pay secret for the gpushop2 site. So, we need to find out who owns the gpushop2 site. After that, we can craft a authorization header and grab the x-pay.

The Googler

Our answer comes in the form of the OAuth consent modal. When logging into paymeflare, we can click to find out who registered the OAuth app:

OAuth consent developer info

This is probably the challenge author! With a bit of luck, the author used their own Google account to register gpushop2 for paymeflare.

To test this, we'll need to construct a JWT with the correct Google person ID for epuig@google.com. We can look this up by pasting the email into Google Hangouts:

Google Hangouts user lookup

And in DevTools, we see:

{
  "results": [
    {
      "suggestion": "epuig@google.com",
      "objectType": "PERSON",
      "person": {
        "personId": "113062232211527114766",
...

The Impersonation

Now, we just need to put it all together. Let's make the authorization header for the account in the OAuth consent screen:

payload = json.dumps({
    "sub": "113062232211527114766",
    "email": "epuig@google.com",
    ...
})
our_jwt = "eyJ..." # from Google
print(f"Bearer .{base64.urlsafe_b64encode(payload)}., Bearer {our_jwt}")

We can now send our request:

GET /api/account HTTP/1.1
host: paymeflare-web.2022.ctfcompetition.com
authorization: ...

And paymeflare sends us the secret x-pay value for gpushop2!

{
  "user": {
    "id": "113062232211527114766",
    "email": "epuig@google.com",
    ...
  },
  ...
  "host": "gpushop2.2022.ctfcompetition.com",
  "ip": "10.126.173.148:1337",
  "secret": "d5180066901cd592b26c2b84f261bb37ce3aa3c8e0199781b6240bb9396ea48d"
}

Buying the Flag

When a customer checks out, paymeflare adds a x-wallet header to the request before forwarding it. This header contains a newly generated Ethereum address; once the customer sends enough ETH to this address, the order is considered paid.

Order payment

If we can set the x-wallet header to an address with a balance greater than 1337 ETH (such as 0x000...000), our order for the flag will be marked as paid.

Theoretically, we can now just send our request with the correct headers to the gpushop2 origin, right?

POST /cart/checkout HTTP/1.1
x-pay: d51...48d
x-wallet: 000...000

Nope! The server is only directly accessible at its internal 10.0.0.0/8 IP, so we'll need to find another way to send a request to it.

kCTF Spelunking

Maybe we can send a request to the gpushop2 origin from a different challenge? Google hosts their CTF using their own Kubernetes-based CTF platform, kCTF. It's open source, so we can take a look at how it does network isolation:

networkingv1.NetworkPolicySpec{
    ...
    Egress: []networkingv1.NetworkPolicyEgressRule{{
        To: []networkingv1.NetworkPolicyPeer{{
            IPBlock: &networkingv1.IPBlock{
                CIDR: "0.0.0.0/0",
                Except: []string{/* ... */, "10.0.0.0/8", /* ... */},
            },
        }},
    }},
},

It seems like they've already thought of this attack and blocked it! It appears that our only way to the gpushop2 origin is through paymeflare's HAProxy.

h2c Smuggling

We now need to find a way to smuggle our x-pay and x-wallet headers across what looks like an impossible barrier:

http-request set-header X-Wallet EMPTY
http-request set-header X-Wallet %[var(txn.wallet)] if is_checkout
http-request set-header X-Pay %[req.hdr(host),lower,word(1,:),hmac(sha256,req.secret),hex,lower]

HAProxy seems to unconditionally reset the x-pay and x-wallet headers to its own values before passing along each request.

In addition to HAProxy, each request passes through Varnish before finally reaching Laravel:

sequenceDiagram
    participant Client
    participant HAProxy
    participant Varnish
    participant Laravel
    Client->>HAProxy: GET / HTTP/1.1
    HAProxy->>Varnish: GET / HTTP/1.1
    Varnish->>Laravel: GET / HTTP/1.1

After searching for hours, we eventually stumbled upon some research into smuggling requests with the cleartext variant of HTTP/2, h2c.

It seems like this attack will work! HAProxy is one of the few reverse proxies that accepts incoming h2c upgrades by default, and the challenge explicitly enables HTTP/2 support in Varnish:

set -- varnishd \
  -F -f /etc/varnish/default.vcl \
  -a proxy=:8443,PROXY \
  -p feature=+http2

To establish an h2c connection, we need to start with a HTTP/1.1 connection and then upgrade it:

GET / HTTP/1.1
Upgrade: h2c
HTTP2-Settings: AAMAAABkAARAAAAAAAIAAAAA
Connection: Upgrade, HTTP2-Settings

Once we send a request with these magic headers, Varnish responds with a HTTP/1.1 101 Switching Protocols. HAProxy then blindly forwards the rest of the TCP connection between the client and Varnish. We now have a direct tunnel to Varnish and can send our own request with our own x-pay and x-wallet headers!

Using a modified version of BishopFox's h2csmuggler, we can now send our POST /cart/checkout request from earlier.

Our request and response now look like this:

sequenceDiagram
    participant Client
    participant HAProxy
    participant Varnish
    participant Laravel
    Client->>HAProxy: Upgrade: h2c
    HAProxy->>Varnish: Upgrade: h2c
    Varnish->>HAProxy: HTTP/1.1 101 Switching Protocols
    HAProxy->>Client: HTTP/1.1 101 Switching Protocols
    Client-->Varnish: HTTP/2 Tunnel
    Client->>Varnish: POST /shop/checkout HTTP/2
    Varnish->>Laravel: POST /shop/checkout HTTP/1.1
    Laravel->>Varnish: CTF{...}
    Varnish->>Client: CTF{...}

And our flag order is now marked as paid!

Getting the flag


Philip Papurt (g@gnk.io) & Larry Yuan (contact@larry.sh)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment