srdnlen CTF 2025 | Focus. Speed. I am speed. (Web)

Difficulty : easy

This challenge consists of a web store, the objective here is to buy the Lightning McQueen's Secret Text item, since it costs 50 it's pretty much impossible for us.

There is also a redeem gift card option but we don't have a gift card.

So let's read some of the code.

App.js :

const path = require('path');
const bodyParser = require('body-parser');
const mongoose = require('mongoose');
const express = require('express');
const User = require('../models/user');
const Product = require('../models/product'); 
const DiscountCodes = require('../models/discountCodes'); 
const passport = require('passport');
const { engine } = require('express-handlebars');
const { Strategy: JwtStrategy } = require('passport-jwt');
const cookieParser = require('cookie-parser');

function DB(DB_URI, dbName) {
    return new Promise((res, _) => {
        mongoose.set('strictQuery', false);
        mongoose
            .connect(DB_URI, { useNewUrlParser: true, useUnifiedTopology: true, dbName })
            .then(() => res());
    });
}

// Generate a random discount code
const generateDiscountCode = () => {
    const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    let discountCode = '';
    for (let i = 0; i < 12; i++) {
        discountCode += characters.charAt(Math.floor(Math.random() * characters.length));
    }
    return discountCode;
};



async function App() {
    const app = express();
    app.use(passport.initialize());
    app.use(cookieParser());
    app.use(bodyParser.json());

    app.engine('hbs', engine({ extname: '.hbs', defaultLayout: 'base' }));

    app.use(express.static('static'));
    app.set('view engine', 'hbs');
    app.set('views', path.join(__dirname, '../webviews'));

    app.use('/', require('./routes'));

    passport.use('user-local', User.createStrategy());
    const option = {
        secretOrKey: process.env.JWT_SECRET,
        jwtFromRequest: (req) => req?.cookies?.['jwt'],
        algorithms: ['HS256'],
    };

    

    passport.use(
        new JwtStrategy(option, (payload, next) => {
            User.findOne({ _id: payload.userId })
                .then((user) => {
                    next(null, { userId: user._id } || false);
                })
                .catch((_) => next(null, false));
        })
    );

    const products = [
        { productId: 1, Name: "Lightning McQueen Toy", Description: "Ka-chow! This toy goes as fast as Lightning himself.", Cost: "Free" },
        { productId: 2, Name: "Mater's Tow Hook", Description: "Need a tow? Mater's here to save the day (with a little dirt on the side).", Cost: "1 Point" },
        { productId: 3, Name: "Doc Hudson's Racing Tires", Description: "They're not just any tires, they're Doc Hudson's tires. Vintage!", Cost: "2 Points" },
        { 
            productId: 4, 
            Name: "Lightning McQueen's Secret Text", 
            Description: "Unlock Lightning's secret racing message! Only the fastest get to know the hidden code.", 
            Cost: "50 Points", 
            FLAG: process.env.FLAG || 'SRDNLEN{fake_flag}' 
        }
    ];
    

    for (const productData of products) {
        const existingProduct = await Product.findOne({ productId: productData.productId });
        if (!existingProduct) {
            await Product.create(productData);
            console.log(`Inserted productId: ${productData.productId}`);
        } else {
            console.log(`Product with productId: ${productData.productId} already exists.`);
        }
    }

    // Insert randomly generated Discount Codes if they don't exist
    const createDiscountCodes = async () => {
        const discountCodes = [
            { discountCode: generateDiscountCode(), value: 20 }
        ];

        for (const code of discountCodes) {
            const existingCode = await DiscountCodes.findOne({ discountCode: code.discountCode });
            if (!existingCode) {
                await DiscountCodes.create(code);
                console.log(`Inserted discount code: ${code.discountCode}`);
            } else {
                console.log(`Discount code ${code.discountCode} already exists.`);
            }
        }
    };

    // Call function to insert discount codes
    await createDiscountCodes();

    app.use('/', (req, res) => {
        res.status(404);
        if (req.accepts('html') || req.accepts('json')) {
            return res.render('notfound');
        }
    });

    return app;
}

module.exports = { DB, App };

routes.js :

const express = require('express')
const isAuth = (req, res, next) => {passport.authenticate('jwt', { session: false, failureRedirect: '/user-login' })(req, res, next)}
const JWT = require('jsonwebtoken')
const router = express.Router()
const passport = require('passport')
const UserProducts = require('../models/userproduct'); 
const Product = require('../models/product'); 
const User = require('../models/user');
const DiscountCodes = require('../models/discountCodes')
const { v4: uuidv4 } = require('uuid');

let delay = 1.5;

