cześć

Good luck, have fun!

How to hijack, proxy and smuggle sockets with Rack/Ruby

Dávid Halász

WrocLove RB '19

@halaszdavid

Like remote desktop sessions, but in a HTML5 <canvas>

In-browser remote consoles

Browser

Hypervisor

VM

Proxy

WS

VNC

PROXY

PROXY

PROXY

WS

VNC

So what THE HELL IS THIS?

require "socket"
sock = TCPSocket.new("wrocloverb.com", 80)

sock.write("GET / HTTP/1.1\r\n");
sock.write("Host: wrocloverb.com\r\n")
sock.write("\r\n")

puts sock.read(358)
sock.close

  "HTTP/1.1 301 Moved Permanently
   Cache-Control: public, max-age=0, must-revalidate
   Content-Length: 39
   Content-Type: text/plain
   Date: Thu, 07 Mar 2019 20:54:19 GMT
   Location: https://wrocloverb.com/
   Age: 72524
   Connection: keep-alive
   Server: Netlify
 
   Redirecting to https://wrocloverb.com/"

HEADERS

PAYLOAD

HTTP

As files, but for Networking

Socket.write()

Socket.write()

Socket.write()

Socket.write()

Socket.write()

Socket.write()

Socket.write()

Socket.write()

Socket.write()

Socket.write()

Socket.write()

Socket.write()

Socket.write()

For Reading ...
 

Use your Imagination

# sock_a <- websocket
# sock_b <- vncsocket

Thread.new do
  loop do
    ws_data = sock_a.read(1024)
    vnc_data = ws_to_vnc(ws_data)
    sock_b.write(vnc_data)
  end
end

loop do
  vnc_data = sock_b.read(1024)
  ws_data = vnc_to_ws(vnc_data)
  sock_a.write(ws_data)
end

Non-blocking IO

  • Same methods, but with _nonblock suffix
    • read_nonblock
    • write_nonblock
  • Methods are not waiting
  • Instead of waiting an exception is raised
    • EWOULDBLOCK
    • EAGAIN
  • Testing the readiness extracted out
  • Single loop to handle all sockets
class Reader
  def initialize
    @sockets = []
    Thread.new { event_loop }
  end

  def register(socket)
    @sockets.push(socket)
  end

  private

  def event_loop
    loop do
      # Wait for events
      to_read, _, _ = IO.select(@sockets, [], [], 1)

      # Handle the events
      to_read.each do |socket|
        puts socket.read_nonblock(4096)
      end
    end
  end
end

My Adventures
with IO.select

pairing = {
  sock_a => [sock_b, method(:ws_to_vnc)],
  sock_b => [sock_a, method(:vnc_to_ws)]
}

loop do
  rds, wrs, _ = IO.select(pairing.keys, pairing.keys, [], 1)
  
  # Iterate through all the readable sockets
  rds.each do |read|
    # Retrieve the right endpoint and translation method
    write, translate = pairing[read]

    # Don't do anything if write is not writable - OOPSIE
    next unless wrs.include?(write)

    # Read, translate and write
    data = read.read_nonblock(4096)
    translated = translate.call(data)
    write.write_nonblock(translated)
  end
end

This is really bad

Alternative solutions

  • Don't use Ruby
  • Using threads with blocking IO
  • Use a library for asynchronous IO
    • Celluloid
    • EventMachine
    • Async
  • Wait for AutoFibers to happen
    • Fibers yielding automatically IO wait
    • Similar to Crystal

😭

😭  😭

👍

🤩 🤩

# sock_a <- websocket
# sock_b <- vncsocket

fibers = []

fibers << AutoFiber.new do
  loop do
    ws_data = sock_a.read(1024)
    vnc_data = ws_to_vnc(ws_data)
    sock_b.write(vnc_data)
  end
end

fibers << AutoFiber.new do
  loop do
    vnc_data = sock_b.read_nonblock(1024)
    ws_data = vnc_to_ws(vnc_data)
    sock_a.write_nonblock(ws_data)
  end
end

# Not sure about the syntax, sorry
loop { fibers.each(&:call) }

✨✨ MAGIC ✨✨

Bouncing SElect

pairing = {
  sock_a => [sock_b, method(:ws_to_vnc)],
  sock_b => [sock_a, method(:vnc_to_ws)]
}

@to_read = pairing.keys
@to_write = pairing.keys

loop do
  rds, wrs, _ = IO.select(@to_read, @to_write, [], 1)

  @to_read -= read # bounce out
  @to_write -= write

  # Iterate through all the readable sockets
  rds.each do |read|
    # Retrieve the right endpoint and translation method
    write, translate = pairing[read]

    # Don't do anything if write is not writable
    next unless wrs.include?(write)

    # Read, translate and write
    data = read.read_nonblock(4096)
    translated = translate.call(data)
    write.write_nonblock(translated)

    @to_read.push(read) # bounce back in
    @to_write.push(write)
  end
end

IO.Select isn't ideal

EPOLL

  • Higher performance than IO.select
    • Pass the sockets to the kernel just once
    • Ask for their status separately
  • Available on Linux only
    • kqueue on BSD
    • Fallback to IO.select
  • Partially implements the bouncing select
    • EPOLLONESHOT
    • Unregister a socket after being ready
    • Sockets still have to be rearmed explicitly

void epoll_register(int *epoll, VALUE socket) {
  struct epoll_event ev;
  ev.data.u64 = (uint64_t) socket;
  ev.events = EPOLLONESHOT | EPOLLIN | EPOLLOUT;
  epoll_ctl(*epoll, EPOLL_CTL_ADD, SOCK_PTR(socket), &ev);
}


