[+] Source
1. Initial reconnaissance:
- Ở challenge này chúng ta có một web app tên là "knock-knock" trông khá giống pastebin:
- Nhập vào bất kỳ cái gì như trên rồi ấn "Create", "knock-knock" sẽ tạo ra cho chúng ta một cái note với nội dung là những gì chúng ta vừa nhập vào. Mỗi cái note như thế này sẽ được đánh dấu bởi 2 url parameter: "id" và "token".
- Mỗi lần tạo 1 note mới thì "id" sẽ cộng thêm 1, còn "token" có vẻ như sẽ được generate ngẫu nhiên, như vậy thì các note khác nhau sẽ có "id" và "token" khác nhau.
2. Review source code:
- Chúng ta sẽ check source code để xem "id" và "token" được generate như thế nào. Đầu tiên là
Dockerfile
:
FROM node:17.4.0-buster-slim
RUN mkdir -p /app
WORKDIR /app
COPY package.json .
RUN yarn
COPY . .
USER node
CMD ["node", "index.js"]
Dockerfile
cho chúng ta biết "Knock-knock" được code bằng NodeJS bản 17.4.0.COPY package.json .
có nghĩa là docker sẽ copy filepackage.json
từ source vào container. Trong source mà tác giả đã cho không cópackage.json
nên chúng ta đành tự tạo vậy.package.json
là file cấu hình của npm, giúp cho npm hiểu nó cần phải cài đặt cái gì, thông tin về ứng dụng, phiên bản,… Bạn có thể tham khảo tìm hiểu sâu hơn về file này tại đây để biết cách viết nó hoặc lấy filepackage.json
mẫu ở đây như mình (vì mình lười xD).Sau đó là nội dung chính của challenge này
index.js
. Về cơ bản cái app "knock-knock" này chỉ có 2 API chính làPOST /create
dùng để tạo note vàGET /note
dùng để xem note:
app.post('/create', (req, res) => {
const data = req.body.data ?? 'no data provided.';
const { id, token } = db.createNote({ data: data.toString() });
res.redirect(`/note?id=${id}&token=${token}`);
});
app.get('/note', (req, res) => {
const { id, token } = req.query;
const note = db.getNote({
id: parseInt(id ?? '-1'),
token: (token ?? '').toString(),
});
if (note.error) {
res.send(note.error);
} else {
res.send(note.data);
}
});
- Chúng ta đang tìm hiểu xem "id" và "token" được generate như thế nào, do đó tập trung vào
POST /create
trước. Sau khidata
được user nhập vào thì nó được truyền vào hàmcreateNote
của objectdb
- 1 instance của classDatabase
. Check xem hàmcreateNote
hoạt động như thế nào:
class Database {
constructor() {
this.notes = [];
this.secret = `secret-${crypto.randomUUID}`;
}
createNote({ data }) {
const id = this.notes.length;
this.notes.push(data);
return {
id,
token: this.generateToken(id),
};
}
// ...
}
- Biến
id
sẽ được gán bằng độ dài của arraynotes
, mỗi note do user tạo ra sẽ được đẩy vào array này:this.notes.push(data);
, mỗi lầndata
được đẩy vào độ dài array tăng thêm 1, có nghĩa là id cũng tăng thêm 1. Cơ mà khoan đã, hàmcreateNote
này không chỉ được gọi trong apiPOST /create
, nó còn được gọi ngay từ đầu trước cả khi client gọi đến API này:
const db = new Database();
db.createNote({ data: process.env.FLAG });
- Điều này có nghĩa flag chắc chắn sẽ nằm ở note có
id=0
(note đầu tiên). Thayid
ở bước Initial reconnaissance thành 0 thì:
- Vấn đề còn nằm ở
token
. Mỗiid
khác nhau sẽ có mộttoken
khác nhau, nó được tạo ra như sau:
generateToken(id) {
return crypto
.createHmac('sha256', this.secret)
.update(id.toString())
.digest('hex');
}
- Tác giả sử dụng hàm băm sha256 với key là
secret-${crypto.randomUUID}
, data cần được hash chính là biếnid
. Đã sử dụng sha256 lại còn random key của hàm băm vớicrypto.randomUUID
nữa, nhìn có vẻ rất là "hard core crypto", nhưng lỗ hổng lại nằm chính ngay đây xD
this.secret = `secret-${crypto.randomUUID}`;
- Nếu
secret
được gán bằngsecret-${crypto.randomUUID()}
thì bài này "no hope", vì UUID random nên token được generate ra cũng random:
- Nhưng
secret
được gán bằngsecret-${crypto.randomUUID}
nên:
crypto.randomUUID
khi concat với string "secret-" sẽ trả về 1 string rất dài là implementation của hàmcrypto.randomUUID
. Mà cái string này cố định chứ không random nên chuỗi hash sau khi băm số "0" cũng sẽ cố định luôn, hàm băm sha256 sẽ trở nên vô dụng.
3. Generate token and get flag:
- Ý tưởng khai thác của chúng ta sẽ là sử dụng chính hàm
generateToken
của tác giả để generatetoken
từid
có giá trị là 0, sau đó truy cập vào note cóid=0
cùng với token vừa mới được tạo ra thì nó chắc chắn sẽ in ra flag thay vì "invalid token".
const crypto = require('crypto');
secret = `secret-${crypto.randomUUID}`;
function generateToken(id) {
return crypto
.createHmac('sha256', this.secret)
.update(id.toString())
.digest('hex');
}
console.log(generateToken(0))
- Vì máy của mình đã có sẵn node nên mình định copy nguyên đoạn code trên 1 phát lấy token ăn luôn, cơ mà không dễ như vậy :)
- Sau một hồi quay cuồng thì mình phát hiện ra lí do khá là ngớ ngẩn. Theo Dockerfile thì tác giả đang sử dụng node bản 17.4.0, còn của mình là bản 14.17.6. Mà mỗi bản node khác nhau thì hàm
crypto.randomUUID
sẽ được implement khác nhau, do đó key sẽ khác nhau.
- Chúng ta cần phải chạy đúng bản node của tác giả thì mới ra đúng key. Cơ mà tải node về khá là lâu, nên mình quyết định chạy docker cho nhanh. Chúng ta chỉ tạo 1 folder có đủ từng này file mô phỏng theo như đề bài, ở đây mình đặt tên folder là "knock-knock":
- Sau đó
docker build -t knock-knock .
tại chính folder "knock-knock" này để tạo image "knock-knock", sau đódocker run knock-knock
để tạo một container từ image này. Lưu ý trong quá trình tạo container các bạn có thể gặp bug như sau:
- Bug trên xảy ra là do chúng ta chưa cài module "express" cho cái app này. Để cài module này thì tại file
package.json
"tự chế" chúng ta cần bổ sung thêm 1 trường như sau:
"dependencies": {
"express": "^4.17.2"
}
- Fix nốt bug kia là chúng ta đã có thể chạy một container ngon nghẻ:
- Sau đó
docker exec -it 4336f31704f1 /bin/bash
để vào shell của container, dùng node cli để chạy exploit code phía trên và lấy token:
- Truy cập và lấy flag:
Flag: dice{1_d00r_y0u_d00r_w3_a11_d00r_f0r_1_d00r}