7 min read

Hands-On with OAuth Labs (Part 1): Exploiting Unstable Claims in Lab 01

Hands-On with OAuth Labs (Part 1): Exploiting Unstable Claims in Lab 01
This is a hands-on introduction to OAuth vulnerabilities. I use cyllective's oauth-labs as the target range, and take you all the way from setting up the environment (including the WSL2 pitfalls I fell into) to a full breakdown of Lab 01. It's aimed at anyone who's curious about OAuth attacks but hasn't actually pwned one yet.

Why oauth-labs

OAuth is the protocol behind almost every "Sign in with Google / GitHub" button out there. Get the implementation wrong and you're looking at account-takeover-grade bugs. But OAuth's attack surface isn't very beginner-friendly—the flow is convoluted, the terminology is dense, and just reading the spec rarely makes it click.

cyllective's oauth-labs breaks the common OAuth mistakes down into six independent labs. Each one hides exactly one bug, and you get to read the source code (white-box). For anyone who wants to build up an intuition for OAuth attacks, this is an ideal starting point.

The six labs cover the following topics:

  • Lab 00: a pure playground with no vulnerability, just to get familiar with the flow
  • Lab 01: unstable identity value (unstable claims) ← this post
  • Lab 02: open redirect from not validating redirect_uri at all
  • Lab 03: open redirect from validating only the domain
  • Lab 04: JWT signature simply not verified
  • Lab 05: improper handling of the JWT jku claim

Setup: Docker is easy, WSL2 is the real boss fight

The lab itself is easy to stand up, but on WSL2 (Kali running on Windows) I ran into a whole string of networking issues. This part is worth documenting, because any "Docker lab bound to a specific internal IP" will hit the same wall.

Standing up the labs

There are only two prep steps: edit hosts, then run make. First, write that whole list of domains from the README into /etc/hosts (it uses the 172.16.16.0/24 subnet—be careful it doesn't clash with your existing network), then:

git clone https://github.com/cyllective/oauth-labs
cd oauth-labs
make config && make docker && make labs

When docker compose ps shows a whole row of server-0x / client-0x all Up, you're good to go.

Pitfall #1: the browser is on Windows, but hosts was edited in WSL

I got stuck on my very first ERR_NAME_NOT_RESOLVED, and the cause is a classic: what I appended with sudo tee -a /etc/hosts was WSL's (Linux's) hosts file, which only programs inside Linux can resolve. But I was using the Windows browser, and it looks up Windows's own hosts file—which has no entry for oauth.labs whatsoever.

The two systems each have their own hosts file—this is one of the most common mental gaps in WSL2 development.

Pitfall #2: the service is hard-bound to a Docker-internal IP, and Windows can't reach it

Even after adding the entries to Windows's hosts file, I still couldn't connect. Going back to docker compose ps, caddy (the reverse proxy) was bound like this:

172.16.16.1:80->80/tcp, 172.16.16.1:443->443/tcp

It's bound only to 172.16.16.1—which is the Docker network's gateway. That lives inside WSL, and Windows has no route to it.

Checking the compose file confirmed it:

caddy:
  ports:
    - "172.16.16.1:80:80"
    - "172.16.16.1:443:443"

The proper fix is to make it bind to all interfaces. Strip off that IP prefix:

sed -i 's|"172.16.16.1:80:80"|"0.0.0.0:80:80"|; s|"172.16.16.1:443:443"|"0.0.0.0:443:443"|' docker-compose.yaml
make labsdown && make labs

After the restart caddy becomes 0.0.0.0:443->443, bound to all of WSL's interfaces. Then, in Windows's hosts file, point all *.oauth.labs entries at WSL's eth0 IP (find it with ip addr show eth0, usually the 172.2x.x.x one), and the Windows browser and Burp can finally connect.

Heads up: WSL's eth0 IP can change after a reboot. If you lose connectivity, just look it up again and update the Windows hosts file.

Lab 01: figure out "what the client uses to identify you"

With the environment ready, I moved into Lab 01. The objective is stated in the challenge brief: gain access to the admin user's resources, and the vulnerability theme is "unstable claims."

Before attacking anything, my habit is to fire up Burp and walk through the normal flow first—if you don't know what normal looks like, you can't tell what's abnormal.

First, look at the authorization request

I registered an account on server-01, kicked off login from client-01, and the authorization request I intercepted (after URL-decoding) looked like this:

/oauth/authorize?
  client_id=<uuid>
  code_challenge=<...>
  code_challenge_method=S256        ← PKCE is present
  redirect_uri=https://client-01.oauth.labs/callback
  response_type=code                ← authorization code flow
  scope=read:profile
  state=<...>

