Хабрахабр

Реверс-инжиниринг протокола ngrok v2


ngrok — это сервис, позволяющий создавать туннели на локальный компьютер пользователя. Иными словами, резервируется публичный адрес, все обращения по которому пробрасываются на локальный порт.

К сожалению, с 2016 года поддержка open-source версии клиента (ngrok v1) прекращена, и чтобы воспользоваться сервисом, нужно запустить закрытую версию (ngrok v2), что во многих случаях неприемлемо. Данная статья описывает процесс изучения протокола, используемого официальным клиентом, и создания альтернативного открытого клиента.

А нужно ли оно? Альтернативы ngrok


Как ни странно, у данного сервиса очень мало альтернатив. Конкретно, три:

  • serveo.net. Предоставляет аналогичный функционал, но использует SSH reverse port forwarding, а не кастомный клиент. К сожалению, в настоящее время проект закрыт.

    Serveo is temporarily disabled due to phishing.

    Serveo will return in a few days with a few new restrictions to help dissuade abuse. Thanks for your patience!

  • localtunnel.me. Предоставляет только HTTP-туннель с распределением на основе заголовка Host, причем в случае HTTPS данные расшифровываются на сервере и идут в клиентское приложение открытым текстом. В настоящее время сайт проекта недоступен.
  • pagekite.net. Предоставляет HTTP- и TLS-туннели. После 30-дневного пробного периода за дальнейшее использование придется заплатить.

(P.S. В комментариях подсказали, что существует localhost.run, который предоставляет HTTP-туннели через SSH port forwarding, аналогично serveo.net. Однако, судя по всему, сервис предоставляет только HTTP-туннели, в отличие от ngrok)

Naive attempt #1: mitmproxy


Попробуем прослушать трафик официального приложения с помощью mitmproxy:

$ mitmproxy$ http_proxy=http://127.0.0.1:8080 https_proxy=http://127.0.0.1:8080 ngrok http 8080 # в другом окне терминала

Приложение, естественно, начинает ругаться на невалидный сертификат. Однако в тексте ошибки видно, что ngrok пытается отрезолвить адрес сервера tunnel.us.ngrok.com через DNS-over-HTTPS:

Get https://dns.google.com/resolve?cd=true&name=tunnel.us.ngrok.com&type=AAAA: x509: certificate signed by unknown authority

Попробуем дернуть сам tunnel.us.ngrok.com:

$ curl https://tunnel.us.ngrok.com/curl: (60) SSL certificate problem: unable to get local issuer certificateMore details here: https://curl.haxx.se/docs/sslcerts.html curl failed to verify the legitimacy of the server and therefore could notestablish a secure connection to it. To learn more about this situation andhow to fix it, please visit the web page mentioned above.

Видимо, клиент использует certificate pinning с самоподписанным сертификатом. Попробуем игнорировать ошибку:

$ curl -k https://tunnel.us.ngrok.comWarning: Binary output can mess up your terminal. Use "--output -" to tell Warning: curl to output it to your terminal anyway, or consider "--output Warning: <FILE>" to save to a file.$ curl -k --output - https://tunnel.us.ngrok.com����-illegal WNDINC frame length: 0x474554

Google по запросу «illegal WNDINC frame length» выдает библиотеку для Go для мультиплексирования TCP-соединений. Эта же библиотека упоминается в issue с призывом открыть исходники ngrok v2.

Библиотека muxado


Проверим, действительно ли ngrok использует библиотеку muxado:

$ nm ./ngrok | grep muxado00000000008ae2c0 T github.com/inconshreveable/muxado.(*addr).Network00000000008ae2e0 T github.com/inconshreveable/muxado.(*addr).String0000000000e31b40 B github.com/inconshreveable/muxado.bufferClosed0000000000e31b50 B github.com/inconshreveable/muxado.bufferFull00000000008ad430 T github.com/inconshreveable/muxado.Client0000000000e31b60 B github.com/inconshreveable/muxado.closeError00000000008b4c00 T github.com/inconshreveable/muxado.(*condWindow).Broadcast00000000008b2ed0 T github.com/inconshreveable/muxado.(*condWindow).Decrement00000000008b2da0 T github.com/inconshreveable/muxado.(*condWindow).Increment00000000008b2d30 T github.com/inconshreveable/muxado.(*condWindow).Init...

