DEV Community

Michael Kaisanov
Michael Kaisanov

Posted on • Updated on

Packet Sniffer in Elixir

There is no time to explain, let's create a simple sniffer on Elixir!

The source code lives here.

OTP 22.0 release added a new socket module and now Erlang developers have the same socket power as C-developers (+/- 10 volts). Let's try it out.

Disclaimer: Erlang's socket is a wrapper around OS's socket. Thus I give links for implementation of POSIX functions in the Linux's source code to provide proofs and show how the code looks like. But in another OS the code may look different and placed somewhere else. But the general concept should be the same.

So, let's say we're going to listen for raw packets at the device driver (OSI Layer 2). To achieve this there are some steps to be done:

Create a socket

The way to do this is to call :socket.open/3. There are few variants of this function but we will use one with "open(Domain, Type, Protocol)" arguments. This function is a binding for a standard POSIX's C function named socket.

Socket creation works this way:

:socket.open(domain, socket_type, protocol)
Enter fullscreen mode Exit fullscreen mode

The domain variable must be equal to 17.
This is a communication domain constant that specifies what kind of data we expect to receive from the OS. In the current situation "17" stands for raw low-level packets. Read more. The constant is defined here.

The socket_type must have :raw value. It means we are interested in raw packets.

The protocol specifies what kind of packets we're interested in. Since we're interested in all types we use ETH_P_ALL constant. There is one more thing to mention about the value. The value must be passed into the :socket.open/3 in network byte order (big-endian). My processor has little-endian encodings so if I don't convert this value I would get an error.

So, let's see how the socket creation looks like:

domain = 17
protocol = 0x0003

# Convert the protocol to a network byte order
<<protocol_host::big-unsigned-integer-size(16)>> = <<protocol::native-unsigned-integer-size(16)>>

{:ok, socket} = :socket.open(domain, :raw, protocol_host)
Enter fullscreen mode Exit fullscreen mode

Read data from the socket

OK, the socket is created and now it's time to read some data. There are a few ways to read but let's focus on :socket.recvfrom/3. The documentation describes this function pretty well. In short it sounds like "Hello, OS. Please give me data for this socket. If there is no data let me know when it comes". When the data is available it returns it. Otherwise it sends a message to a socket owner process when data is available.

defmodule Sniffer do
  use GenServer
  

  defp socket_recieve(socket) do
    case :socket.recvfrom(socket, 1500, :nowait) do
      {:ok, {source, data}} ->
        GenServer.cast(self(), {:socket_data, source, data})

      {:select, _select_info} ->
        :ok

      {:error, reason} ->
        GenServer.cast(self(), {:socket_error, reason})
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The socket is our socket we’ve created earlier. The 1500 is a buffer size for new data. And the :nowait atom says that we don’t want to wait for data and want this function to return something immediately.

Let’s review the possible result this function produces.
The first clause matches the successful result, i.e. the data is ready, The source variable is a map that describes where the data came from.

The second clause basically means “I don’t have any data right now, but I'll let you know when it comes.”

The last clause is an error handler.

Now let’s see the callbacks implementation:

defmodule Sniffer do
  ...

  def handle_cast({:socket_data, source, data}, socket) do
    IO.puts(Hurray, we have a new packet)
    socket_recieve(socket)

    {:noreply, socket}
  end

  def handle_cast({:socket_error, reason}, socket) do
    IO.inspect(reason, label: "Something goes wrong :-(")

    {:noreply, socket}
  end

  def handle_info({:"$socket", socket, :select, _select_handle}, socket) do
    IO.puts(Knock knock new data is available to pick up)
    socket_recieve(socket)

    {:noreply, socket}
  end
end

Enter fullscreen mode Exit fullscreen mode

Bind the socket to an interface

If you start reading data from a socket without any limits you find that data comes from different sources like loopback device, different network devices, from docker etc. To limit this behavior we can bind the socket to a special device like ethernet or wifi card. To achieve this just use :socket.bind/2 function.

The second argument of the bind function is a socket address. For a raw packets it seems the :socket.bind/2 does not accept the sockaddr_ll type as address so let’s implement it ourselves. The C definition of the sockaddr_ll type you can find here. It’s just a C struct. A C struct looks like a data type but basically it’s a blob of binary data. Since it’s just a binary we can build it ourselves. By the way we don’t need to fill all the values to create a binding for a socket. Only sll_protocol and sll_ifindex fields are required, the rest fields should are empty.
The sll_protocol is the same as we know from the Create a socket section. But sll_ifindex is special for every machine.

ifindex stands for Interface Index. You can see the list of all available network interfaces on your machine using command line ip addr. There are interface names like eth0, wlp1s0, etc and their indexes. Erlang has a similar function to retrieve such a list :net.if_names(). Notice that indexes in OS and Erlang may be different.

The if_index definition:

if_index = 1
Enter fullscreen mode Exit fullscreen mode

or

{:ok, if_index} = :binary.bin_to_list(eth0) |> :net.if_name2index()
Enter fullscreen mode Exit fullscreen mode

Now we have values to build sockaddr_ll struct:

sll_protocol = 0x0003
sll_ifindex = if_index
sll_hatype = 0
sll_pkttype = 0
sll_halen = 0
sll_addr = <<0::native-unsigned-size(8)-unit(8)>>
Enter fullscreen mode Exit fullscreen mode

The C sockaddr_ll struct representation on Erlang:

addr = <<
      sll_protocol::big-unsigned-size(16),
      sll_ifindex::native-unsigned-size(32),
      sll_hatype::native-unsigned-size(16),
      sll_pkttype::native-unsigned-size(8),
      sll_halen::native-unsigned-size(8),
      sll_addr::binary
    >>
Enter fullscreen mode Exit fullscreen mode

And finally the second argument for “:socket.bind/2” is:


sockaddr = %{
      family: @af_packet,
      addr: addr
    }

:ok = :socket.bind(socket, sockaddr)
Enter fullscreen mode Exit fullscreen mode

That’s it. Now the socket is bound to a specific network interface.

Set promiscuous mode

What is promiscuous mode? In this mode a net interface controller does not filter any traffic and allows you to do this. For example you have a laptop and a smartphone connected to Wif-Fi. When promiscuous mode is off the laptop sees only traffic aimed at it. But when it is on it’s possible to monitor packets sent to smartphone also.

NOTE: The promiscuous mode consumes CPU so turn it off when you’re done. On Ubuntu you can check if any network adapter is in promiscuous mode using the command ip addr | grep PROMISC. And turn it off ip link set eth0 promisc off.

To turn promiscuous mode on just use :socket.ioctl/4 function:

if_name = :binary.bin_to_list(eth0)
:ok = :socket.ioctl(socket, :sifflags, if_name, %{promisc: true})
Enter fullscreen mode Exit fullscreen mode

Permissions

The usage of a raw packets scanning and promiscuous mode requires privileged access. It could be sudo or Linux capabilities.
To set up proper Linux capabilities you need to know where the beam.smp file is located. It should be somewhere on Erlang directory like erlang/24.3.3/erts-12.3.1/bin/beam.smp.
To set up capabilities use setcup command:

sudo setcap cap_net_raw,cap_net_admin=ep ERLANG_PATH/erts-VERSION/bin/beam.smp
Enter fullscreen mode Exit fullscreen mode

That’s it

I described some pitfalls people may face trying to use raw socket on Erlang. The code is placed on GitHub.

Thanks for reading and have fun!

Top comments (0)