< Home of 0x7f.dev

NTP Implementation in Elixir

The idea

So everything started off with the idea that I can somehow fake uptime of a machine by updating the time against a "corrupted" NTP that will lie about the time. Since there isn't such a thing as a lying NTP server I decided to create one.

This will be an exercise in reverse engineering a protocol, some Linux command line tricks, bitstring manipulation in Elixir, and some various other tricks.

Discovering NTP

I'm using a M1 Macbook so I needed an x86 Linux VM. I downloaded OrbStack^1 and started an Ubuntu 22.04 machine. Next up we will need a couple of things installed on the machine so we can: 1. update the time against a known working NTP server, and 2. capture that network traffic.

$ sudo apt install tcpdump ntpdate
$ # Next start our network package capture in background
$ sudo tcpdump udp -w output.pcap &
[1] 2272
$ # And now we update the time
$ sudo ntpdate -u ntp.ubuntu.com
15 Jul 16:31:21 ntpdate[2273]: adjust time server 185.125.190.58 offset +0.113190 sec

Cool! Now all we have to do is stop the tcpdump:

$ kill 2272
56 packets captured
56 packets received by filter
0 packets dropped by kernel

Now we can explore around the pcap file. There is two ways we're going to explore today, one is with tcpdump, and the other one is with Wireshark^2.

Exploring our pcap file with tcpdump

$ tcpdump -qns 0 -X -r output.pcap

This will produce quite an output:

Output
16:32:32.497588 IP 198.19.249.172.33489 > 198.19.248.200.53: UDP, length 43
  0x0000:  4500 0047 b63a 4000 4011 05cf c613 f9ac  E..G.:@.@.......
  0x0010:  c613 f8c8 82d1 0035 0033 7ee1 5368 0100  .......5.3~.Sh..
  0x0020:  0001 0000 0000 0001 036e 7470 0675 6275  .........ntp.ubu
  0x0030:  6e74 7503 636f 6d00 0001 0001 0000 2904  ntu.com.......).
  0x0040:  b000 0000 0000 00                        .......
16:32:32.497686 IP 198.19.249.172.33489 > 198.19.248.200.53: UDP, length 43
  0x0000:  4500 0047 b63b 4000 4011 05ce c613 f9ac  E..G.;@.@.......
  0x0010:  c613 f8c8 82d1 0035 0033 7ee1 eeec 0100  .......5.3~.....
  0x0020:  0001 0000 0000 0001 036e 7470 0675 6275  .........ntp.ubu
  0x0030:  6e74 7503 636f 6d00 001c 0001 0000 2904  ntu.com.......).
  0x0040:  b000 0000 0000 00                        .......
16:32:32.545195 IP 198.19.248.200.53 > 198.19.249.172.33489: UDP, length 193
  0x0000:  4500 00dd ada1 0000 3f11 4ed2 c613 f8c8  E.......?.N.....
  0x0010:  c613 f9ac 0035 82d1 00c9 0000 5368 8180  .....5......Sh..
  0x0020:  0001 0005 0000 0001 036e 7470 0675 6275  .........ntp.ubu
  0x0030:  6e74 7503 636f 6d00 0001 0001 036e 7470  ntu.com......ntp
  0x0040:  0675 6275 6e74 7503 636f 6d00 0001 0001  .ubuntu.com.....
  0x0050:  0000 004d 0004 b97d be39 036e 7470 0675  ...M...}.9.ntp.u
  0x0060:  6275 6e74 7503 636f 6d00 0001 0001 0000  buntu.com.......
  0x0070:  004d 0004 5bbd 5b9d 036e 7470 0675 6275  .M..[.[..ntp.ubu
  0x0080:  6e74 7503 636f 6d00 0001 0001 0000 004d  ntu.com........M
  0x0090:  0004 5bbd 5e04 036e 7470 0675 6275 6e74  ..[.^..ntp.ubunt
  0x00a0:  7503 636f 6d00 0001 0001 0000 004d 0004  u.com........M..
  0x00b0:  b97d be3a 036e 7470 0675 6275 6e74 7503  .}.:.ntp.ubuntu.
  0x00c0:  636f 6d00 0001 0001 0000 004d 0004 b97d  com........M...}
  0x00d0:  be38 0000 2904 b000 0000 0000 00         .8..)........
