Project notes

Building "Nodemation": An n8n Sandbox-Escape Forensics Challenge

How I built the Nodemation lab, generated the incident evidence, and designed the investigation path for participants.

For “Nodemation”, I wanted to make a forensics challenge where participants could investigate evidence from an actual vulnerable application. That meant setting up the lab, running the incident, and collecting what it produced. I initially thought collecting the artifacts would be the easy part. Then I had to make sure the workflow history, execution records, network traffic, and memory excerpt all described the same incident. That part took more work than I expected :3

The challenge is based on an n8n sandbox-escape patch bypass, published as GHSA-gv7g-jm28-cr3m and reported by Security Joes in July 2026.

This page covers how I built the lab, why I made some of the evidence that way, and what I wanted participants to understand during the investigation. The full participant walkthrough, answers, and solver commands are kept in PoC.md in the challenge repository.

Design Approach

The main thing I wanted participants to do was connect the evidence. Finding a suspicious workflow should give them somewhere to start. After that, they still need to understand what it does, check whether it actually ran, and identify which network activity belongs to it.

So I split the evidence into two groups:

SourceArtifacts
Generated locallyREADME.txt, .env, audit.log, workflow_versions.json, workflow_owners.pdf, docker-inspect.txt
Collected from the live labworkflow_export.json, traffic.pcap, executions.json, n8n-event-log.json, c2-memory.bin

Some artifacts had to be generated manually because the corresponding enterprise audit source was not available in this single-node lab. Those files provide the background for the case, including ownership, configuration, and the workflow edit history. For the execution records, packet capture, event log, and memory excerpt, I wanted participants to work with data collected from the running environment. The generated files still had to agree with that evidence. Otherwise, participants could follow the right idea and end up with a timeline that does not make sense.

Building the Lab

Everything ran inside one disposable Docker container, using the vulnerable n8n version:

docker run -d --name n8n-vuln-ctf --hostname elsche --restart no `
  -p 127.0.0.1:5678:5678 -p 127.0.0.1:31337:31337 `
  -e N8N_ENCRYPTION_KEY=... `
  -e N8N_USER_FOLDER=/home/node/.n8n `
  -e GENERIC_TIMEZONE=Asia/Jakarta `
  -e N8N_RUNNERS_ENABLED=false `
  n8nio/n8n:2.30.4

A few of these settings also become relevant to the investigation:

  • The hostname, elsche, becomes part of the token sent to the listener.
  • The timezone gives the case its WIB context, so participants need to pay attention when comparing it with UTC timestamps.
  • N8N_ENCRYPTION_KEY is the secret read by the payload and used to encode the token.
  • Both published ports bind to loopback, keeping access to the lab services local to the host.

Seven Workflows

I put seven workflows inside the instance:

TypeCountPurpose
Malicious1Produces the valid exfiltration
Failed decoys2Produce earlier attempts that participants need to check
Normal4Provide ordinary workflow activity

All seven use the same basic structure: a webhook or cron trigger connected to a Set node. The normal workflows are Customer Onboarding, Retention Cleanup, Slack Digest, and Asset Sync. They provide background activity so the malicious workflow is not the only thing happening in the instance.

The two decoys have different reasons for failing. One tries to connect to a closed local port, which produces a SYN followed by a RST. The other reaches the listener but uses the wrong key, so decoding its token does not produce the expected value. I wanted participants to be able to explain why each candidate failed. That gives them something concrete to verify when they compare the workflows and traffic.

All three payload variants come from the same stage-two template. The template has three placeholders, %%KEY%%, %%HOST%%, and %%PORT%%:

