
From application admin to SYSTEM: chaining a script console, in-memory staging, and a held privilege
Most real privilege escalation is not one exotic bug. It is a short stack of ordinary, individually reasonable capabilities that line up. This post walks one such stack end to end on a Windows application server: you reach an application admin console, turn that into code execution as the service account, carry a small helper onto the box without touching the network, and use a privilege that account should never have held to become SYSTEM and mint a local administrator.
Each of the three pieces has its own post here already. This one puts them together, because the chain is the point: no single link is a CVE, and a defender who understands the sequence can cut it at any of several places. I spend the second half on exactly that.
The three pieces
1. A script console that runs as the service account
Liferay ships a Groovy script console under Control Panel > Server Administration > Script. It is a legitimate operator feature: paste Groovy, run it inside the portal JVM, read the result. The security property that matters is that the code runs with the privileges of the account the portal service runs as, with full reach into the Java and Liferay APIs. Anyone who can open the console has code execution on the host as that service account.
def p = ["cmd.exe", "/c", "whoami"].execute(); p.waitFor(); out.println(p.in.text)
The console is not a vulnerability. It is a powerful feature whose safety depends entirely on who can reach it and how little the service account can do. Full detail, including read-only enumeration and the lockdown, is in the Liferay script console post.
2. Staging a helper through the console with gzip and base64
Sometimes the box has no route back to you: no outbound HTTP, filtered egress, no file-upload path. You still have the console, which moves text. So you move the binary as text. Compress it with gzip first to shrink it, base64 the result, paste that into a script that decodes, inflates, and writes the file, then verify the hash before you trust it.
def gz = "H4sIA...." // base64(gzip(helper.exe))
def raw = java.util.Base64.getDecoder().decode(gz)
def gis = new java.util.zip.GZIPInputStream(new ByteArrayInputStream(raw))
def f = new File("C:/ProgramData/stage/helper.exe")
f.parentFile.mkdirs(); f.withOutputStream { it << gis }
out.println("SIZE=" + f.length())
["cmd.exe","/c","certutil -hashfile C:/ProgramData/stage/helper.exe SHA256"].execute().with {
waitFor(); out.println(in.text)
}
Two practical limits. The script is a POST form field, so the servlet container's maximum POST size caps a single paste. On a default bundle that is about 2 MB of body, which after the base64 inflation of roughly a third leaves room for a compressed helper around 1.4 MB. Compress first, keep the helper small, and if it still does not fit, append it across several executions. This is a transfer technique, not an exploit: it is the same idea as certutil -encode or a base64 round trip, adapted to a text-only console.
3. A service account that holds SeTcbPrivilege
SeTcbPrivilege, the act as part of the operating system right, is one of a handful of privileges that are equivalent to full control of the machine. Windows trusts a process that holds it to speak for the operating system: such a process can acquire an SSPI credential handle bound to a logon session other than its own, including the built-in SYSTEM session, whose well known LUID is 0x3E7. A normal caller may only name its own session. A holder of SeTcbPrivilege may name SYSTEM.
From there the escalation is mechanical: with a SYSTEM-backed credential handle, open the Service Control Manager over that authenticated channel, create a service (a service with no explicit account runs as LocalSystem), and let the service command run as SYSTEM. Running as SYSTEM, create an account and add it to Administrators. The mechanism, the SCM 1053 timing quirk, and the working code are in the SeTcbPrivilege post and its proof of concept. Nothing here is corrupted or overflowed. A granted privilege is used exactly as designed, by a context that should never have held it.
Check the privilege is actually present before relying on it:
["cmd.exe","/c","whoami /priv"].execute().with { waitFor(); out.println(in.text) }
A line for SeTcbPrivilege means the token holds it. A state of Disabled is not absence: a held privilege can be self-enabled, which is the first thing the helper does.
Chaining them
Put the three in a line and the server falls:
- Reach the console. You hold application admin, by reused credentials, a leaked config, or a weak password. The console gives code execution as the portal service account, say
svc_app. - Confirm the precondition.
whoami /privfrom the console showssvc_appholdsSeTcbPrivilege. Service accounts for enterprise applications are routinely over-granted, so this is common, not rare. - Stage the helper. The box has no egress, so gzip plus base64 carries the SeTcb helper in through the console. Verify the SHA256 before use.
- Escalate. Run the helper from the console. It self-enables
SeTcbPrivilege, borrows the SYSTEM logon session, creates a short-lived service that runs as SYSTEM, and that service creates a local administrator. The service is deleted; the account stays. - Full control. You now hold a local administrator on the application server, independent of the console and the service account you started from.
The shape to notice: step 1 is an application exposure, step 3 is a transport trick, and steps 2 and 4 are a Windows configuration weakness. Three different owners, three different fixes. That is good news for a defender, because you do not have to win all three.
Tooling and usage
This chain is mostly manual plus one small helper, so the "tools" are commands and one published PoC. Placeholders throughout.
- Console RCE (Liferay Groovy console, as the service account), confirm the privilege is there:
Look for a["cmd.exe","/c","whoami /priv"].execute().with { waitFor(); out.println(in.text) }SeTcbPrivilegeline before continuing. Detail in the Liferay console post. - Stage the helper through the console when there is no egress: gzip, base64, then decode and verify on target (full snippet earlier in this post, and in the Liferay post's file-transfer section).
- Escalate with the SeTcb helper. This is a published PoC rather than a stock tool, because no off-the-shelf tool does this exact SeTcb-to-service-to-admin step:
Code, build, and mechanism: the SeTcb post and itsTcbElevationAddAdmin.exe <new-admin> <password>tcb-privilege-to-systemPoC.
Everything else uses built-in Windows and the application's own features. The SeTcb helper is the only piece worth building, which is why it is the one shipped as code; the rest is commands.
Defense
Break any one link and the chain fails. In rough order of leverage:
- Remove the precondition (highest leverage). Audit which accounts and services hold
SeTcbPrivilege(and its equally dangerous siblingsSeImpersonatePrivilege,SeAssignPrimaryTokenPrivilege,SeCreateTokenPrivilege,SeDebugPrivilege,SeRestorePrivilege,SeBackupPrivilege). Almost nothing legitimately needs SeTcb. Read the current assignment from the User Rights policy (Act as part of the operating system) and from each service's logon account, and strip it from anything that is not a documented requirement. Removing SeTcb fromsvc_appkills step 4 outright, no matter how the attacker reached the console. - Run the application as a least-privilege account. The portal service should run as a dedicated local account with no special privileges and no local admin, ideally a virtual or managed service account scoped to only what the app needs. Then console code execution is a contained foothold, not a path to SYSTEM.
- Lock down the console itself. Restrict the Server Administration console to a small set of named administrators, put the admin surface behind network controls so it is not broadly reachable, and treat portal admin credentials as privileged (unique, rotated, MFA where the product supports it). Disabling Groovy under System Settings does not disable the Server Administration script tab, so do not rely on that setting alone; control access instead. The console post has the specifics.
- Constrain the service account so staging and service creation are hard. Deny the service account the ability to create services where feasible, apply application control (WDAC or AppLocker) so an unsigned helper dropped into a writable directory will not execute, and remove write-and-execute overlap from directories the service can reach. Egress filtering is worth having regardless, though note that the gzip plus base64 path specifically defeats egress controls, which is exactly why the application-control and least-privilege layers matter more than the network layer here.
- Patch and baseline. Keep the application current; a patched console authorization bug or a fixed default credential removes step 1. Baseline which services and privileged accounts should exist so a new one stands out.
Detection
The chain is loud if you are collecting the right logs, because step 4 touches several high-signal Windows events in a tight window:
- Service account spawning a shell. The portal JVM (or its service) spawning
cmd.exe,powershell.exe,certutil.exe, or an unexpected binary from a staging directory is the first tell. Alert on child processes of the application service account that are not part of its normal behavior. - Sensitive privilege use. Security event 4672 (special privileges assigned at logon) and 4673 (sensitive privilege use) referencing
SeTcbPrivilegeoutside of known system processes. - Transient service install. System event 7045 and Security event 4697 for a service created and then quickly deleted, especially one whose binary path points at a user-writable or staging directory, or carries command-line arguments rather than a real service image.
- Account creation and admin add. Security 4720 (account created) followed closely by 4732 (member added to a security-enabled local group, Administrators), under the SYSTEM account. This pair, close in time and parented to the service install above, is the cleanest single signature for the whole chain.
- Staging artifacts.
certutil -hashfileorcertutil -encode/-decodeinvoked by the application service account, and new executables appearing in application-writable paths with no corresponding deployment. A Sigma rule for the service-install-to-admin sequence is in the proof-of-concept repository.
The highest-confidence correlation is the sequence, not any single event: sensitive-privilege use, then a transient service as SYSTEM, then a new local admin, all within seconds and all tracing back to the application service account. If you can alert on that ordering you catch the technique regardless of how the operator reached the console.
Use
This is written for authorized work: your own labs, systems you are explicitly engaged to test, and the blue-team job of recognizing and breaking the chain. The individual mechanisms are already public; the value of seeing them together is that it tells both sides where the cheap, decisive fixes are. Remove SeTcb from accounts that do not need it and the rest of the chain has nowhere to land.