This is a standard authorization code flow with PKCE, and the redirect_uri is hard-coded. That tells me: the bug in this lab is not in the redirect_uri or in code interception—I need to look in a different direction.

The real clue is "the page where the client identifies you"

The actual lead showed up after a successful login, on client-01's profile page. Its response looked like this (excerpt):

<h1>Thanks for signing in!</h1>
<p>To change your profile details, please visit https://server-01.oauth.labs</p>
<label>Firstname</label> <input value="" readonly>
<label>Lastname</label>  <input value="" readonly>
<label>Email</label>     <input value="" readonly>
<p>... hrm something seems to be missing.</p>

This page hands you two explicit hints:

  1. Firstname / Lastname / Email are all empty, and it even pokes fun at itself with "something seems to be missing"—meaning the client wants to display this data but can't get it (because I didn't fill it in at registration). The client depends on these fields sent over by the server.
  2. It tells you outright to go to server-01 to change your data—meaning your identity data (claims) live over on server-01, are filled in by you, and can be changed.

Reasoning: which field gets used as "identity"?

Over on server-01's profile editor, the fields I can change are exactly those three: firstname, lastname, and email.

Of the three, which is most likely to be used by the client as the sole identifier for "who you are"?

  • firstname / lastname feel more like "display names" and generally aren't used to identify people.
  • email, on the other hand, is often treated by systems as a unique identity—plenty of sites authenticate you by email.

On top of that, the name of this lab is "unstable claims," and email happens to be a field "the user can change themselves"—the challenge title itself is hinting: the client is dumb enough to take something 'mutable' and treat it as 'identity.'

This isn't a guess out of thin air; it's four observations chained together into a conclusion: the client depends on the server's fields → those fields are user-supplied and editable → of the three, email looks most like an identifier → the challenge name confirms it. This chain of "observation → inference" is the vulnerability analysis itself.

Verify the mechanism, then hit the target

I didn't jump straight to filling in the admin's email. Instead, I ran a control experiment first: set email to a test value, save, log back into client-01, and see whether its profile changes. Sure enough, client-01's profile went from "all empty" to displaying the email I'd entered—confirming that client-01 really does identify users by email.

Prove the mechanism first, then attack the target—this order matters. If you fill in the admin email straight away and it fails, you won't be able to tell whether "email isn't the identifier at all" or "the admin email was blocked by some kind of check."

Once the mechanism was confirmed, all that was left was to change my own email to the admin's and log back in. This lab's server does no uniqueness check on email, so I could set it directly to the admin's email; the client identifies users by email, so on the next login it mistook me for admin.

The essence of this vulnerability

In one sentence:

The client took the "user-editable email" from the OAuth identity proof as the sole identity, instead of using the immutable sub; and the server never enforced email uniqueness. Put the two together, and anyone can impersonate anyone.

Why it's an OAuth vulnerability

When the OAuth flow finishes, the authorization server returns a set of user info (claims) to the client, which usually contains:

sub:   permanent, immutable internal ID (assigned by the server, out of the user's control)
email: user can change it themselves
name:  user can change it themselves

The OAuth/OIDC standard explicitly requires the client to use sub to identify the user, precisely because it's stable, unique, and outside the user's control. This lab's client cut corners and used email—an "unstable claim"—and the whole foundation of identity verification collapsed. This isn't just paper theory confined to a lab; in the real world, tons of "authenticate by email" integrations fall flat right here.

It's actually an old friend you already know

If you've done any web pentesting, you already recognize the skeleton of this bug:

The system uses a "value the user controls" to make a "security decision."
  • the role=user in your cookie, which you change to role=admin
  • the ?user_id=123 in the URL, which you change to ?user_id=1 ← this is IDOR
  • this lab's email, which you change to the admin's ← the same thing wearing an OAuth costume

So "change the email to become admin" isn't a universal formula. What's universal is the question: "This value that decides my identity/privileges—is it something I can control? If so, what happens when I set it to someone else's?" This is the same mindset as IDOR/BOLA.

Wrap-up

Lab 01 has no fancy tricks, but it makes one crucial idea crystal clear: a large part of OAuth security comes down to which field in the identity proof the client 'chooses to trust'. Pick the wrong field, and no amount of complete PKCE or rigorous flow can protect you.

This post is a personal learning log. All operations were carried out in a locally self-hosted lab environment; it contains no flags or concrete identifier values, and readers are encouraged to deploy oauth-labs and try it hands-on themselves.