16:32:32.608719 IP 198.19.248.200.53 > 198.19.249.172.33489: UDP, length 169
  0x0000:  4500 00c5 ada2 0000 3f11 4ee9 c613 f8c8  E.......?.N.....
  0x0010:  c613 f9ac 0035 82d1 00b1 0000 eeec 8180  .....5..........
  0x0020:  0001 0003 0000 0001 036e 7470 0675 6275  .........ntp.ubu
  0x0030:  6e74 7503 636f 6d00 001c 0001 036e 7470  ntu.com......ntp
  0x0040:  0675 6275 6e74 7503 636f 6d00 001c 0001  .ubuntu.com.....
  0x0050:  0000 004d 0010 2620 002d 4000 0001 0000  ...M..&..-@.....
  0x0060:  0000 0000 0040 036e 7470 0675 6275 6e74  [email protected]
  0x0070:  7503 636f 6d00 001c 0001 0000 004d 0010  u.com........M..
  0x0080:  2620 002d 4000 0001 0000 0000 0000 0041  [email protected]
  0x0090:  036e 7470 0675 6275 6e74 7503 636f 6d00  .ntp.ubuntu.com.
  0x00a0:  001c 0001 0000 004d 0010 2620 002d 4000  .......M..&..-@.
  0x00b0:  0001 0000 0000 0000 003f 0000 2904 b000  .........?..)...
  0x00c0:  0000 0000 00                             .....
16:32:32.725746 IP 198.19.249.172.36991 > 185.125.190.57.123: UDP, length 48
  0x0000:  4500 004c 6a77 4000 4011 98b2 c613 f9ac  E..Ljw@.@.......
  0x0010:  b97d be39 907f 007b 0038 37c1 e300 03fa  .}.9...{.87.....
  0x0020:  0001 0000 0001 0000 0000 0000 0000 0000  ................
  0x0030:  0000 0000 0000 0000 0000 0000 0000 0000  ................
  0x0040:  0000 0000 e85d 2c80 b9ab e514            .....],.....
16:32:32.757240 IP 185.125.190.57.123 > 198.19.249.172.36991: UDP, length 48
  0x0000:  4500 004c 40da 0000 3f11 0350 b97d be39  E..L@...?..P.}.9
  0x0010:  c613 f9ac 007b 907f 0038 0000 2402 03e7  .....{...8..$...
  0x0020:  0000 0044 0000 0017 c944 586a e85d 2bd7  ...D.....DXj.]+.
  0x0030:  9da3 dbc5 e85d 2c80 b9ab e514 e85d 2c80  .....],......],.
  0x0040:  beff 6d74 e85d 2c80 bf00 b637            ..mt.],....7

We're going to cut it down into bite size pieces. First, we asked for time ntp.ubuntu.com which means we need to get the DNS record. We preform two DNS lookups one for A and one for AAAA record.

We know that by looking into the DNS protocol specification^3, and seeing that we send 00 01 in order to get A and we send 00 1C to get AAAA.

0x0030:  6e74 7503 636f 6d00 0001 0001 0000 2904  ntu.com.......).
                        ~~~~~^

and

0x0030:  6e74 7503 636f 6d00 001C 0001 0000 2904  ntu.com.......).
                        ~~~~~^

Next two packets are responses, which we can ignore. And now we have something that we need! The request and response for NTP. The first one we will have to parse and the second one we will have to create.

Exploring the NTP request

This is the packet that we will dissect right now. While we do that let's also think about what could be interesting and think of a way to extract it in Elixir.

16:32:32.725746 IP 198.19.249.172.36991 > 185.125.190.57.123: UDP, length 48
  0x0000:  .... .... .... .... .... .... .... ....  E..Ljw@.@.......
  0x0010:  .... .... .... .... .... .... e300 03fa  .}.9...{.87.....
  0x0020:  0001 0000 0001 0000 0000 0000 0000 0000  ................
  0x0030:  0000 0000 0000 0000 0000 0000 0000 0000  ................
  0x0040:  0000 0000 e85d 2c80 b9ab e514            .....],.....

We can ignore everything before E3 00 03 FA, as that's the IP header, and the protocol header.

Let's start with with the E3. That part of the request we call flags and to understand it we need to convert E3 to binary: 11100011. In that number we have three flags.

bits Description
11 The first two bits represent the leap indicator^4. In this case it's unknown
100 The second three bits represent the NTP Version 4^5
011 Last two are the packet mode^6, and in this case it's the client

Next up we have 00 which is a Clock stratum^7 (source, GPS, atomic clock, etc.) and in this case it's set to 0 which means unspecified.

Next 03 is the pooling interval^8, in this case it's set to three.

Following that we have three fields that specify the clock precision^9, delay^10 and dispersion^11: FA 00 01 00 00 00 01 00 00.

And finally we start getting into interesting and potentially useful things:

bytes Description Needed?
00 00 00 00 Reference ID^12 If set to something we need to return it. Currently nil.
00 00 00 00 00 00 00 00 Reference timestamp^13
00 00 00 00 00 00 00 00 Origin timestamp^14
00 00 00 00 00 00 00 00 Receive timestamp^15
E8 5D 2C 80 B9 AB E5 14 Transmit timestamp^16 This one is needed for comparison on the client side. We return it as is.

So, looking at this we're only interested in 32 bits of the ID and last 64 bits. In Elixir this is very easy to extract with pattern matching:

<<_::binary-size(12),
  id::binary-size(4),
  _::binary-size(24),
  origin_timestamp::binary-size(8)>> = request

In my implementation I hardcoded the ID so I use: <<_::binary-size(40), origin_timestamp::binary>>.

We're done with the request, now we need to look at the response.

Exploring and building a response

As a starting point here is our response that we got from the working NTP server:

16:32:32.757240 IP 185.125.190.57.123 > 198.19.249.172.36991: UDP, length 48
  0x0000:  .... .... .... .... .... .... .... ....  E..L@...?..P.}.9
  0x0010:  .... .... .... .... .... .... 2402 03e7  .....{...8..$...
  0x0020:  0000 0044 0000 0017 c944 586a e85d 2bd7  ...D.....DXj.]+.
  0x0030:  9da3 dbc5 e85d 2c80 b9ab e514 e85d 2c80  .....],......],.
  0x0040:  beff 6d74 e85d 2c80 bf00 b637            ..mt.],....7

We will be building and reading only past 24 02 so I've masked everything else.

First thing we need to build is the flags. This will be static so we can hardcode it later on:

bits Description
00 Let's set the leap indicator^4 to unknown
100 The second three bits represent the NTP Version 4^5
100 And let's set this to be a server^6

So this in binary is 00100100 which is 24 in hexadecimal.

Then:

And now for the actually important fields:

bytes Value Description
C9 44 58 6A 201.68.88.106 Reference ID^12, later in implementation we will always use this one
E8 5D 2B D7 9D A3 DB C5 Jul 15, 2023 14:29:43.615781531 UTC Reference Timestamp^13
E8 5D 2C 80 B9 AB E5 14 Jul 15, 2023 14:32:32.725279157 UTC Origin Timestamp^14
E8 5D 2C 80 BE FF 6D 74 Jul 15, 2023 14:32:32.746085015 UTC Receive Timestamp^15
E8 5D 2C 80 BF 00 B6 37 Jul 15, 2023 14:32:32.746104610 UTC Transmit Timestamp^16

Closing notes on the reverse engineering

You will notice that ntpdate makes a couple of requests (visible in the pcap file). It does that by issuing separate requests with a different origin timestamp, and measuring the difference between the response. Then it calculates the offset and uses that to set the time. You can see that in the output of the command as well:

$ sudo ntpdate -u ntp.ubuntu.com
15 Jul 16:31:21 ntpdate[2273]: adjust time server 185.125.190.58 offset +0.113190 sec

Keep that in mind later when we will be implementing the server.

A simple UDP server in Elixir

Ok, so we established that we will need to listen and respond to UDP packets. If you look at the requests above you can see we will need also need to listen on port 123^17.

In Erlang there is a module :gen_udp^17 that has everything we need. Here is a little crash course on how to use it.

Crash course on :gen_udp module

There are really only three methods we need to know and understand: gen_udp.open/2^18, gen_udp.recv/2^19 and :gen_udp.send/4^20. We start the server with:

{:ok, socket} = :gen_udp.open(port, [:binary, {:active, false}])

and we receive it and respond with:

case :gen_udp.recv(socket, 0) do
  {:ok, {ip, port, data}} ->
    :gen_udp.send(socket, ip, port, "Hello, world!")

  {:ok, :udp_closed} ->
    # do something

  {:error, reason} ->
    # do something
end

Simple server

Easiest way to do this is with a recursive loop:

defmodule SimpleServer do
  def init(port) do
    {:ok, socket} = :gen_udp.open(port, [:binary, {:active, false}])

    loop(socket)
  end

  def loop(socket) do
    case :gen_udp.recv(socket, 0) do
      {:ok, :udp_closed} ->
        # do something

      {:error, reason} ->
        # do something

      {:ok, {ip, port, data}} ->
        :gen_udp.send(socket, ip, port, "Hello, world!")
    end
  end
end

SimpleServer.init(123)

So, calling init/1 with a port number will start the UDP server and call the first iteration of the loop method. In the loop/1 method we block on waiting for the request, and once we get it we call the loop/1 again.

