# Create Module

> Create a new pathrunner exploit module from pathfinding.cloud path definition

- Skill: `datadog/create-module` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add datadog/create-module`
- Raw SKILL.md: https://api.skillmd.com/api/skills/datadog/create-module/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: datadog (https://skillmd.com/u/datadog)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/datadog/create-module

---


# Create Pathrunner Module

You are creating a new pathrunner exploit module. The user has provided a pathfinding-cloud ID (e.g., `ecs-001`). Your job is to:

1. Read all source data for this path
2. Analyze the demo_attack.sh for operational details (timing, retries, payload behavior)
3. Generate the Go exploit module + payloads (reusing existing where possible)
4. Wire up registration, cleanup handlers, and imports
5. Verify the build compiles and tests pass

## Source Data

Read these files to understand the attack path. Extract the service from the ID (e.g., `lambda` from `lambda-002`).

You MUST read all four files before proceeding to Step 1:

1. **Path definition (source of truth):** Use Glob to find the YAML at `../pathfinding.cloud/data/paths/{service}/{id}.yaml` (relative to the pathrunner repo root) and Read it
2. **Lab scenario:** Use Grep to find the matching `scenario.yaml` by searching for the ID in `../pathfinding-labs/modules/scenarios/`, then Read it
3. **Demo attack script:** Read `demo_attack.sh` from the same scenario directory found in step 2
4. **Cleanup script:** Read `cleanup_attack.sh` from the same scenario directory found in step 2

## Steps

### Step 0: SSO Preflight

Before any AWS-touching work, verify the SSO profiles you'll need are still alive. Both plabs and pathrunner's attacker identity commonly use short-term SSO profiles that expire silently.

```bash
./scripts/check-sso.sh plabs
```

If any profile shows FAIL/EXPIRED, tell the user which and stop — they need to run `aws sso login --profile <name>` interactively before you can proceed. Do NOT try to work around expired SSO by using `SKIP_SSO_CHECK=1` unless the user explicitly asks.

If the module will use attacker infra (any `revshell/*`, `exfil/https`, `exfil/s3`, or a Glue-style module uploading to the attacker code bucket), also verify the attacker profile: `./scripts/check-sso.sh check <attacker-profile-name>`. Discover the profile name via `./pathrunner attacker show`.

### Step 1: Parse and Validate

- Extract the service from the ID (e.g., `ecs` from `ecs-001`)
- Read all source data above
- Determine the category from the YAML (new-passrole, self-escalation, principal-access, existing-passrole, credential-access)
- Check if a module already exists: `grep -r '"$ARGUMENTS"' pkg/exploits/`
- If module exists, inform the user and stop

### Step 2: Analyze demo_attack.sh for Operational Details

**CRITICAL**: The demo_attack.sh script is the most important source of operational knowledge. It contains battle-tested timing, retry logic, and payload behavior that MUST be reflected in the module. Extract and document ALL of the following before writing any code:

#### 2a. Timing and Delays
- What `sleep` commands exist and what are they waiting for? (e.g., function initialization, policy propagation, event source mapping activation)
- What are the specific delay durations? Use these exact values — they were calibrated through testing.
- Are there polling loops? What are their intervals and max attempt counts?

#### 2b. Retry Logic
- Does the script retry operations? How many times and with what interval?
- What conditions trigger a retry vs a failure?
- What is the total max wait time? (e.g., `MAX_ATTEMPTS=30` * `sleep 10` = 5 minutes)
- These retry parameters must be translated directly into the module's Execute() method.

#### 2c. Payload Behavior (What the attack actually DOES)
- What specific AWS API calls does the injected code make? (e.g., `iam.attach_user_policy`, `iam.create_role`, `sts.get_caller_identity`)
- Does the payload need to return data to the caller, or does it take action independently?
- **For event-triggered modules** (ESM, CloudWatch Events, etc.): the function output is NOT returned to the attacker. Only payloads that take direct action (IAM modifications, webhook exfil) are useful. Payloads like `exfil/output` that rely on capturing the function response will NOT work.
- **For directly-invoked modules** (lambda:InvokeFunction, etc.): the function response IS captured, so `exfil/output` payloads work fine.

#### 2d. Resource Discovery
- Does the script discover resources dynamically? (e.g., `aws dynamodb describe-table` to get stream ARN)
- What environment variables or parameters does it extract from terraform/context?
- Which of these should become module Options vs discovered at runtime?

#### 2e. Verification Steps
- How does the script verify the exploit succeeded? (e.g., polling for policy attachment, listing users)
- For event-triggered modules: the payload SHOULD implement the `Verifiable` interface so the module can confirm the payload executed. The verification method should test the payload's effect using the starting user's credentials (e.g., call `iam:ListUsers` to check if admin policy was attached).
- For direct-invoke modules: verification usually comes from inspecting the function response.

#### 2f. Cleanup Permissions
- Does the starting user have permission to delete what they create? (Check the IAM policy in the Terraform module)
- If NOT: default `CLEANUP` to "false" and advise using `workspace cleanup` with admin credentials
- This is common — e.g., lambda-002 starting user has CreateFunction but NOT DeleteFunction

### Step 3: Check Existing Payloads and Determine What's Needed

The payload registry is **modular and service-scoped**: payloads live under `pkg/payloads/{service}/` (`ec2/`, `lambda/`, `glue/`), self-register in `init()`, and are queried by tag (`payloads.GetPayloadsByTags`, `payloads.GetPayloadForService`). Same logical name (e.g., `backdoor/attach-policy`) can exist under multiple services — the composite `(service, name)` key disambiguates. Your module doesn't enumerate specific payloads; it declares service tags via `GetCompatibleTags()` and every registered payload for that service becomes selectable.

- Check if `pkg/payloads/{service}/` directory exists
- If YES: list existing payloads and evaluate which are compatible
- **Match the payload to what demo_attack.sh does**: If the demo script attaches a policy to a user, don't use a payload that creates a new user. Create a new payload that matches the demo behavior if none exists.
- For event-triggered modules: exclude `exfil/response` from the recommended payloads since there's no caller to receive the response
- Only create new payloads when the exploitation pattern requires something not already available
- Not all modules need payloads — self-escalation and principal-access modules typically don't
- **If the payload depends on attacker infra** (listener callback, exfil bucket): note it now. The module itself does NOT need to know — the payload declares its own options (`LISTENER_IP`, `HTTPS_URL`, `EXFIL_BUCKET`, etc.) and pathrunner auto-populates them from the running listener / deployed bucket. See `payload-patterns.md` for the auto-inject contract.

### Step 4: Create the Module — Start From a Canonical Example

Rather than assembling the module from a list of rules, **copy the canonical module for your category and adapt it**. The canonicals below are known-good, tested, and idiomatic — they demonstrate the current signatures (`modules.ExecutionContext`), the `shared.PayloadHelper` for tag-based payload wiring, resource tracking, side-effect tracking, and (where applicable) attacker-infra integration. Read the canonical end-to-end before writing anything.

| Your module's shape | Canonical to copy | Read it because it demonstrates |
|---|---|---|
| **new-passrole** — provision a fresh compute resource with a privileged role and run a payload on it | `pkg/exploits/ec2_passrole/module.go` (ec2-001) | `shared.NewPayloadHelper(serviceTag, langTag)` for one-line payload wiring, `Discoverable` for AWS-side option enumeration, auto-detection of infra defaults (AMI, VPC, subnet), tracking the created compute resource, tracking payload `SideEffectReporter` output, `ExecutionContext` signature |
| **existing-passrole** — modify an existing resource's code/config to inherit its role's permissions | `pkg/exploits/lambda_updatecode_addpermission/module.go` (lambda-005) | Backing up original state before mutating so cleanup can restore it, injecting payload params directly into the generated code when the AWS API can't set env vars, tracking a policy/permission grant separately from the code change, handler-name adaptation, waiting for propagation only when the payload takes IAM action (SideEffectReporter check), CLEANUP defaulting to true because the caller can reverse everything |
| **new-passrole with attacker code artifacts** — payload must be hosted somewhere the victim service can fetch it from (Glue script, CodeBuild source, etc.) | `pkg/exploits/glue_passrole_job/module.go` (glue-003) | Using `ectx.AttackerIdentity` (separate from victim identity) to upload payload script to `attacker.GetCodeBucketInfo()` bucket, namespacing uploads under a per-run S3 prefix and cleaning them up (but leaving the bucket itself since it's persistent attacker infra), auto-populating `EXFIL_BUCKET` from `attacker.GetExfilBucket()`, auto-resolving `TARGET_USER` from the caller identity, passing payload options via job arguments (Glue `DefaultArguments`), verifying via the payload's `Verifiable` interface |

**For categories not covered by those three canonicals** — `self-escalation`, `principal-access`, `credential-access`, event-triggered `new-passrole` — grep `pkg/exploits/*/module.go` for the closest existing module in the same category and use it as your starting point. `iam_*` directories are all self-escalation or two-step; `sts_assume_role/` is principal-access; `lambda_passrole_esm/` is event-triggered new-passrole. If nothing quite matches, start from the closest canonical above and diverge only where the path genuinely demands it.

Once you've read the canonical, create `pkg/exploits/{service}_{technique}/module.go` by copying its shape and editing:
- `PathInfo` — populate from the YAML (ID, Name, Category, Services, Description, Permissions, Prerequisites, References, MITRE, Aliases)
- `Options()` — derive from demo_attack.sh's parameters, mirroring the canonical's naming style
- `Execute()` — translate demo_attack.sh's API calls, sleeps, retries, and verification into the canonical's flow. Use the demo's exact sleep durations and retry counts (they were calibrated through real testing).

The supporting files (`module-patterns.md`, `payload-patterns.md`, `checklist.md`) are reference material for cross-cutting concerns (registration mechanics, attacker-infra auto-injection contract, payload interface semantics, post-creation checklist) — consult them when a specific concern comes up, but don't try to follow them linearly.

### Step 5: Create New Payloads (if needed)

Only if Step 3 determined new payloads are needed. Rather than assembling from a rule list, **copy the closest existing payload in the same service and adapt it**:

- Match by shape, not just service: for a new IAM-modifying backdoor, copy `pkg/payloads/{service}/backdoor_attach_policy.go` and adjust the AWS API call and `SideEffectReporter` output; for a new out-of-band exfil, copy `exfil_https.go` or `exfil_s3.go`; for a new reverse shell, copy `revshell_tls.go`.
- Payload lives at `pkg/payloads/{service}/{technique}_{method}.go`; `init()` calls `payloads.Register(&MyPayload{})`. No wiring changes needed if the service directory already exists.
- If your payload is the first under a new service directory (e.g., first `pkg/payloads/ecs/…`), add a blank import to `cmd/pathrunner/main.go` and add the service tag to `pkg/payloads/tags.go`.
- The payload's Python/bash code should mirror what demo_attack.sh injects — same API calls, same error handling.
- If the payload takes IAM action (attaches policy, creates user, etc.): implement `SideEffectReporter` so the module can track modifications for cleanup (`pkg/payloads/lambda/backdoor_attach_policy.go` is the reference implementation).
- If the module is event-triggered (payload runs but its return value isn't captured): implement `Verifiable` so the module can confirm the effect via a follow-up API call (`pkg/payloads/lambda/backdoor_attach_policy.go` also demonstrates this).
- Lambda/Glue payloads read runtime params from `os.environ` (Python) or job args; EC2 payloads read shell env. Never string-substitute values into the source at code-gen time.
- If the payload depends on attacker infra (listener callback, exfil bucket), use the standard option names so the auto-inject/auto-populate wiring picks them up — see `payload-patterns.md` for the full contract.

### Step 6: Add Cleanup Handlers

Check `pkg/core/repl/session.go` `cleanupResource()` switch statement.
If the module creates resource types not already handled:
- Add new cases to the switch
- Implement cleanup functions following the existing pattern
- Import any new AWS SDK service packages needed

**Check demo_attack.sh AND cleanup_attack.sh** to identify ALL resource types created:
- Lambda functions, event source mappings, IAM roles/users/policies, EC2 instances, ECS services, etc.
- The cleanup script shows exactly what needs to be reversed — each cleanup step maps to a resource type

### Step 7: Wire Up Registration

- Verify the module's `init()` function calls `modules.Register("{id}", constructor)` — the primary key must be the pathfinding.cloud ID.
- **Exploit modules are picked up automatically**: `pkg/exploits/register.go` is generated by `scripts/gen_register.go` (invoked via `//go:generate` in `pkg/exploits/gen.go`). Running `make build` — or `go generate ./pkg/exploits/` — regenerates it and adds a blank import for your new directory. Do NOT edit `cmd/pathrunner/main.go` for a new exploit module.
- **Payloads are still wired manually**: If you introduced a new payload service directory (e.g., first payload under `pkg/payloads/ecs/`), add a blank import for it in `cmd/pathrunner/main.go` alongside the existing `pkg/payloads/{ec2,lambda,glue}` imports. Payloads within an existing service directory need no wiring changes — `init()` handles it.