router.get('/store', isAuth, async (req, res) => {
    try{
        const all = await Product.find()
        const products = []
        for(let p of all) {
            products.push({ productId: p.productId, Name: p.Name, Description: p.Description, Cost: p.Cost })
        }
        const user = await User.findById(req.user.userId);
        return res.render('store', { Authenticated: true, Balance: user.Balance, Product: products})
    } catch{
        return res.render('error', { Authenticated: true, message: 'Error during request' })
    }
})


router.get('/redeem', isAuth, async (req, res) => {
    try {
        const user = await User.findById(req.user.userId);

        if (!user) {
            return res.render('error', { Authenticated: true, message: 'User not found' });
        }

        // Now handle the DiscountCode (Gift Card)
        let { discountCode } = req.query;
        
        if (!discountCode) {
            return res.render('error', { Authenticated: true, message: 'Discount code is required!' });
        }

        const discount = await DiscountCodes.findOne({discountCode})

        if (!discount) {
            return res.render('error', { Authenticated: true, message: 'Invalid discount code!' });
        }

        // Check if the voucher has already been redeemed today
        const today = new Date();
        const lastRedemption = user.lastVoucherRedemption;

        if (lastRedemption) {
            const isSameDay = lastRedemption.getFullYear() === today.getFullYear() &&
                              lastRedemption.getMonth() === today.getMonth() &&
                              lastRedemption.getDate() === today.getDate();
            if (isSameDay) {
                return res.json({success: false, message: 'You have already redeemed your gift card today!' });
            }
        }

        // Apply the gift card value to the user's balance
        const { Balance } = await User.findById(req.user.userId).select('Balance');
        user.Balance = Balance + discount.value;
        // Introduce a slight delay to ensure proper logging of the transaction 
        // and prevent potential database write collisions in high-load scenarios.
        new Promise(resolve => setTimeout(resolve, delay * 1000));
        user.lastVoucherRedemption = today;
        await user.save();

        return res.json({
            success: true,
            message: 'Gift card redeemed successfully! New Balance: ' + user.Balance // Send success message
        });

    } catch (error) {
        console.error('Error during gift card redemption:', error);
        return res.render('error', { Authenticated: true, message: 'Error redeeming gift card'});
    }
});

router.get('/redeemVoucher', isAuth, async (req, res) => {
    const user = await User.findById(req.user.userId);
    return res.render('redeemVoucher', { Authenticated: true, Balance: user.Balance })
});

router.get('/register-user', (req, res) => {
    return res.render('register-user')
})

router.post('/register-user', (req, res, next) => {
    let { username , password } = req.body
    if (username == null || password == null){
        return next({message: "Error"})
    }
    if(!username || !password) {
        return next({ message: 'You forgot to enter your credentials!' })
    }
    if(password.length <= 2) {
        return next({ message: 'Please choose a longer password.. :-(' })
    }

    User.register(new User({ username }), password, (err, user) => {
        if(err && err.toString().includes('registered')) {
            return next({ message: 'Username taken' })
        } else if(err) {
            return next({ message: 'Error during registration' })
        }

        const jwtoken = JWT.sign({userId: user._id}, process.env.JWT_SECRET, {algorithm: 'HS256',expiresIn: '10h'})
        res.cookie('jwt', jwtoken, { httpOnly: true })

        return res.json({success: true, message: 'Account registered.'})
    })
})

router.get('/user-login', (req, res) => {
    return res.render('user-login')
})

router.post('/user-login', (req, res, next) => {
    passport.authenticate('user-local', (_, user, err) => {
        if(err) {
            return next({ message: 'Error during login' })
        }

        const jwtoken = JWT.sign({userId: user._id}, process.env.JWT_SECRET, {algorithm: 'HS256',expiresIn: '10h'})
        res.cookie('jwt', jwtoken, { httpOnly: true })

        return res.json({
            success: true,
            message: 'Logged'
        })
    })(req, res, next)
})

router.get('/user-logout', (req, res) => {
    res.clearCookie('jwt')
    res.redirect('/')
})

function parseCost(cost) {
    if (cost.toLowerCase() === "free") {
        return 0;
    }
    const match = cost.match(/\d+/); // Extract numbers from the string
    return match ? parseInt(match[0], 10) : NaN; // Return the number or NaN if not found
}