def build_stage2s() -> dict:
    real = render_stage2("process.env.N8N_ENCRYPTION_KEY || ''",
                         "127.0.0.1", 31337)
    # decoy: closed local port (connection refused -> clean RST, no exfil)
    decoy_dead = render_stage2("process.env.N8N_ENCRYPTION_KEY || ''",
                               "127.0.0.1", 8081)
    # plausible-looking decoy key (not a self-announcing fake)
    decoy_wrong_key = render_stage2("'3d91b27c1a99079a61a194fa4da72ac1'",
                                    "127.0.0.1", 31337)
    return {"real": real, "dead": decoy_dead, "wrong_key": decoy_wrong_key}

Each variant is base64-encoded, split into fragments, and placed in separate Set-node fields. I will explain the reason for splitting it later.

The static evidence has its own generator. That generator does not touch files that need to come from the live lab, such as the PCAP or execution history. I kept that separation because mixing those steps up can leave the package with an empty capture.

Getting the Right Workflow Version to Run

This was one of the parts where the setup looked correct before it actually was. Imported workflows start inactive. In this single-main setup, importing the JSON was not enough to activate them. After activation, I also had to make sure the malicious version was the published version.

For a while, the webhook returned 200, but nothing reached the C2 listener. I kept checking the payload, even though the instance was still serving the old, harmless workflow version. So, yes, a successful webhook response did not mean I was testing the version I thought I was testing.

I used a few small scripts to update n8n’s SQLite database. The publish step inserts or replaces the workflow’s published-version record:

db.run(
  "INSERT OR REPLACE INTO workflow_published_version " +
  "(workflowId, publishedVersionId, createdAt, updatedAt) " +
  "VALUES (?, ?, STRFTIME('%Y-%m-%d %H:%M:%f','NOW'), " +
          "STRFTIME('%Y-%m-%d %H:%M:%f','NOW'))",
  [wid, vid]
);

The other scripts activate the seven workflows, assign their owners, and adjust the creation and update timestamps to fit the case. Without that last step, the export would show that everything had been created only a few minutes earlier. I wanted the workflows to have an existing history, with the malicious edit appearing shortly before the incident.

The history mapping looks roughly like this:

const history = {
  'wf-nightly-backup-0001':      { created: '2026-05-18 09:00:00.000',
                                   updated: /* the tamper, see the PoC */ },
  'wf-invoice-totalizer-0002':   { created: '2026-03-28 14:10:00.000', ... },
  // ... five more
};
db.run('UPDATE workflow_entity SET createdAt=?, updatedAt=? WHERE id=?', ...);

After that experience, I made checking the running workflow version part of every rebuild. I wanted to verify that the published version actually contained the payload before generating the evidence.

There was also a node-version issue. The escape expression needed to be in a Set v1 node. Set v2 reads a different parameter key and silently drops the expression in this setup. The execution still succeeded, but it finished in only a few milliseconds and produced no callout. That unusually short execution was what made me look at the node configuration again.

How the Attack Works

The scenario uses an insider account with workflow-editor rights, matching the account access described in the advisory. The account edits an existing workflow and places a sandbox-escape expression in a Set node. The escape combines two behaviors:

  1. A concise arrow body, () => process, resolves the real Node.js process object because the sandbox’s arrow-function branch performs no effective check.
  2. Reflect.get(process, 'getBuiltinModule') avoids the static property check and provides access to the built-in module loader.

From there, the expression loads child_process and calls execSync, allowing commands to run as the n8n process. The command decodes and evaluates the base64-encoded stage-two payload. Keeping stage two separate also gives participants another piece of logic to recover from the workflow.

The Stage-Two Payload

The stage-two behavior is fairly small:

  1. Read N8N_ENCRYPTION_KEY from the environment.
  2. Build a token in the form hostname-epoch.
  3. XOR the token with material derived from sha256(key + "::n8n-exfil-v1").
  4. Send the encoded value to the local listener through GET /log?d=v1:<hex>.

I kept the payload free of comments explaining the attack. Participants need to recover its behavior from the code itself.

The interaction looks like this:

