ElixirConf 2022 Teller Challenge Writeup

Michael Lubas, 2022-09-06

At this year’s ElixirConf Teller hosted a challenge in Elixir, write an Elixir client for a banking application to get the secret account number and balance. This is a writeup for the remote attendee instance, if you played this in-person at ElixirConf the setup was different.

After signing up for an account you land on this page:

The phone is interactive, clicking on the teller icon brings you to a sign in page:

Clicking the bottom brings you back to the home screen:

The 4Passwords app has some accounts:

You may have noticed the telnet command at the bottom of the screen. Let’s log into an account and see what happens on the wire:

Sign into an account from 4Passwords:

2FA prompt:

Tap output:

When testing the security of real phone apps, you have to setup a proxy like Burp suite to intercept and view the traffic. Teller has taken the hard part out of it and built a system to view the HTTP requests right in your terminal.

Let’s start with this HTTP request:

POST /login
user-agent: Teller Bank iOS 1.1
api-key: HowManyDevsDoesItTakeToConnectAMacbookToAProjector?
device-id: OABEYPYLEVI7BCKX
content-type: application/json
accept: application/json
{
  "password": "elixirconf",
  "username": "elixir"
}

Looks like a standard POST request with some custom headers set, simple enough to make with Elixir.

Now the response:

200 OK
teller-is-hiring: https://jobs.lever.co/teller/dd21975a-901b-49ee-926c-336c92f8673d
f-request-id: req_irlajx6h5ild7wams3qbrfdt47dxyvine3wqyty
timestamp: 1662417292388
request-token: QTEyOEdDTQ.pVUdNRTFXaGfmkweVpXr3gHXXkFZ9xZphywdqCEQVO9sQj-F0GKuKPPiMz0.VcID-aye0M1AkkrU.wihi84bOdAYVzo2ws0wq5d7gDz45MTQ5DKd1Ajo_rd9NLDxypEE9uJKvOyXM3rhwSNlHWMdJfbeNjduTtfXau8YwSOtgpxxhoRXW5qGOQENQQBRo-OSKZJKfB8De71SlVqLtzGKFQHl-y1yh9osY.iMXKOn8cD6ElM9-pTGjY-w
f-token-spec: eyJtZXRob2QiOiJmdW4tZ3VlcnItZ2piLXN2aXItZnZrLW9uZnItZ3V2ZWdsLWdqYi15YmpyZS1wbmZyLWFiLWNucXF2YXQiLCJzZXBhcmF0b3IiOiIlIiwidmFsdWVzIjpbImRldmljZS1pZCIsInVzZXJuYW1lIiwibGFzdC1yZXF1ZXN0LWlkIiwiYXBpLWtleSJdfQ
content-type: application/json; charset=utf-8
{
  "devices": [
    {
      "id": "sms_ad_ybaznjpuk6drvvgjiuuxwolnwskzk4n3h2a2lzq",
      "mask": "***1639",
      "type": "SMS"
    },
    {
      "id": "voice_ad_ybaznjpuk6drvvgjiuuxwolnwskzk4n3h2a2lzq",
      "mask": "***1639",
      "type": "VOICE"
    }
  ],
  "mfa_required": true
}

Making this request in Elixir is simple enough.

Livebook setup:

Mix.install([:req, :jason, :kino])
defmodule Teller do
  @url "https://challenge.teller.engineering"
  @api_key "HowManyDevsDoesItTakeToConnectAMacbookToAProjector?"
  @user_agent "Teller Bank iOS 1.1"
  @device_id "GWBZHHAS2UHT45IO"
  @app_json "application/json"
  @teller_hire "I know!"

  def basic_login(username, password) do
    base_headers = [
      user_agent: @user_agent,
      api_key: @api_key,
      device_id: @device_id,
      content_type: @app_json,
      accept: @app_json,
      teller_is_hiring: @teller_hire
    ]

    # Initial Login Request
    body = get_login_body(username, password)
    %Req.Response{headers: _resp_headers, body: body} =
      Req.post!("#{@url}/login", body: body, headers: base_headers)
    body
  end

  def get_login_body(username, password) do
    %{
      username: username,
      password: password
    }
    |> Jason.encode!()
  end
end

Output:

iex(4)> Teller.basic_login("yellow_millie", "brazil")
%{
  "devices" => [
    %{
      "id" => "sms_ad_hgq37cjwsuuyoquc36j2645vvmznsietievs7ji",
      "mask" => "***7954",
      "type" => "SMS"
    },
    %{
      "id" => "voice_ad_hgq37cjwsuuyoquc36j2645vvmznsietievs7ji",
      "mask" => "***7954",
      "type" => "VOICE"
    }
  ],
  "mfa_required" => true
}