### Step 8: Build and Verify

Run:
```bash
make build
```

`make build` runs `go generate ./pkg/exploits/` before compiling, so it regenerates `register.go` and then builds. If you prefer raw Go commands, run `go generate ./pkg/exploits/ && go build ./...`.

Fix any compilation errors.

### Step 9: Create Tests

Add unit tests in `tests/unit/`:
- Verify module loads by primary ID and aliases
- Verify PathInfo fields are correct

Add integration tests in `tests/integration/`:
- Verify `use {service}-{number}` works
- Verify `show info` displays correct metadata
- Verify `search {service}` finds the module

### Step 10: Run Tests

```bash
go test ./tests/...
```

Fix any test failures.

### Step 11: Final Verification

Run through the sibling `checklist.md` in this skill directory and confirm every item passes.

Report to the user:
- Module created: `{service}-{number}` at `pkg/exploits/{service}_{technique}/module.go`
- Payloads: list which were reused vs newly created, and WHY (reference demo_attack.sh behavior)
- Timing: list key delays and retry parameters extracted from demo_attack.sh
- Cleanup handlers: list any new ones added
- Test results: all passing
- Next step: deploy the lab scenario and run `/test-module <id> --iterate 5` to verify against real AWS. `test-module` will iterate up to 5 attempts on pathrunner-side failures (compile errors, wrong ARN parses, missing env vars, timing bugs) with hard-stops on lab-side or environmental failures.