Из вывода этой команды можно сделать несколько выводов (простите за тавтологию):

  1. ngrok действительно использует данную библиотеку.
  2. Автор не пытался как-либо обфусцировать исполняемый файл, так как в нем оставлены символы.

Также заметим, что ошибка от сервера была получена по защищенному (TLS) соединению, что означает, что протокол muxado используется внутри TLS-сессии. Это позволяет предположить, что поверх muxado данные передаются открытым текстом, так как дополнительное шифрование было бы избыточным. Таким образом, чтобы снять незашифрованный дамп траффика, достаточно перехватить вызовы (*stream).Read и (*stream).Write.

ABI


Прежде чем пытаться перехватывать вызовы, нужно понять, как передаются интересующие нас параметры. Для этого напишем простую программу на Go, использующую библиотеку (в качестве принимающей стороны будет выступать netcat):

Код

package main import "net"import "github.com/inconshreveable/muxado" func main() { var conn net.Conn conn, _ = net.Dial("tcp", "127.0.0.1:1234") sess := muxado.Client(conn, &muxado.Config{}) conn, _ = sess.Open() data := []byte("Hello, world!") conn.Write(data)}

Итак, для перехвата траффика нас интересуют:

  • Уникальный идентификатор потока (нужен для того, чтобы различать несколько одновременно активных потоков).
  • Указатель на буфер с данными.
  • Длина буфера с данными.

В выводе objdump на функции github.com/inconshreveable/muxado.(*stream).Write (Забавно, что разработчики Go, похоже, не заморачивались с name mangling.) отчетливо видна загрузка аргументов со стека:

 4de2d6: 48 8b 44 24 58 mov 0x58(%rsp),%rax 4de2db: 48 89 44 24 08 mov %rax,0x8(%rsp) 4de2e0: 48 8b 44 24 60 mov 0x60(%rsp),%rax 4de2e5: 48 89 44 24 10 mov %rax,0x10(%rsp) 4de2ea: 48 8b 44 24 68 mov 0x68(%rsp),%rax 4de2ef: 48 89 44 24 18 mov %rax,0x18(%rsp)

Осталось понять, где именно на стеке лежат нужные нам значения. Для этого воспользуемся gdb и выведем состояние стека на момент вызова функции.

