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

?v=1

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-дневного пробного периода за дальнейшее использование придется заплатить.


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 certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

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

$ curl -k https://tunnel.us.ngrok.com
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: " 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 muxado
00000000008ae2c0 T github.com/inconshreveable/muxado.(*addr).Network
00000000008ae2e0 T github.com/inconshreveable/muxado.(*addr).String
0000000000e31b40 B github.com/inconshreveable/muxado.bufferClosed
0000000000e31b50 B github.com/inconshreveable/muxado.bufferFull
00000000008ad430 T github.com/inconshreveable/muxado.Client
0000000000e31b60 B github.com/inconshreveable/muxado.closeError
00000000008b4c00 T github.com/inconshreveable/muxado.(*condWindow).Broadcast
00000000008b2ed0 T github.com/inconshreveable/muxado.(*condWindow).Decrement
00000000008b2da0 T github.com/inconshreveable/muxado.(*condWindow).Increment
00000000008b2d30 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=, s=)
    at /home/sergey/muxado/src/github.com/inconshreveable/muxado/stream.go:81
81	func (s *stream) Write(buf []byte) (n int, err error) {
(gdb) set language c
Warning: the current language does not match this frame.
(gdb) p {char*[4]}$rsp
$1 = {
  0x4e0cbf  "H\213l$XH\203\304`\303\350\002A\367\377\351\255\376\377\377", '\314' , "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 }

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

Итак, теперь мы знаем, как расположены в памяти аргументы функции (*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 0x0
fatal 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=0xc42016f9d0
runtime.sigpanic()
...

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


Attempt #3: gdb script

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

set language c
break github.com/inconshreveable/muxado.(*stream).Write
commands
    set $stream={void*}($rsp+8)
    set $buf={char*}($rsp+16)
    set $len={long long}($rsp+24)
    p $stream
    p (*$buf)@$len
    p $len
    cont
end

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 

Дизассемблирование раздела .rodata:

0000000000978000 :

Этот же зазор присутствует и в самом файле, там пустое пространство заполнено нулевыми байтами. Это позволяет изменить записанный в файле размер сегмента, содержащего секцию .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 
...
  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 .text
org 0x9773f0
use64

write_pre_hook:
; сюда происходит переход с функции (*stream).Write
push dword 0x74697277
call log
add rsp, 8
mov rcx, [fs:-8] ; первая инструкция (*stream).Write заменена на переход
jmp 0x8b0e79

read_post_hook:
; сюда происходит переход с 0x8b0f8c
add rsp, 0x58 ; последняя инструкция перед ret, см. выше
push dword 0x64616572
call log
add rsp, 8
ret

log:
; сохраняем регистры, чтобы не дай Бог не затереть ничего важного
push rdi
push rsi
push rdx
push 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
; выделяем память под буфер с помощью mmap
mov rax, 9
mov rdi, 0
mov rsi, 44
add rsi, [rsp+72]
mov rdx, 3
push r10
push r8
push r9
mov r10, 34
mov r8, -1
mov r9, 0
syscall
test rax, rax
js segfault
pop r9
pop r8
pop r10
; копируем параметры со стека
mov edi, [rsp+40]
mov [rax], edi
lea rdi, [rax+4]
lea rsi, [rsp+56]
push rcx
mov rcx, 40 ;up to rsp+96
rep movsb
; копируем сам буфер
mov rsi, [rsp+72] ;rsp+64
mov rcx, [rsp+80] ;rsp+72
rep movsb
pop rcx
; вызываем write
mov rdi, 3
mov rsi, rax
mov rdx, 44
add rdx, [rsp+72]
push rax
call writeall
; освобождаем память
pop rdi
mov rsi, 44
add rsi, [rsp+72]
mov rax, 11
syscall
test rax, rax
jnz segfault
; возвращаем управление
pop rax
pop rdx
pop rsi
pop rdi
ret

writeall:
mov rax, 1
syscall
test rax, rax
js segfault
add rsi, rax
sub rdx, rax
test rdx, rdx
jnz writeall
ret

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 — heartbeet. Инициатор соединения периодически отправляет туда случайные 4 байта и ожидает получить их же на выходе. Таких потока создается 2 в обоих направлениях.
  • При получении входящего соединения передается 32-битный тип 3, 64-битное число L (little-endian) и JSON-объект длины L байт, описывающий соединение. После этого по соединению передаются сырые данные без каких-либо служебных пакетов.


Заключение

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

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

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

© Habrahabr.ru