router.post('/store', isAuth, async (req, res, next) => {
    const productId = req.body.productId;

    if (!productId) {
        return next({ message: 'productId is required.' });
    }

    try {
        // Find the product by Name
        const all = await Product.find()
        product = null
        for(let p of all) {
            if(p.productId === productId){
                product = p
            }
        }

        if (!product) {
            return next({ message: 'Product not found.' });
        }

        // Parse the product cost into a numeric value
        let productCost = parseCost(product.Cost);  

        if (isNaN(productCost)) {
            return next({ message: 'Invalid product cost format.' });
        }

        // Fetch the authenticated user
        const user = await User.findById(req.user.userId);

        if (!user) {
            return next({ message: 'User not found.' });
        }

        // Check if the user can afford the product
        if (user.Balance >= productCost) {
            // Generate a UUID v4 as a transaction ID
            const transactionId = uuidv4();
            
            // Deduct the product cost and save the user
            user.Balance -= productCost;
            await user.save();

            // Create a new UserProduct entry
            const userProduct = new UserProducts({
                transactionId: transactionId,
                user: user._id,
                productId: product._id, // Reference the product purchased
            });

            await userProduct.save(); // Save the UserProduct entry

            // Add the UserProduct reference to the user's ownedproducts array
            if (!user.ownedproducts.includes(userProduct._id)) {
                user.ownedproducts.push(userProduct._id);
                await user.save(); // Save the updated user
            }

            // Prepare the response data
            const responseData = {
                success: true,
                message: `Product correctly bought! Remaining balance: ${user.Balance}`,
                product: {
                    Name: product.Name,
                    Description: product.Description,
                },
            };
            if (product.productId === 4) {
                responseData.product.FLAG = product.FLAG || 'No flag available';
            }

            return res.json(responseData);
        } else {
            return res.json({success: false, message: 'Insufficient balance to purchase this product.' });
        }
    } catch (error) {
        console.error('Error during product payment:', error);
        return res.json({success: false, message: 'An error occurred during product payment.' });
    }
});

router.get('/', (req, res, next) => {
    passport.authenticate('jwt', async (err, r) => {
        let { userId } = r
        if (!userId) {
            return res.render('home', {
                Authenticated: false
            })
        }

        try {
            // Fetch the user and populate the ownedproducts, which are UserProducts
            const user = await User.findById(userId)
                .populate({
                    path: 'ownedproducts', // Populate the UserProducts
                    populate: {
                        path: 'productId', // Populate the product details
                        model: 'Product' // The model to fetch the product details
                    }
                })
                .exec()

            // Map the owned products with product details and transactionId
            const ownedproducts = user.ownedproducts.map((userProduct) => {
                const product = userProduct.productId; // Access the populated product details
                return {
                    Name: product.Name,           // Name of the product
                    Description: product.Description, // Description of the product
                    Cost: product.Cost,             // Cost of the product
                    FLAG: product.FLAG || null,      // Flag (only exists for certain products)
                    transactionId: userProduct.transactionId // Add transactionId here
                }
            })

            return res.render('home', {
                Authenticated: true,
                username: user.username,
                Balance: user.Balance,  // Pass balance as a variable to the template
                ownedproducts: ownedproducts // Pass the products with transactionId
            })
        } catch (err) {
            console.error('Error fetching user or products:', err)
            return next(err) // Handle any errors (e.g., database issues)
        }
    })(req, res, next)
})

router.use((err, req, res, next) => {
    res.status(err.status || 400).json({
        success: false,
        error: err.message || 'Invalid Request',
    })
})

module.exports = router

We see in app.js that it creates one gift card using random characters.

We find our first vulnerability in route.js :

if (lastRedemption) {
            const isSameDay = lastRedemption.getFullYear() === today.getFullYear() &&
                              lastRedemption.getMonth() === today.getMonth() &&
                              lastRedemption.getDate() === today.getDate();
            if (isSameDay) {
                return res.json({success: false, message: 'You have already redeemed your gift card today!' });
            }
        }

        // Apply the gift card value to the user's balance
        const { Balance } = await User.findById(req.user.userId).select('Balance');
        user.Balance = Balance + discount.value;
        // Introduce a slight delay to ensure proper logging of the transaction 
        // and prevent potential database write collisions in high-load scenarios.
        new Promise(resolve => setTimeout(resolve, delay * 1000));
        user.lastVoucherRedemption = today;
        await user.save();

        return res.json({
            success: true,
            message: 'Gift card redeemed successfully! New Balance: ' + user.Balance // Send success message
        });

The delay in here can be exploited with a race condition since it has a 1.5 second delay to register the voucher as redeemed and adds the balance the account before the delay.

Now that we have our first vulnerability we need to guess the gift card, in the entity files we can see that it uses mongoose :

const mongoose = require('mongoose')

const DiscountCodeSchema = new mongoose.Schema({
    discountCode: {
        type: String,
        default: null, // Optional field for discount codes
    },
    value: {
        type: Number,
        default: 10
    }
})

module.exports = mongoose.model('DiscountCodes', DiscountCodeSchema)

this means we can do a nosql injection.

http://speed.challs.srdnlen.it:8082/redeem?discountCode[$ne]=invalid

using that would give us a value in the database different to that string.

and it works just fine.

Now we couple that with the race condition and get the flag. We can use burpsuite and send all the request in parallel.

and now we have a balance of 60 points (this might be different from person to person and depends on factors like your internet connection and your luck.), so we buy the item and get our flag.

Last updated