void select(int *epoll, int timeout) {
  int i = 0;
  struct epoll_event events[256];
  int numevents = epoll_wait(epoll, events, 256, timeout);

  for (int i = 0; i < numevents; i++) {
    VALUE socket = (VALUE) events[i].data.u64;
    // set readiness of the socket
    // in an ruby-readable structure
    // that can be iterated with each
  }
}

For any other platform it can fall back to IO.select

Works well™ on Linux

https://github.com/skateman/surro-gate

Using HTTP for bidirectional data transfer instead of the classic request-response model after a regular handshake

Websockets

Browser

Webserver

GET /websocket HTTP/1.1
Host: localhost
Connection: Upgrade
Upgrade: WebSocket

Browser

Webserver

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

Browser

Webserver

WebSocket frames
defined by RFC 6455

Source: pngarts.com 

require 'rack'

# app can be anything that responds to #call
app = lambda do |env|
  # Here you do some stuff

  [
    '200', # status
    {'Content-Type' => 'text/html'}, # headers
    ['Hello world!'] # body
  ]
end
 
# Incoming HTTP requests will run app.call
# with a single hash as an argument that
# contains all the parsed data from the request
Rack::Handler::Puma.run app
require 'rack'

# app can be anything that responds to #call
app = lambda do |env|
  # Here you do some stuff

  [
    '200', # status
    {'Content-Type' => 'text/html'}, # headers
    ['Hello world!'] # body
  ]
end
 
# Incoming HTTP requests will run app.call
# with a single hash as an argument that
# contains all the parsed data from the request
Rack::Handler::Puma.run app

Socket hijacking

@proxy = Proxy.new # Runs in background

app = lambda do |env|
  return [404, {}, []] unless http_upgrade?

  sock_ws = env['rack.hijack'].call

  address, port = magic(env)
  sock_vnc = TCP.new(address, port)

  @proxy.push(sock_ws, sock_vnc)

  [-1, {}, []] # Dummy return to make Rack happy
end

Rack::Handler::Puma.run app

This is how we roll

Browser

Hypervisor

VM

Proxy

WS

VNC

Thank you

GET /websocket HTTP/1.1
Host: localhost
Connection: Upgrade
Upgrade: WebSocket
🐱 🐱 🐱 🐱 🐱

Browser vs Desktop

Hypervisor

VM

Server Proxy

VNC

Client Proxy

VNC

VNC Client

Purr

HTTP Upgrade + VNC

HTTP Upgrade + SSH

HTTP Upgrade + ???

TCP Smuggled Through HTTP

Browser

Webserver

Proxy

Hypervisor

VM

Browser

Webserver

Proxy

Hypervisor

VM

I want to purr like a cat

Browser

Webserver

Proxy

Hypervisor

VM

Go for it, talk to purr://miq

Browser

Webserver

Proxy

Hypervisor

VM

Plugin

purr://miq

Browser

Webserver

Proxy

Hypervisor

VM

Plugin

I'm listening on localhost:1234

Browser

Webserver

Proxy

Hypervisor

VM

Plugin

I'm listening on localhost:1234

Browser

Webserver

Proxy

VNC client

Plugin

Hypervisor

VM

Connecting to
localhost:1234

Browser

Webserver

Proxy

VNC client

Plugin

Hypervisor

VM

VNC

Browser

Webserver

Proxy

VNC client

Plugin

Hypervisor

VM

VNC

HTTP w/upgrade to Purr

Browser

Webserver

Proxy

VNC client

Plugin

Hypervisor

VM

VNC

HTTP w/upgrade to Purr

Browser

Webserver

Proxy

VNC client

Plugin

Hypervisor

VM

VNC

HTTP 101

Browser

Webserver

Proxy

VNC client

Plugin

Hypervisor

VM

VNC

Purr

Browser

Webserver

Proxy

VNC client

Plugin

Hypervisor

VM

VNC

Purr

VNC

Browser

Webserver

Proxy

VNC client

Plugin

Hypervisor

VM

VNC

Purr

VNC

There's a problem
With this

Browser Plugin

+

Client APP

Purrchitecture

  • Server
    • Rack server written in Ruby
    • Uses surro-gate for bouncing-select
  • Plugin
    • JS/WebExtensions
    • Calls the client using native messaging
    • Have to figure out packaging and distribution
  • Client
    • Listens locally, forwards to the server
    • My very first try to write Go
  • Frontend library
    • Activates the plugin and the client
    • I don't like writing JS
require 'purr'

app = Purr.server do |env|
  # Just implement your own magic method
  # that returns with a host and a port
  host, port = magic(env)

  [host, port]
end

Rack::Handler::Puma.run app

(Dis)AdvanTages

  • Behaves like HTTP
  • Use of Rack middlewares
    • Authentication
    • Logging
    • Router
  • Cookies
  • HTTP headers
  • SSL/TLS support with HTTPS
  • Needs both a browser plugin and a binary (for now) 

It's not fully done

Far from being
Purrduction ready

But it works

This presentation is running in a browser, that is running in a container which is being accessed through VNC. The container is running in a VM, without any exposed ports. Therefore, the only way to access the container's VNC server from my machine is via a HTTP server purring on the VM.  

I can prove it

Thank You

Dávid Halász
dhalasz@redhat.com
Twitter: halaszdavid
Github: skateman

I have some stickers of George and Purr

github.com/skateman/purr
github.com/skateman/surro-gate
www.manageiq.org
www.skateman.eu

Made with FontAwesome icons