Thread 1 "test" hit Breakpoint 1, github.com/inconshreveable/muxado.(*stream).Write (buf=..., err=..., n=<optimized out>, s=<optimized out>) at /home/sergey/muxado/src/github.com/inconshreveable/muxado/stream.go:8181 func (s *stream) Write(buf []byte) (n int, err error) {(gdb) set language cWarning: the current language does not match this frame.(gdb) p {char*[4]}$rsp$1 = { 0x4e0cbf <main.main+319> "H\213l$XH\203\304`\303\350\002A\367\377\351\255\376\377\377", '\314' <repeats 13 times>, "dH\213\f%\370\377\377\377H;a\020vKH\203\354\bH\211,$H\215,$\017\266\005k\003\025", 0xc0000b4000 "", 0xc000014300 "Hello, world!", 0xd <error: Cannot access memory at address 0xd>}

Первый элемент данного массива — адрес возврата, и в передаче аргументов он принимать участие не может. Два последних, очевидно, представляют из себя адрес массива и его длину; так как указатель на поток идет в списке аргументов первым, логично предположить, что именно он находится во второй ячейке. Его можно (с оговорками) использовать в качестве уникального идентификатора потока.

Итак, теперь мы знаем, как расположены в памяти аргументы функции (*stream).Write (для (*stream).Read всё точно так же, так как у функций одинаковый прототип). Осталось реализовать сам перехват.

Naive attempt #2: runtime function hooks


Попробуем перенаправить вызовы (*stream).Write в функцию-прокси:

Примерно так

unsigned long long write_hook(){ volatile long long* rsp = RSP(); void* stream = (void*)rsp[1]; char* buf = (char*)rsp[2]; long long len = rsp[3]; UNSET_HOOK(); unsigned long long ans; PUSH(rsp[3]); PUSH(rsp[2]); PUSH(rsp[1]); CALL(syscall_write); POP(24, ans); SET_HOOK(); return ans;}

При попытке вызвать ngrok с данным хуком получаем краш следующего вида:

unexpected fault address 0x0fatal error: fault[signal SIGSEGV: segmentation violation code=0x80 addr=0x0 pc=0x4a2dc6] goroutine 1 [running]:runtime.throw(0xac7ca8, 0x5) /usr/local/Cellar/go/1.8.3/libexec/src/runtime/panic.go:596 +0x95 fp=0xc42016f9f0 sp=0xc42016f9d0runtime.sigpanic()...

Тут нас ждет неожиданное препятствие в лице goroutines. Дело в том, что стек под горутины выделяется динамически: при недостатке места в существующем стеке он выделяется заново в другом месте, и текущее содержимое копируется. К сожалению, функции, генерируемые gcc, сохраняют старый указатель стека в регистре rbp (т.н. frame pointer), и при возврате из такой функции указатель стека начинает указывать на уже освобожденный старый стек (use-after-free). Таким образом, C тут не помощник.

Attempt #3: gdb script


Напишем скрипт для gdb, который будет распечатывать все передаваемые данные:

set language cbreak github.com/inconshreveable/muxado.(*stream).Writecommands set $stream={void*}($rsp+8) set $buf={char*}($rsp+16) set $len={long long}($rsp+24) p $stream p (*$buf)@$len p $len contend run tcp 8080 -log stdout -log-format logfmt -log-level debug

Это работает, но для полноценного дампа нужно сохранять и принимаемые данные. И тут возникает несколько проблем:

  • Чтобы прочитать принятые данные, нужно дождаться, пока функция завершит выполнение. Эта проблема решается установкой breakpoint'а на инструкцию ret.
  • Функция может считать меньше данных, чем планировалось, при этом количество реально считанных байт — одно из возвращаемых значений функции. Нужно понять, как передаются возвращаемые значения. (Также тривиально, достаточно распечатать стек после выполнения функции. Нужное число лежит по адресу $rsp+48).
  • Третья, и самая главная проблема. Вывод gdb не предназначен для автоматического парсинга (в качестве примера см. распечатку из раздела ABI), поэтому полученные таким образом дампы пригодны только для визуального анализа. (На самом деле это не проблема, так как протокол крайне прост и распознается с первого взгляда).

Attempt #4: assembly


Открыв бинарник ngrok objdump'ом, можно заметить, что между секциями .text и .rodata присутствует зазор в 0xc10=3088 байт:

 9773eb: e9 50 ff ff ff jmpq 977340 <type..eq.[2]github.com/kevinburke/cli.Flag> Дизассемблирование раздела .rodata: 0000000000978000 <type.*>:

Этот же зазор присутствует и в самом файле, там пустое пространство заполнено нулевыми байтами. Это позволяет изменить записанный в файле размер сегмента, содержащего секцию .text (поиск/замена в hex-редакторе), и добавить в пустое пространство код для логгирования вызовов.

Инструкция относительного перехода на архитектуре x86_64 занимает 5 байт: опкод (E9) + смещение до конечного адреса (signed int). Так как размер исполняемого файла ngrok сильно меньше 2 гигабайт, эта инструкция позволяет передать управление в любую точку секции .text, в том числе в наш новый код.

Первая инструкция обоих функций занимает 9 байт, так что первые 5 байт инструкции можно заменить на инструкцию перехода:

 8b0e70: 64 48 8b 0c 25 f8 ff mov %fs:0xfffffffffffffff8,%rcx 8b0e77: ff ff 

Для вызова оригинальной функции достаточно выполнить исходную инструкцию и перейти по адресу func+9

С инструкцией ret в функции (*stream).Read все куда интереснее:

 8b0f6d: 7f 22 jg 8b0f91 <github.com/inconshreveable/muxado.(*stream).Read+0xa1>... 8b0f8c: 48 83 c4 58 add $0x58,%rsp 8b0f90: c3 retq 8b0f91: 48 8b 5c 24 60 mov 0x60(%rsp),%rbx

Инструкция ret (записана как retq, в противовес retf) занимает всего 1 байт, при этом следующая за ней инструкция является jump target'ом, поэтому изменять ее нельзя. Однако на саму инструкцию ret переход нигде не производится, поэтому ничто не мешает заменить ее на переход вместе с предыдущей инструкцией (после перехода, естественно, ее придется выполнить).

Полный ассемблерный код логгера

section .textorg 0x9773f0use64 write_pre_hook:; сюда происходит переход с функции (*stream).Writepush dword 0x74697277call logadd rsp, 8mov rcx, [fs:-8] ; первая инструкция (*stream).Write заменена на переходjmp 0x8b0e79 read_post_hook:; сюда происходит переход с 0x8b0f8cadd rsp, 0x58 ; последняя инструкция перед ret, см. вышеpush dword 0x64616572call logadd rsp, 8ret log:; сохраняем регистры, чтобы не дай Бог не затереть ничего важногоpush rdipush rsipush rdxpush rax;; stack layout:;; rsp+32 ret;; rsp+40 kind ('read' or 'writ');; rsp+48 ret0;; rsp+56 &stream;; rsp+64 buf;; rsp+72 len;; rsp+80 unknown;; rsp+88 n;; rsp+96 err; выделяем память под буфер с помощью mmapmov rax, 9mov rdi, 0mov rsi, 44add rsi, [rsp+72]mov rdx, 3push r10push r8push r9mov r10, 34mov r8, -1mov r9, 0syscalltest rax, raxjs segfaultpop r9pop r8pop r10; копируем параметры со стекаmov edi, [rsp+40]mov [rax], edilea rdi, [rax+4]lea rsi, [rsp+56]push rcxmov rcx, 40 ;up to rsp+96rep movsb; копируем сам буферmov rsi, [rsp+72] ;rsp+64mov rcx, [rsp+80] ;rsp+72rep movsbpop rcx; вызываем writemov rdi, 3mov rsi, raxmov rdx, 44add rdx, [rsp+72]push raxcall writeall; освобождаем памятьpop rdimov rsi, 44add rsi, [rsp+72]mov rax, 11syscalltest rax, raxjnz segfault; возвращаем управлениеpop raxpop rdxpop rsipop rdiret writeall:mov rax, 1syscalltest rax, raxjs segfaultadd rsi, raxsub rdx, raxtest rdx, rdxjnz writeallret segfault:; сюда происходит переход в случае ошибок в системных вызовахmov [0], rax
Программа на Python для парсинга логов

import sys stream = sys.stdin.buffer while True: chunk = stream.read(44) if not chunk: break assert len(chunk) == 44 kind = chunk[:4].decode('ascii') assert kind in ('read', 'writ') str_id = hex(int.from_bytes(chunk[4:12], 'little')) l = int.from_bytes(chunk[20:28], 'little') n = int.from_bytes(chunk[36:], 'little') if kind == 'read' else l buf = stream.read(l) assert len(buf) == l if '--full-data' not in sys.argv: buf = buf[:n] print('((%r, %s, %d), (%r, %d))'%(kind, str_id, l, buf, n))

Таким образом, теперь у нас есть работающий инструмент для снятия дампов траффика с ngrok. Проверим его в действии!

Дамп

(('writ', 0xc420326600, 4), (b'\x00\x00\x00\x00', 4))(('writ', 0xc420148e00, 4), (b'\xff\xff\xff\xff', 4))(('writ', 0xc420326600, 227), (b'{"Version":["2"],"ClientId":"","Extra":{"OS":"linux","Arch":"amd64","Authtoken":"3FjYRxVDd2QvNkX13h82k_6Thwfp93PUZEpsz3vYe5v","Version":"2.2.8","Hostname":"tunnel.us.ngrok.com","UserAgent":"ngrok/2","Metadata":"","Cookie":""}}\n', 227))(('read', 0xc420326600, 512), (b'{"Version":"2","ClientId":"df05b949e58359ea6901cff60935531d","Error":"","Extra":{"Version":"prod","Cookie":"lh7YagbqJ9ixLYyE05ZDMPvaYNVm5isu$xF1Mp8fDc689269YUGlGNAV/0XRyrEH390rwGqILZqYS5+qDUNbMn2l4puKD2CJHAgI83yo49aopujf0uhPBm4t997BTBvpFSg+zrgnrW9cRNuO8ApSe2+OPpUuPK0GZYZ1bpbz7Pod7cJycwVIgDFZZXLxEeNdXylQxSax9YOxgxcHeLBa79OjqrJpEUUWYtTNiMa5wxkr0AwKh","AccountName":"\xd0\xa1\xd0\xb5\xd1\x80\xd0\xb3\xd0\xb5\xd0\xb9 \xd0\x9b\xd0\xb8\xd1\x81\xd0\xbe\xd0\xb2","SessionDuration":0,"PlanName":"Free"}}', 430))(('read', 0xc420148a00, 4), (b'\xff\xff\xff\xff', 4))(('writ', 0xc420148200, 4), (b'\x00\x00\x00\x01', 4))(('writ', 0xc420148200, 111), (b'{"Id":"","Proto":"https","Opts":{"Hostname":"","Auth":"","Subdomain":""},"Extra":{"Balance":false,"Token":""}}\n', 111))(('read', 0xc420148200, 512), (b'{"Id":"39b1e32e134eff8671b02268945643f9","URL":"https://deb82e2e.ngrok.io","Proto":"https","Opts":{"Hostname":"deb82e2e.ngrok.io","Auth":"","Subdomain":"","HostHeaderRewrite":false,"LocalURLScheme":""},"Error":"","Extra":{"Token":"WUiWWfM9kRbpFpXoEOCydiJdEob7BKN0$EHrXSWq/fY/mRDRSTNqkVWEVCDJUyBdMSU5uSEMH5RHq5D9W1gA1BTWTUEUbltyhQIlhTJvGxezhDeOYqGe5CwNFHnIOVNidToULds48FCVdWc0zRC3Djyack74P9mQ11VHKQKAXPzXUXlUbo6TRkwMWKrpN0q93pmL3fQamRP6cREZTl2YMdnFUZtwHwyh4LGacxGAvdCP867rTKBL/3eWLdkcF2lSPdHuH8V51RzCMWMIbvmtyySzE', 512))(('read', 0xc420148200, 1024), (b'cOIiZ09W6pMPTHoTcih0"}}', 23))(('writ', 0xc4200ea200, 4), (b'\x00\x00\x00\x01', 4))(('writ', 0xc4200ea200, 127), (b'{"Id":"","Proto":"http","Opts":{"Hostname":"deb82e2e.ngrok.io","Auth":"","Subdomain":""},"Extra":{"Balance":false,"Token":""}}\n', 127))(('read', 0xc4200ea200, 512), (b'{"Id":"9714ddd4cb111adf6599f099cec98482","URL":"http://deb82e2e.ngrok.io","Proto":"http","Opts":{"Hostname":"deb82e2e.ngrok.io","Auth":"","Subdomain":"","HostHeaderRewrite":false,"LocalURLScheme":""},"Error":"","Extra":{"Token":"G4nIrca8GTvq4H62sTmqdb144FmhMgrg$U6TwkKWafv/3+bFM5AP7xIFfkWqx+HUsYWhkYXivrtMfcqan0mKZx99LHGI7mm5lOMmvI+Kdy7WF/GnwrMDXrRFwhYowczaWKRKnUimnNtndq7rdttMevFabwe5WSzwf+IZhWzQ2yvcW31+qVuS7F6uykUSw+mnBNtsdXFSNpToagqQOM66A8LT+l3f3OOHKrWpdq39Bz2RfoRmXaRpkDrdfT6vPUQd6S8uVUnv3t2173Ik7AgT9PlzOMJ', 512))(('read', 0xc4200ea200, 1024), (b'hhVDbeM2HP+qV6S5I="}}', 21))(('writ', 0xc420148e00, 4), (b'\x00ys7', 4))(('writ', 0xc420148e00, 4), (b'\x00ys7', 4))(('read', 0xc420148a00, 4), (b'\x00ys7', 4))(('writ', 0xc420148a00, 4), (b'\x00ys7', 4))(('read', 0xc420148e00, 4), (b'\x00ys7', 4))(('writ', 0xc420148e00, 4), (b'\x11|\xb9\x99', 4))(('read', 0xc420148a00, 4), (b'\x11|\xb9\x99', 4))(('writ', 0xc420148a00, 4), (b'\x11|\xb9\x99', 4))(('read', 0xc420148e00, 4), (b'\x11|\xb9\x99', 4))(('read', 0xc4200ea200, 4), (b'\x00\x00\x00\x03', 4))(('read', 0xc4200ea200, 8), (b'M\x00\x00\x00\x00\x00\x00\x00', 8))(('read', 0xc4200ea200, 77), (b'{"Id":"9714ddd4cb111adf6599f099cec98482","ClientAddr":"***.***.**.***:17815"}', 77))(('read', 0xc4200ea200, 32768), (b'GET / HTTP/1.1\r\nHost: deb82e2e.ngrok.io\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\nUpgrade-Insecure-Requests: 1\r\nUser-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0 Safari/605.1.15 Midori/6\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: ru-RU\r\nX-Forwarded-For: ***.***.**.***\r\n\r\n', 359))(('writ', 0xc420148e00, 4), (b'\x0e\xb2\xc0\x01', 4))(('read', 0xc420148a00, 4), (b'\x0e\xb2\xc0\x01', 4))(('writ', 0xc420148a00, 4), (b'\x0e\xb2\xc0\x01', 4))(('read', 0xc420148e00, 4), (b'\x0e\xb2\xc0\x01', 4))(('writ', 0xc420148e00, 4), (b'X\xa3?+', 4))(('read', 0xc420148a00, 4), (b'X\xa3?+', 4))(('writ', 0xc420148a00, 4), (b'X\xa3?+', 4))(('read', 0xc420148e00, 4), (b'X\xa3?+', 4))(('read', 0xc4200ea200, 32768), (b'', 0))

Из этого дампа прекрасно видно внутреннее устройство протокола:

  • Очевидно, что потоки авторизации и создания туннеля инициируются клиентом, а потоки с собственно подключениями — сервером. Этого нет в логах, но это очевидно по соображениям здравого смысла.
  • В начале каждого потока передается 32-битное число — тип потока. Это 0 для авторизации, 1 для создания туннеля и 3 для входящих соединений.
  • Поток с типом -1 — heartbeat. Инициатор соединения периодически отправляет туда случайные 4 байта и ожидает получить их же на выходе. Таких потока создается 2 в обоих направлениях.
  • При получении входящего соединения передается 32-битный тип 3, 64-битное число L (little-endian) и JSON-объект длины L байт, описывающий соединение. После этого по соединению передаются сырые данные без каких-либо служебных пакетов.

Заключение


Так как muxado — open-source библиотека, протокол мультиплексирования можно изучить по исходникам. Приводить его здесь не имеет смысла.

Результатом работы стали библиотека на Python для работы с протоколом ngrok, и альтернативный консольный клиент, использующий данную библиотеку. GitHub

P.S. Конструктивная критика приветствуется.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»