Let’s move on to the SMS flow:

Outbound request (you want to make this with Elixir):

POST /login/mfa/request
teller-is-hiring: I know!
user-agent: Teller Bank iOS 1.1
api-key: HowManyDevsDoesItTakeToConnectAMacbookToAProjector?
device-id: LMGNUMGLVMETT3S3
request-token: QTEyOEdDTQ.pVUdNRTFXaGfmkweVpXr3gHXXkFZ9xZphywdqCEQVO9sQj-F0GKuKPPiMz0.VcID-aye0M1AkkrU.wihi84bOdAYVzo2ws0wq5d7gDz45MTQ5DKd1Ajo_rd9NLDxypEE9uJKvOyXM3rhwSNlHWMdJfbeNjduTtfXau8YwSOtgpxxhoRXW5qGOQENQQBRo-OSKZJKfB8De71SlVqLtzGKFQHl-y1yh9osY.iMXKOn8cD6ElM9-pTGjY-w
f-token: mab5ykrfvnudevwhitzf5bccjo6dnv6pgkoc5j4oi6b2llpajguq
content-type: application/json
accept: application/json
{
  "device_id": "sms_ad_ybaznjpuk6drvvgjiuuxwolnwskzk4n3h2a2lzq"
}

Response from server:

200 OK
teller-is-hiring: https://jobs.lever.co/teller/dd21975a-901b-49ee-926c-336c92f8673d
f-request-id: req_xb4geyt72qbiaft7fmfvmldofsh37kn3jq7goqa
timestamp: 1662417296681
request-token: QTEyOEdDTQ.El0DI_2JT70MlEQ9uDitgxUIctIrucjbkzLujD2bJi_PI7TfiRQx_Xqsxbc.m-ztq6EA5j-Mz_wr.CGyQsTmpGiNi5EcMkvBF2v1s684R6f_w6uLJU_u1sTk1gTsy3CjDWO5NP0Xyfm9pcTKdO9rbT0o8GS3DFrNfLB4EscApoYVwsmH1Ys-aSawx_55wrkVNG2II8SPlRH9v-T7-7XwCtzK78HMLy4GZp3nTcwmE6icDQzJk.8WfaxzAV_nItDNe32fissg
f-token-spec: eyJtZXRob2QiOiJmdW4tZ3VlcnItZ2piLXN2aXItZnZrLW9uZnItZ3V2ZWdsLWdqYi15YmpyZS1wbmZyLWFiLWNucXF2YXQiLCJzZXBhcmF0b3IiOiIlIiwidmFsdWVzIjpbImxhc3QtcmVxdWVzdC1pZCIsImFwaS1rZXkiLCJkZXZpY2UtaWQiLCJ1c2VybmFtZSJdfQ
content-type: application/json; charset=utf-8
{}

Now we come to the first real challenge, computing what f-token is. The request-token value you send in the POST is the same as the last response.

If you’ve worked with JWTs at all, you’ll know the distinctive “ey” suffix. You can decode it in Elixir with:

spec =
  "eyJtZXRob2QiOiJmdW4tZ3VlcnItZ2piLXN2aXItZnZrLW9uZnItZ3V2ZWdsLWdqYi15YmpyZS1wbmZyLWFiLWNucXF2YXQiLCJzZXBhcmF0b3IiOiIlIiwidmFsdWVzIjpbImRldmljZS1pZCIsInVzZXJuYW1lIiwibGFzdC1yZXF1ZXN0LWlkIiwiYXBpLWtleSJdfQ"

Base.decode64!(spec, padding: false)
|> Jason.decode!()

Output:

%{
  "method" => "fun-guerr-gjb-svir-fvk-onfr-guvegl-gjb-ybjre-pnfr-ab-cnqqvat",
  "separator" => "%",
  "values" => ["device-id", "username", "last-request-id", "api-key"]
}

It looks like f-token-spec says how to create an f-token, but there’s a problem with the top line. It seems all the letters are in the English alphabet, let’s try a Caesar cipher first.

Consider the charlist/integer mapping of:

a 97
z 122
- 45
inspect('az-', charlists: :as_lists)

> "[97, 122, 45]"

Lets write a Caesar cipher decoder. Since the input is all lower case, it is only a few lines.

defmodule Caesar do
  def brute(s) do
    charlist = to_charlist(s)

    Enum.map(0..25, fn x ->
      Enum.map(charlist, fn c ->
        if c in 97..122 do
          compute_c(c, x)
        else
          c
        end
      end)
    end)
  end

  def compute_c(char, x) do
    base_c = char - 97 + x
    rem(base_c, 26) + 97
  end
