Back to blog

Hardening the XRPL EVM Node

A detailed look at the AI-assisted security audit behind XRPL EVM v11: 24 findings across validator management, IBC, module wiring, and the CI/CD pipeline, all fixed in 22 pull requests that shipped in node v11.0.0.

9 min readby XRPL EVM Team

Hardening the XRPL EVM Node: Inside Our AI-Assisted Security Audit

A look at how we used AI to stress-test the XRPL EVM node, what it found, and the pull requests we opened in response.

When we shipped XRPL EVM v11 in July, we grouped its security fixes by area and held back the specifics, in line with coordinated disclosure. With v11 now running on the network, this post is the detailed write-up we promised.

Every fix described below shipped in node v11.0.0. They came out of the AI-assisted audit process described in the v11 announcement: a written scope and threat model, one AI reviewer per area of the codebase, and human triage before any fix lands. That process covered not just the chain's Go code but also module wiring, dependencies, and the CI/CD pipeline that builds the node, and the findings reflect that breadth.


The numbers

The audit produced 24 accepted findings with merged fixes across the xrplevm/node repository, with the following severity breakdown:

SeverityCount
HIGH5
MEDIUM9
LOW7
INFO3

Every finding went through human triage, and each of these is addressed by one of 22 merged pull requests on xrplevm/node. They are linked inline below and indexed at the bottom of this post for anyone who wants to read the diffs directly.

Rather than walk through every finding one by one, the rest of this post groups them by theme, because the patterns are more interesting than any single bug.


Theme 1: Tightening the validator set and the IBC perimeter

The findings with the highest blast radius all sit at the same boundary: the rules that decide who can become a validator and what can cross IBC.

