Permalink
Fetching contributors…
Cannot retrieve contributors at this time
434 lines (309 sloc) 17.8 KB

Gnirehtet for developers

Getting started

Requirements

You need the Android SDK (Android Studio) and the JDK 8 (openjdk-8-jdk).

You also need the Rust environment to build the Rust version:

wget https://sh.rustup.rs -O rustup-init
sh rustup-init

Build

Everything

If gradle is installed on your computer:

gradle build

Otherwise, you can call the gradle wrapper:

./gradlew build

This will build the Android application, the Java and Rust relay servers, both in debug and release versions.

Specific parts

Several gradle tasks are exposed in the root project. For instance:

  • debugJava and releaseJava build the Android application and the Java relay server;
  • debugRust and releaseRust build the Android application and the Rust relay server.

Even if the Rust build tasks are exposed through gradle (which wraps calls to cargo), it is often more convenient to use cargo directly.

For instance, to build a release version of the Rust relay server:

cd relay-rust
cargo build --release

It will generate the binary in target/release/gnirehtet.

Cross-compile the Rust relay server from Linux to Windows

To build gnirehtet.exe from Linux, install the cross-compile toolchain (on Debian):

sudo apt install gcc-mingw-w64-x86-64
rustup target add x86_64-pc-windows-gnu

Add the following lines to ~/.cargo/config:

[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
ar = "x86_64-w64-mingw32-gcc-ar"

Then build:

cargo build --release --target=x86_64-pc-windows-gnu

It will generate target/x86_64-pc-windows-gnu/release/gnirehtet.exe.

Android Studio

To import the project in Android Studio: File → Import…

From there, you can develop on the Android application and the Java relay server. You can also execute any gradle tasks, and run the tests with visual results.

Overview

The client registers itself as a VPN, in order to intercept the whole device network traffic.

It exchanges raw IPv4 packets as byte[] with the device:

  • it receives packets from the Android applications or system;
  • it must forge response packets.

The client (executed on the Android device) just maintains a TCP connection to the relay server, and sends the raw packets to it.

This TCP connection is established over adb, after we started a reverse port redirection:

adb reverse localabstract:gnirehtet tcp:31416

This means that every connection initiated to localhost:31416 from the device will be redirected to the port 31416 on the computer, on which the relay server is listening.

The relay server does all the hard work. It receives the IP packets from every connected client and opens standard sockets (which, of course, don't require root) accordingly, then relays data in both directions. This requires to translate packets between level 3 (on the device side) and level 5 (on the network side) in the OSI model.

In a sense, the relay server behaves like a NAT (more precisely a port-restricted cone NAT), in that it opens connections on behalf of private peers. However, it differs from a standard NAT in the way it communicates with the clients (the private peers), by using a very specific (though simple) protocol, over a TCP connection.

Client

The client is an Android project located in app/.

The VpnService is implemented by GnirehtetService.

We control the application through broadcasts received by GnirehtetControlReceiver (we cannot send intents to GnirehtetService directly, read comments in GnirehtetControlReceiver).

Some configuration options may be passed as extra parameters, converted to a VpnConfiguration instance. Currently, the user can configure the DNS servers to use.

The very first time, Android requests to the user the permission to enable the VPN. In that case, the API requires to call startActivityForResult, so we need an Activity: this is the purpose of AuthorizationActivity.

RelayTunnel manages one connection to the relay server. PersistentRelayTunnel manages RelayTunnel instances to handle reconnections, so that we can stop and start the relay while the client keeps running.

To send response packets to the system, we must write one packet at a time to the VPN interface. Since we receive packets from the relay server over a TCP connection, we have to split writes at packet boundaries: this is the purpose of IPPacketOutputStream.

Relay server

The relay server comes in two flavors:

  • the Java version is a Java 8 project located in relay-java/;
  • the Rust version is a Rust project located in relay-rust/.

It is implemented using asynchronous I/O (through Java NIO and Rust mio). As a consequence, it is essentially monothreaded, so there is no need for synchronization to handle packets.

Selector

There are different socket channels registered to a unique selector:

  • one for the server socket, listening on port 31416;
  • one for each client, accepted by the server socket;
  • one for each TCP connection to the network;
  • one for each UDP connection to the network.

Initially, only the server socket channel is registered.

In Java, the channels (SelectableChannel) are registered to the selector (Selector) defined in Relay, with their SelectionHandler as attachment (for better decoupling). A Client is created for every accepted client.

In Rust, our own Selector class wraps the Poll from mio to expose an API accepting event handlers instead of low-level tokens. The selector instance is created in Relay. The channels are called "handles" in mio; they are simply the socket instances themselves (TcpListener, TcpStream and UdpSocket). A Client is created for every accepted client.

archi

Client

Each client manages a TCP socket, used to transmit raw IP packets from and to the Gnirehtet Android client. Thus, these IP packets are encapsulated into TCP (they are transmitted as the TCP payload).

When a client connects, the relay server assigns an integer id to it, which it writes to the TCP socket. The client considers itself connected to the relay server only once it has received this number. This allows to detect any end-to-end connection issue immediately. For instance, a TCP connect initiated by a client succeeds whenever a port redirection is enabled (typically through adb reverse), even if the relay server is not listening. In that case, the first read will fail.

Packets

A class representing an IPv4 packet (IPv4Packet | Ipv4Packet) provides a structured view to read and write packet data, which is physically stored in the buffers (the little squares on the schema). Since we handle one packet at a time with asynchronous I/O, there is no need to copy or synchronize access to the packets data: the packets just point to the buffer where they are stored.

Each packet contains an instance of IPv4 headers and transport headers (which might be TCP or UDP headers).

In Java, this is straightforward: IPv4Header, TCPHeader and UDPHeader just share a slice of the raw packet buffer.

In Rust, the borrowing rules prevent to share a mutable reference. Therefore, header data classes (*HeaderData) are used to store the fields, and lifetime-bound views (*Header<'a> and *HeaderMut<'a>) reference both the raw array and the header data:

  • ipv4_header:
    • data: Ipv4HeaderData
    • view: Ipv4Header<'a>
    • mutable view: Ipv4HeaderMut<'a>
  • tcp_header:
    • data: TcpHeaderData
    • view: TcpHeader<'a>
    • mutable view: TcpHeaderMut<'a>
  • udp_header:
    • data: UdpHeaderData
    • view: UdpHeader<'a>
    • mutable view: UdpHeaderMut<'a>

In addition, we use enums for transport headers to statically dispatch calls to UDP and TCP header classes:

  • transport_header:
    • data: TransportHeaderData
    • view: TransportHeader<'a>
    • mutable view: TransportHeaderMut<'a>

Router

Each client holds a router (Router | Router), responsible for sending the packets to the right connection, identified by these 5 properties available in the IP and transport headers:

  • protocol
  • source address
  • source port
  • destination address
  • destination port

These identifiers are stored in a connection id (ConnectionId | ConnectionId), used as a key to find or create the associated connection.

Connections

A connection (Connection | Connection) is either a TCP connection (TCPConnection | TcpConnection) or a UDP connection (UDPConnection | UdpConnection) to the requested destination. It registers its own channel to the selector.

The connection is responsible for converting data from level 3 to level 5 for device-to-network packets, and from level 5 to level 3 for network-to-device packets. For UDP connections, it consists essentially in removing or adding IP and transport headers. For TCP connections, however, it requires to respond to the client according to the TCP protocol (RFC 793), in such a way as to ensure a correct end-to-end communication.

A packetizer (Packetizer | Packetizer) converts from level 5 to level 3 by appending correct IP and transport headers.

UDP connection

When the first packet for a specific UDP connection is received from the device, a new UdpConnection is created. It keeps a copy of the IP and UDP headers of this first packet, swapping the source and the destination, in order to use them as headers for all response packets.

The relaying is simple for UDP: each packet received from one side must be sent to the other side, without any splitting or merging (datagram boundaries must be preserved for UDP).

Since UDP is not a connected protocol, a UDP connection is never "closed". Therefore, the selector wakes up once per minute (using a timeout) to clean expired (in practice, unused for more than 2 minutes) UDP connections.

TCP connection

TcpConnection also keeps, as a reference, a copy of the IP and TCP headers of the first packet received.

However, contrary to UDP, TCP must provide reliable delivery. In particular, lost packets have to be retransmitted. Nonetheless, we can take advantage of the two TCP we are proxifying, so that we can provide reliability by delegating the retransmission mechanism to them. In fact, it is sufficient to guarantee that we cannot lose packets from network to device.

Indeed, any packet written to a TCP channel is safe, since it will be managed by the TCP implementation from the system. Losing a raw IP packet received from the device is also safe: the device TCP implementation will follow the TCP protocol to retransmit it. Therefore, dropping packets from device to network does not break the connection.

On the other hand, once we retrieved a packet from a TCP channel from the network, we are responsible for it. Would it be dropped, there would be no way to recover the connection.

As far as I know, there are only two possible causes of packet loss for which we are responsible:

  1. When our buffers are full, we won't resize them indefinitely, so we have to drop packets. Typically, this may happen if the data from the network is received at a higher rate than that they can be sent to the device.

  2. When a raw packet is considered invalid by the device, it is rejected. This may happen for example if the checksum is invalid or if the TCP sequence number is out-of-the-window.

Therefore, by contraposition, if we guarantee that we never retrieve a packet that we won't be able to store, and that we provide a valid checksum and respect the client TCP window, then we won't lose any packet without implementing any retransmission mechanism.

To prevent retrieving a packet while our buffers are full, we indicate that we are not interested in reading (interestOps | interest) the TCP channel when some pending data remain to be written to the client buffer. Once some space becomes available, the client then pulls the available packets from the TcpConnections, which are packet sources (PacketSource | PacketSource).

Hack

For more details, go read the code!

If you find a bug, or have an awesome idea to implement, please discuss and contribute ;-)