end

Caesar.brute("fun-guerr-gjb-svir-fvk-onfr-guvegl-gjb-ybjre-pnfr-ab-cnqqvat")

Output:

['fun-guerr-gjb-svir-fvk-onfr-guvegl-gjb-ybjre-pnfr-ab-cnqqvat',
...
'sha-three-two-five-six-base-thirty-two-lower-case-no-padding',
...
...
,]

This means you need to use SHA3-256, and base32 encoding.

One important thing to keep in mind: this spec changes! The SHA3-256 and base32 part is constant, but the separator and order of the values changes. Once you’re aware of this, writing the actual code is pretty straightforward.

Decoded spec (one configuration, seperator and value order changes):

%{
  "method" => "sha-three-two-five-six-base-thirty-two-lower-case-no-padding",
  "separator" => "%",
  "values" => ["device-id", "username", "last-request-id", "api-key"]
}
defmodule Teller do

  def get_f_token(resp_headers, username) do
    f_spec =
      get_f_spec(resp_headers)
      |> Base.decode64!(padding: false)
      |> Jason.decode!()

    separator = f_spec["separator"]
    f_values = f_spec["values"]
    req_id = get_req_id(resp_headers)

    f_token_string = get_f_token_string(separator, f_values, username, req_id)

    :crypto.hash(:sha3_256, f_token_string)
    |> Base.encode32(case: :lower, padding: false)
  end

  def get_f_token_string(sep, f_values, username, req_id) do
    Enum.reduce(f_values, "", fn v, acc ->
      acc <> sep <> get_f_value(v, username, req_id)
    end)
    |> String.trim_leading(sep)
  end

  def get_f_value(v, username, req_id) do
    case v do
      "device-id" ->
        @device_id

      "api-key" ->
        @api_key

      "username" ->
        username

      "last-request-id" ->
        req_id
    end
  end

  def get_req_id(resp_headers) do
    Map.new(resp_headers)["f-request-id"]
  end

  def get_f_spec(resp_headers) do
    Map.new(resp_headers)["f-token-spec"]
  end
end

Now that the code to construct the f-token is written, the challenge is almost solved. The last part involves decrypting the bank account number. You’re given the following info:

The UI has the full account number, for the accounts in the password manager. If you try to view the special account you were given for this challenge in the UI, you get:

Oh no!

The tap gives you:

"masked_number": "0176", (the last 4 digits of the bank account # are 0176)

{
  "cipher": "128-ECB",
  "key": "QX3+OS2BpO+PDDZU5ZsOQA=="
}

{
  "ach": "110000000",
  "id": "acc_nuebche4p4mkvvlj3viwt4ya7hpg6htnm24t23q",
  "name": "My Checking",
  "number": "5KHljqToPeP5kBHHTPODYHXtCwfGaxxg5dtKSKNx70M=",
  "product": "Flex Checking Account"
}

You already know the full account is 468195920176, the goal is to implement this in Elixir code, then run it with the special credentials for the challenge.

Now let’s decrypt that account number:

defmodule Teller do

  def decrypt() do
    key_base = "QX3+OS2BpO+PDDZU5ZsOQA=="
    ciphertext_base = "5KHljqToPeP5kBHHTPODYHXtCwfGaxxg5dtKSKNx70M="
  
    key = Base.decode64!(key_base)
    ciphertext = Base.decode64!(ciphertext_base)

    :crypto.crypto_one_time(:aes_128_ecb, key, ciphertext, false)
  end
end

Output:

Teller.decrypt()

<<28, 206, 198, 181, 184, 143, 44, 31, 177, 71, 49, 136, 242, 247, 160, 17, 52, 54, 56, 49, 57, 53, 57, 50, 48, 49, 55, 54, 4, 4, 4, 4>>

Scroll to the right, notice the padding, that’s a good sign. Remeber the length of the account number is 12.

# The padding could be variable, so use :binary.part on it
> <<_h::binary-16, an_padded::binary>> = Teller.decrypt() 
> to_remove = :binary.last(an_padded)
> :binary.part(an_padded, 0, byte_size(an_padded) - to_remove)
> "468195920176"

Success!

Overall this was a great exercise. Having the virtual phone and telnet tap made understanding the challenge much easier. Hopefully this trend of Elixir CTF-like puzzles at conferences continues.

Link to Livebook with full solution.

Thank you to the Teller.io team for putting together a great challenge.


Paraxial.io stops data breaches by helping developers ship secure applications. Get a demo or start for free.

Subscribe to stay up to date on new posts.