To me this looked a bit like GenServer^21 so I propose we rewrite it as such.

A UDP GenServer

The idea is quite simple, we will still call the loop, but now with handle_continue/2 function in the GenServer.

This gives us an Application we can register later.

defmodule UdpServer do
  use GenServer

  def init(_params) do
    {:ok, socket} = :gen_udp.open(123, [:binary, {:active, false}])

    {:ok, socket, {:continue, :loop}}
  end

  def handle_continue(:loop, socket) do
    case :gen_udp.recv(socket, 0) do
      {:ok, :udp_closed} ->
        # TODO: implement

      {:error, reason} ->
        # TODO: implement

      {:ok, {ip, port, data}} ->
        :gen_udp.send(socket, ip, port, "Hello, world!")
        {:noreply, socket, {:continue, :loop}}
    end
  end
end

And if you want to register this somewhere the only thing we want to implement is the start_link/1^22 method for this module:

def start_link(params) do
  GenServer.start_link(__MODULE__, params, name: __MODULE__)
end

Now in your application.ex you can register it:

defmodule Application do
  use Application

  def start(_type, _args) do
    children = [
      UdpServer
    ]

    opts = [strategy: :one_for_one, name: NtpServer.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Cool! If you start this (if you don't know how look at the Appendix A of this post) you can test it out.

Building the NTP response in Elixir

In our GenServer we can add a new method: generate_ntp_response that will take the request, parse it, and return a new response.

So let's define it, and pattern match on the request right away:

def generate_ntp_response(<<_::binary-size(40), origin_timestamp::binary>> = _request) do
end

Next up we need the current time, which we can get with: System.system_time/1^23:

now = System.system_time(:second)

and using that we can set up our receive and transmit timestamps:

receive_timestamp = now
transmit_timestamp = receive_timestamp

Next up is building our bitstring that will hold our response. I use a custom sigil ~b so it's a bit easier to write out the hex numbers (check out Appendix B for the implementation and the alternative).

Header

Our header will contain a couple of things:

So putting this together:

header = ~b(23 02 03 E7) <> <<0::size(64)>>
id = ~b(56 17 C3 1E)

Timestamps

Next up we need four timestamps. The value 2_208_988_800 represents the number of seconds between January 1, 1900, and January 1, 1970. This specific value is significant in the context of the Network Time Protocol (NTP) because NTP uses a different epoch than Unix-based systems^24.

Unix-based systems, including Linux and macOS, use January 1, 1970, as the epoch (starting point) for representing time. However, NTP uses January 1, 1900, as its epoch.

To convert NTP timestamps to Unix timestamps (or vice versa), the offset of 2_208_988_800 is added or subtracted, depending on the direction of the conversion. Adding this offset ensures that the timestamps are properly aligned between the two systems.

For example, to convert an NTP timestamp to a Unix timestamp, you would add 2_208_988_800 to the NTP timestamp. This adjustment aligns the NTP time with Unix time, allowing for accurate comparisons and synchronization between NTP-based systems and Unix-based systems.

It's worth noting that the 2_208_988_800 offset is specific to NTP and the difference between the NTP epoch and Unix epoch. Other protocols or systems may use different epoch values and require different offsets for proper conversion between their timestamps and Unix timestamps.

All of our timestamps are seconds precision so we need to pad them a bit with 32 bits of 0.

reference_timestamp = <<receive_timestamp + @ntp_constant::size(32), 0::size(32)>>
origin_timestamp = origin_timestamp
receive_timestamp = <<receive_timestamp + @ntp_constant::size(32), 0::size(32)>>
transmit_timestamp = <<transmit_timestamp + @ntp_constant::size(32), 0::size(32)>>

Note that origin we just pass along.

Putting it all together

All of the above would look something like this in our code:

@ntp_constant 2_208_988_800

def generate_ntp_response(<<_::binary-size(40), origin_timestamp::binary>> = _request) do
  now = System.system_time(:second)

  receive_timestamp = now
  transmit_timestamp = receive_timestamp

  header = ~b(24 02 03 E7) <> <<0::size(64)>>
  id = ~b(56 17 C3 1E)
  reference_timestamp = <<receive_timestamp + @ntp_constant::size(32), 0::size(32)>>
  origin_timestamp = origin_timestamp
  receive_timestamp = <<receive_timestamp + @ntp_constant::size(32), 0::size(32)>>
  transmit_timestamp = <<transmit_timestamp + @ntp_constant::size(32), 0::size(32)>>

  <<header::binary, id::binary, reference_timestamp::binary, origin_timestamp::binary,
    receive_timestamp::binary, transmit_timestamp::binary>>
end

which, in turn, makes our handle_continue/2 function look like:

def handle_continue(:loop, socket) do
  case :gen_udp.recv(socket, 0) do
    {:ok, :udp_closed} ->
      Logger.warning("UDP socket closed")

    {:error, reason} ->
      Logger.error("Error: #{reason}")

    {:ok, {ip, port, request}} ->
      packet = generate_ntp_response(request)
      :gen_udp.send(socket, ip, port, packet)
      {:noreply, socket, {:continue, :loop}}
  end
end

In the finished implementation I've added some logging as well.

Trying it out

So we start our Elixir NTP server with:

$ iex -S mix

and we can get ready to set the time. First let's edit /etc/hosts file and set our Macs IP as some hostname:

127.0.1.1 ubuntu
127.0.0.1 localhost
::1   localhost ip6-localhost ip6-loopback
ff02::1   ip6-allnodes
ff02::2   ip6-allrouters
198.19.249.3  time.0x7f.dev

and then you can run $ sudo ntpdate -u time.0x7f.dev.

For those following along don't even try to set time on your computer with time.0x7f.dev. I am not crazy enough to run anything like this on public internet!

The end

With this nice little exercise we covered two very cool parts of Elixir which have confused me for a long time: bitstrings and GenServers. While this could have been solved without the use of those two concepts (see my DNS in JS post) this made it easier to read and understand.

I've also had fun implementing my own custom sigil, which I am not sure if I would recommend to do in production code since it looks very "built-in".

And in the end I ended up finally understanding tcpdump and how it can be used to inspect traffic. Take a look at Wireshark for easier to read preview of pcap files.

Oh, and just to mention this can't be used to fake uptime... Unfortunately for me, but probably for the best, uptime does not work by comparing time with the one got from NTP.

Appendix A - Creating and setting up a basic Elixir app with mix

Take a look at the Github repo^25 that follows this post to see the complete image, but here are the steps how you can set up yours. First up you need to create a new mix project with:

$ mix new ntp_server

Next up we need a lib/application.ex file that will host our Application for registering our GenServer:

# lib/application.ex
defmodule NtpServer.Application do
  use Application

  def start(_type, _args) do
    children = [
      NtpServer.UdpServer
    ]

    opts = [strategy: :one_for_one, name: NtpServer.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

and last we need to modify the mix.exs file:

# mix.exs
defmodule NtpServer.MixProject do
  use Mix.Project

  def project do
    [
      app: :ntp_server,
      version: "0.1.0",
      elixir: "~> 1.14",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  # Run "mix help compile.app" to learn about applications.
  def application do
    [
      extra_applications: [:logger],
      mod: {NtpServer.Application, []}
    ]
  end

  # Run "mix help deps" to learn about dependencies.
  defp deps do
    [
      # {:dep_from_hexpm, "~> 0.3.0"},
      # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
    ]
  end
end

to register our Application with: mod: {NtpServer.Application, []}.

Now you can start the app with iex -S mix and get the IEX and our app running for experimentation.

Appendix B - Our custom bitstring sigil

I've taken the time to implement our custom sigil^26 in this project ~b so we can save some time when writing out lists of hex values.

<<0x7F>>
# becomes
~b(7F)

This was very simple to do with a bit of scaffolding:

# lib/bitstring_sigil.ex
defmodule NtpServer.BitstringSigil do
  def sigil_b(string, _opts) do
    # TODO: implementation
  end
end

# lib/ntp_server.ex
defmodule NtpServer.UdpServer do
  use GenServer
  import NtpServer.BitstringSigil
end

And now for the algorithm (I used dbg/0 to explain everything):

string #=> "e3 00 03 fa"
|> String.upcase() #=> "E3 00 03 FA"
|> String.split("\n") #=> ["E3 00 03 FA"]
|> Enum.map(&String.split(&1, " ")) #=> [["E3", "00", "03", "FA"]]
|> List.flatten() #=> ["E3", "00", "03", "FA"]
|> Enum.reject(&(&1 == "")) #=> ["E3", "00", "03", "FA"]
|> Enum.join() #=> "E30003FA"
|> Base.decode16!() #=> <<227, 0, 3, 250>>

Appendix C - Sending UDP packets

For this we can use the netcat method:

$ nc -4u localhost 123

And what ever you send (end it with an Enter) you will get back Hello, world!.

If you find any typos or errors, please get in touch! I tried my best editing this but the post turned out huge.