• Category: Web
  • Points: 200
  • Solves: 12
  • Description:

Under Construction! Please protect your head, wear a hardhat.

Service: http://web.ctf.rocks:8080


This challenge was a weird one. Initial recon (favicon, error page) told us that we are dealing with Spring, but not much more. A hint in the HTML source was the beginning of our journey:

Logging in

Since /login displayed an empty white page, and GETing or POSTing the given credentials to it did not change anything (header, cookies, content), we used our beloved requests to fire some non-form-encoded json directl at the endpoint (which is supported by requests):

data = {"username": "user", "password": "password"}
r = s.post(LOGIN, json=data)

which happily returned the following page content:


and no cookies, but the following headers:

{ ...
 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoidXNlciIsImV4cCI6MTQ5NzEzNTgzM30.5oR_EfmqpBU_m3OYMToeyZVkycn_s42gjVjxc7ZUlX0'
... }

The Authorization header basically contains a signed JSON Web Token holding the following payload:

  "user": "user",
  "exp": 1497135833

This confirms that we are now logged in as user user. Keep that in mind for later.

Looking around

The authorizedURLs field mentioned above tells us that there is another endpoint called /apis, which is the next step on our journey. Let’s chat with it.

r = s.get(URL + 'apis')


"error":"Bad Request",
"message":"Required Integer parameter 'userId' is not present",

Okay, we know from above that our userId is 52, so let us iterate over the first 100 or so ids …

for i in range(0, 100):
    payload = {"userId": i}
    r = s.get(URL + 'apis', params=payload)
    print r.text

Now that was a waste of LOC, since id 0 is the interesting one:


Hello there! Yet another endpoint. The end is near …

r = s.get(URL + 'supersecretflagresource')

But not so fast …

"message":"Access is denied",

Elevation of Privilege

Since the endpoint is associated to the admin user, we figure that we need to login as admin. But how? We don’t know the password. Should we try SQLi now? Wait. Remember the JSON Web Token from earlier?


Maybe the validation is crap and we can simply replace the payload?

  "user": "admin",
  "exp": 1497136451

Copying the header and signature from the original token, we get:


Let us send that to the server …

"error":"Internal Server Error",
"message":"JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.",

Well, at least we now know that they are probably using Java JWT (JJWT) on the server to parse & validate the tokens.

Hours later, we still did not manage to validate our manipulated json, but while reading the DefaultJwtParser code (we guessed from some exceptions that they are using this one), we noticed something:

if (base64UrlEncodedDigest != null) { //it is signed - validate the signature

What do you mean? Does this mean if no digest is present in the token, you simply do not validate anything but still accept the token? What the hell?

Can this be true? Let us try with the following token (the trailing dot needs to be there, since they literally count the dots) …


Yielding …

"error":"Internal Server Error",
"exception":"io.jsonwebtoken.exception.HeaderWithoutSignatureException","message":"JWT string is missing a signature.",

Oh, thank you friendly exception, thanks for letting us know. One more try with this token (note the leading dot) …


And here we go:

{"message":"You are close now",
"script":"function getFlag() {   var text = $('.c-intro').innerText;   return 'SCTF{' + text.slice(35,38) + text.slice(0,10) + '}';}",

Since it appeared weird to us that this was possible with an actual library (likely in the most up to date version), we did some more research to figure out what actually happened. Reading closed issues on GitHub is always a fun thing to do, and apparently the answer by a maintainer to this issue did the job:

Don’t call parse if you know it is a signed token. Call parseClaimsJws.

We think it is a dangerous thing to have the less-secure version as the default. Other people also share this opinion, and apparently there is a change on the way.

The Flag

Retrieving the actual flag after this “trick” was straight forward. Since the website mentioned in the response was in UTF8 encoded Swedish, and UTF8 in Python is never fun, we simply fired up the JavaScript console, pasted the given script, and done.

 function getFlag() {
   var text = $('.c-intro').innerText;
   return 'SCTF{' + text.slice(35,38) + text.slice(0,10) + '}';

Thanks for the flag & interesting challenge.