Michael Kaisanov
Posted on October 2, 2022
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
- Read data from the socket
- Bind the socket to the interface (optional)
- Set promiscuous mode (optional)
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)
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)
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
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
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
or
{:ok, if_index} = :binary.bin_to_list(“eth0”) |> :net.if_name2index()
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)>>
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
>>
And finally the second argument for “:socket.bind/2” is:
sockaddr = %{
family: @af_packet,
addr: addr
}
:ok = :socket.bind(socket, sockaddr)
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})
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
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!
Posted on October 2, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.