sequenceDiagram
    participant G as Traffic generator
    participant N as n8n
    participant P as Stage-two process
    participant C as Local listener

    G->>N: Trigger backup workflow
    N->>P: Expression escapes the sandbox
    P->>P: Read encryption key
    P->>P: Build and XOR the host token
    P->>C: GET /log?d=v1:...
    C-->>P: 200 ok

Why I Split the Payload

The first version had a fairly obvious problem when I looked at it from the participants’ side. The malicious expression was around 1,600 characters long, while the other fields were around 30 characters. A length scan immediately pointed at it. Searching for execSync also returned only one useful result. That made the first step much easier than I intended. Participants could find the answer candidate before understanding much of the workflow.

I split the encoded payload across several Set-node fields named cacheKey, salt, ttl, endpoint, and checksum. The fragments are reassembled in node order and then base64-decoded. Each fragment stays within a similar length range to the normal workflow expressions. The two decoys also contain the same escape-related keywords, so a search now identifies three candidates. A keyword search is still a useful starting point. Participants then need to reconstruct each candidate and check its behavior against the other evidence.

Generating the Traffic

The traffic generator runs while tcpdump captures the activity. It produces normal webhook and health-check requests, followed by the two decoy attempts and the real exfiltration request. It also adds the obfuscated key packet, background DNS traffic, and fake TLS handshakes.

The key packet is one of the details I liked working on. The supplied .env contains a rotated placeholder key, and the real key is redacted from docker-inspect.txt. Participants therefore need another source for the key used during the incident. I put it in a monitoring configuration POST:

const obf = Buffer.from(KEY.split('').reverse().join(''))
  .toString('base64');
const payload = `{"key":"${obf}","app":"monitor","v":2}`;

// -> POST /api/config  (looks like boring telemetry)

The value is the reversed key encoded with base64. Recovering it is a small decoding step once participants find the request. I wanted the capture to contain something useful outside the obvious /log callouts, so participants have a reason to inspect the surrounding traffic too.

Collecting the Evidence

Once the workflow could run correctly, I still had to collect and package its output. This part involved a few more setup problems than I expected, hehe.

Getting tcpdump Into the Container

The n8n Alpine image did not have a usable apk command, so I could not install tcpdump through the usual package-manager command. I fetched the Alpine package index manually, found the matching tcpdump and libpcap packages, downloaded the APKs, and extracted the binaries and shared libraries into the container. It was a temporary workaround for a disposable lab, but it let me collect the traffic I needed.

The capture starts before the traffic generator and stops after it finishes:

docker exec -u 0 n8n-vuln-ctf sh -c `
  "tcpdump -i any -s 0 -w /tmp/traffic.pcap"

I used -i any because n8n, the C2 listener, and the decoy health server all run inside the same container. -s 0 keeps the packet payloads from being truncated. The encoded token is part of what participants need to recover, so the capture has to preserve the full request.

The Memory Artifact

I also wanted participants to inspect a piece of volatile evidence. The C2 listener keeps a hex-encoded seed in memory. The intended step is to identify and decode that value, so I avoided keeping the plaintext seed directly available as a simple strings result.

After the incident traffic finishes, I signal the listener to dump its own readable memory regions:

docker exec -u 0 n8n-vuln-ctf sh -c `
  'kill -USR2 "$(pgrep -f "node /tmp/c2_stub.js" | head -n1)"'

The signal handler reads /proc/self/maps, selects the heap, stack, and anonymous writable regions, then copies their contents from /proc/self/mem into one binary excerpt. The resulting c2-memory.bin is a controlled process-memory excerpt. It contains selected regions written out by the listener itself, so it should be understood within that scope.

I wanted the memory step to stay manageable. Participants can work through it with a hex editor and a hash function, without needing a multi-gigabyte physical-memory image or a Volatility-based workflow.

Exporting the Application Evidence

The remaining live artifacts come from the n8n instance:

docker exec n8n-vuln-ctf n8n export:workflow `
  --all --pretty --output=/tmp/export.json

docker exec n8n-vuln-ctf node /tmp/db_harvest_executions.js

These steps provide the workflow export and execution records. I also collect the raw n8n event log. The event log is useful because it records the application’s activity during the run. Participants can compare that with the supplied audit trail and workflow history when reconstructing the incident.

Making the Timestamps Agree

This was probably the part I underestimated most. Every time I reproduced the lab, the evidence used the current time. The token also contained the Unix epoch of that run, which meant the encoded output changed every time I rebuilt the challenge.

I wanted the final package to describe one fixed incident. That meant shifting the evidence onto the case timeline while preserving the relationships between events. The re-clocking pass uses the captured token as its anchor and moves packet timestamps, execution windows, and event-log entries together.

The relationships still need to hold after that adjustment:

  • The malicious edit comes before the workflow trigger.
  • The exfiltration packet falls inside the relevant execution window.
  • The execution completes after the callout.

Moving one timestamp independently could break those relationships. So this had to be part of the build process, followed by validation of the resulting evidence. It sounds like a small packaging detail, but the timeline is also one of the things participants are supposed to use to verify their findings. I had to make sure it actually worked.

Participants’ View

Participants receive the evidence package and work through the incident from those artifacts. The intended investigation is roughly:

  1. Inspect the workflow export and identify the three suspicious candidates.
  2. Reassemble and decode stage two to understand the callout and token format.
  3. Inspect the capture to see which connections reached the listener.
  4. Match the relevant traffic to its workflow execution window.
  5. Recover the key material from the configuration request and verify the token.
  6. Inspect the listener’s memory excerpt for the remaining evidence.

The workflow alone gives participants a possible explanation. The execution records and network traffic let them check whether that explanation fits what happened.

What I Wanted Participants to Connect

The case has several records describing different parts of the same sequence:

EvidenceWhat it contributes
Audit trailRecords the malicious edit
Workflow version historyShows the change from v1 to v2
Published-version recordPlaces publication after the edit
Execution recordsProvide the trigger and execution window
Packet captureShows the callout during that execution
Event logProvides application activity to compare with the other records

Participants need to check that these records agree with each other. The decoys make that check more useful. The closed-port attempt fails at the connection stage. The wrong-key attempt reaches the listener, but its token does not decode correctly with the recovered incident key. Both contain suspicious logic, but participants can explain why neither is the valid exfiltration for the case.

A Few Artifact Details

The ownership register is a PDF with white text on a white background. It looks blank when opened, but selecting the text or extracting its text layer reveals the contents. I included that as a small reminder to inspect the file beyond what is immediately visible on the page.

The configuration artifacts also need some context. The .env file contains a rotated placeholder, while the container environment dump has the real key redacted. The supplied configuration therefore cannot be treated as a complete record of the key used during the incident. Participants need to compare it with the captured activity and verify the recovered value against the token.

Checking the Final Package

Before packaging, I scanned every artifact as raw bytes for the flag prefix, including the JSON files, PDF, PCAP, and memory excerpt. The flag itself is only kept on a separate quiz server. Participants submit eight findings to receive it. That lets the evidence contain the intermediate values needed for the investigation without containing the final flag.

I also added validators to re-check the evidence after regeneration. Those checks matter because rebuilding the lab changes several things at once, especially timestamps and token values. I wanted to be able to rebuild the challenge and still get a package that supports the same investigation.

What I Learned

The exploit was only one part of making this challenge. I also had to understand which workflow version n8n was running, collect the traffic at the right time, preserve the useful memory regions, and keep the generated history consistent with the live evidence.

From the author side, I already knew which workflow was malicious and which request contained the valid token. Participants need enough evidence to reach that conclusion themselves. That became the main thing I kept checking: can someone opening these files explain what happened and show why their answer fits?

The full participant investigation, answers, solver commands, and verification tools are available in the challenge repository.