Closing a gap in outbound IBC rate limiting. Rate limits on IBC transfers existed, but only for inbound packets. Outbound transfers went straight from the transfer keeper to the channel keeper, completely skipping the rate-limit middleware. We rewired the transfer stack so outbound packets now flow through transfer -> rateLimit -> channel, giving the chain symmetric protection in both directions. (PR #116)

Disabling the ICA host module. The Interchain Accounts host module was running with a wildcard AllowMessages, meaning any counterparty chain could ask the host to execute arbitrary messages. We wrote a v11.0.0 upgrade handler that disables the ICA host outright, removing that surface area until we have a concrete need and a tightly scoped allow list. (PR #127)

Defending the closed PoA validator set. The XRPL EVM uses Proof-of-Authority: validators are added exclusively through the x/poa module. The standard Cosmos MsgCreateValidator was unsatisfiable in practice (no user account holds bond-denom), but it had no explicit guard. We added a defense-in-depth ante check that blocks MsgCreateValidator post-genesis, with a height-zero carve-out so genesis bootstrapping still works as expected. We also patched a related bug in ExecuteAddValidator where an unbonding-delegation safety check was looking at the candidate as a validator (where they had no history) instead of as a delegator (where the actual records live). (PR #125, PR #126)


Theme 2: Module wiring and silent fallbacks

Another cluster of findings was less dramatic but no less important: the chain was technically functional, but several keepers and configuration paths were quietly initialized with the wrong values.

A precompile holding a zero-value keeper. The EVM gov precompile was constructed by passing app.GovKeeper by value before the keeper had been initialized. Because the precompile then took the address of its local copy, it permanently held a pointer to a keeper with nil internals. Reordering the keeper construction in app/app.go so GovKeeper is built before the EVM keeper restored the precompile to a working state. (PR #115)

Honest defaults instead of silent fallbacks. The chain ID parser silently fell back to a hardcoded 9999 whenever it encountered a malformed value, masking misconfiguration at startup. The keyring backend default was forced to test (unencrypted JSON files), so any operator who did not explicitly set --keyring-backend=os was using the insecure backend. The interface registry constructor's error was being thrown away. Each of these is now wired to fail loudly: errors surface, defaults are honest, and the node refuses to start in a bad state instead of papering over it. (PR #121, PR #120, PR #133)

Plumbing the rest of the keepers correctly. A small set of follow-on fixes registered the x/poa params subspace, removed a duplicate erc20 entry in the genesis module order that would have run the module's genesis initialization twice on a fresh chain, and removed a Cosmos SDK test gRPC service that had been mistakenly registered in the production app. We also added the missing StoreUpgrades switch cases for the v7-v10 upgrades. (PR #138, PR #118, PR #140, PR #129)


Theme 3: CI/CD as a security control

Modern node software is only as trustworthy as the pipeline that builds and ships it. A meaningful portion of the audit's findings, including several of the highest-severity ones, lived not in Go code but in our GitHub Actions workflows and Dockerfiles.

We consolidated the bulk of the CI hardening into a single PR (#117), which:

  • Adds a top-level permissions: {} (deny-all) block to every workflow and grants per-job least-privilege scopes, so a compromised step cannot reach beyond the resources it actually needs.
  • Pins every third-party action to a full commit SHA with a trailing version comment. Floating @v3 tags can be overwritten upstream, and pinning to immutable SHAs neutralizes that class of supply-chain attack.
  • Removes an SSH key that had been written to runner disk and copied into the released Docker image as part of an old workaround for a private Go module that is no longer in use.

On top of that, we:

  • Pinned the Go base image in our Dockerfile to a sha256 digest, so a tag re-push on Docker Hub cannot silently change what we ship. (PR #119)
  • Hardened a release workflow that was interpolating user-controlled workflow_dispatch input directly into a shell run: block, by moving the value into an env: variable so the shell treats it as data, not script. (PR #123)
  • Retired our long-stale Cosmovisor tooling and its Docker image rather than continue publishing an outdated artifact. (PR #135)

Theme 4: Dependency and test hygiene

A few smaller but worthwhile cleanups:

  • ibc-go was pinned to a development pseudo-version. Upstream had since published a stable tag (v10.4.0) for the same commit, so we swapped the label. (PR #122)
  • cosmossdk.io/core was being silently downgraded by a replace directive (the manifest asked for v0.12.0, which is retracted upstream and incompatible with the SDK fork we use). We aligned the requirement with the version actually being resolved. (PR #134)
  • We migrated off the archived github.com/golang/mock (no longer maintained) to its community-supported successor go.uber.org/mock. (PR #130)
  • A shared test fixture in the PoA suite always returned success from staking hooks, so the error-handling path of validator removal was never exercised. Each test case now sets up its own expectations, including the hook-error cases. (PR #142)

What we took away

A few lessons from this round are worth carrying forward:

  1. Wiring order is a security property. Two of the high-severity findings came from keeper wiring: one keeper was copied before it had been initialized, and another was connected to the wrong layer of the IBC stack. Building keepers before they are passed along, and checking what each one is wired to, prevents this kind of mistake.
  2. Honest failure beats silent fallback. Magic defaults like a hardcoded 9999 chain ID or a test keyring backend make life easier for the operator who is using them correctly, and dangerous for the one who is not. Failing fast at startup is the kinder option for both.
  3. Defense-in-depth at the ante layer is cheap insurance. Several of the additions are guards against situations that "cannot happen today." The cost is a few lines on the hot path; the upside is that a future regression elsewhere does not silently become an exploit here.
  4. CI is part of the chain's trust boundary. Permissions blocks, SHA-pinned actions, pinned base images and no secrets on disk each reduce the chance that a compromised dependency or workflow reaches what we ship.
  5. AI-assisted review is a force multiplier, not a replacement. The audit was particularly effective at the "thousand-paper-cuts" problems (dependency drift, missing tests, configuration defaults, wiring order) where traditional reviews can lose focus. It also raised findings that turned out not to apply on closer inspection, which is why every one went through human triage. It complements human auditors and our own engineering review; it does not replace them.

The XRPL EVM node will continue to receive this kind of attention as the network grows. If you want to follow along with the work in real time, the repository is open at github.com/xrplevm/node, and every change referenced in this post links to its pull request.


Appendix: Pull Request Index

PRSeveritySummary
#115HIGHPass initialized GovKeeper to gov precompile instead of zero-value
#116HIGHEnforce rate limit on outbound IBC transfers
#117HIGHHarden GitHub Actions workflows (permissions, SHA pins, SSH key removal)
#118MEDIUMRemove duplicate erc20types entry from genesis module order
#119MEDIUMPin Dockerfile base image to SHA256 digest
#120MEDIUMRemove keyring-backend=test default override
#121MEDIUMReplace hardcoded EVM chain ID fallback with proper error handling
#122MEDIUMReplace ibc-go pseudo-version with v10.4.0 tag
#123MEDIUMHarden TAG workflow input to prevent shell injection
#125MEDIUMBlock MsgCreateValidator post-genesis in PoA ante decorator
#126MEDIUMCheck unbonding delegations from delegator perspective in ExecuteAddValidator
#127MEDIUMAdd v11 upgrade handler disabling ICA host module
#129LOWInclude v7-v10 upgrades in store upgrades switch
#130LOWMigrate from archived golang/mock to go.uber.org/mock
#133LOWHandle interface registry creation errors
#134LOWAlign cosmossdk.io/core requirement with resolved version
#135LOWRemove stale Cosmovisor tooling
#137INFORemove unused PoA GovKeeper dependency
#138LOWRegister PoA params subspace
#139INFORemove stale staking_tokens attribute from PoA add_validator event
#140INFORemove SDK test gRPC service from production app
#142LOWCover staking-hook error handling in PoA MsgServer remove

More news

View more