Knock-knock | DiceCTF 2022

Knock-knock | DiceCTF 2022

[+] 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 file package.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 file package.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 khi data được user nhập vào thì nó được truyền vào hàm createNote của object db - 1 instance của class Database. Check xem hàm createNote 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 array notes, mỗi note do user tạo ra sẽ được đẩy vào array này: this.notes.push(data);, mỗi lần data đượ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àm createNote này không chỉ được gọi trong api POST /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). Thay id ở bước Initial reconnaissance thành 0 thì:

  • Vấn đề còn nằm ở token. Mỗi id khác nhau sẽ có một token 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ến id. Đã sử dụng sha256 lại còn random key của hàm băm với crypto.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ằng secret-${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ằng secret-${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àm crypto.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ả để generate token 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}