Skip to content

Commit

Permalink
node: Add Transfer Verifier mechanism (#4169)
Browse files Browse the repository at this point in the history
Adds a new package and command-line tool for Transfer Verification. This is a process of validating message publications from the core contracts. When a message is emitted, Transfer Verification will examine the corresponding receipt or other logs to ensure that funds were sent into the token bridge whenever this kind of message is emitted from the core bridge. 
This is a defense-in-depth mechanism to guard against a scenario where an attacker finds a way to spoof message publications.
  • Loading branch information
johnsaigle authored Jan 21, 2025
1 parent 4f0e46f commit 2615d55
Show file tree
Hide file tree
Showing 30 changed files with 6,406 additions and 10 deletions.
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ issues:
text: "^func.*supervisor.*(waitSettle|waitSettleError).*$"
linters:
- unused
# This file contains hard-coded Sui core contract addresses that are marked as hardcoded credentials.
- path: pkg/txverifier/sui_test.go

text: "G101: Potential hardcoded credentials"
34 changes: 34 additions & 0 deletions Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,11 @@ if evm2:
)


# Note that ci_tests requires other resources in order to build properly:
# - eth-devnet -- required by: accountant_tests, ntt_accountant_tests, tx-verifier
# - eth-devnet2 -- required by: accountant_tests, ntt_accountant_tests
# - wormchain -- required by: accountant_tests, ntt_accountant_tests
# - solana -- required by: spydk-ci-tests
if ci_tests:
docker_build(
ref = "sdk-test-image",
Expand Down Expand Up @@ -635,6 +640,16 @@ if ci_tests:
sync("./testing", "/app/testing"),
],
)
docker_build(
ref = "tx-verifier-monitor",
context = "./devnet/tx-verifier-monitor/",
dockerfile = "./devnet/tx-verifier-monitor/Dockerfile"
)
docker_build(
ref = "tx-verifier-test",
context = "./devnet/tx-verifier-monitor/",
dockerfile = "./devnet/tx-verifier-monitor/Dockerfile.cast"
)

k8s_yaml_with_ns(
encode_yaml_stream(
Expand All @@ -644,6 +659,11 @@ if ci_tests:
"BOOTSTRAP_PEERS", str(ccqBootstrapPeers)),
"MAX_WORKERS", max_workers))
)

# transfer-verifier -- daemon and log monitoring
k8s_yaml_with_ns("devnet/tx-verifier.yaml")

k8s_yaml_with_ns("devnet/tx-verifier-test.yaml")

# separate resources to parallelize docker builds
k8s_resource(
Expand Down Expand Up @@ -676,6 +696,20 @@ if ci_tests:
trigger_mode = trigger_mode,
resource_deps = [], # testing/querysdk.sh handles waiting for query-server, not having deps gets the build earlier
)
# launches tx-verifier binary and sets up monitoring script
k8s_resource(
"tx-verifier-with-monitor",
resource_deps = ["eth-devnet"],
labels = ["tx-verifier"],
trigger_mode = trigger_mode,
)
# triggers the integration tests that will be detected by the monitor
k8s_resource(
"tx-verifier-test",
resource_deps = ["eth-devnet", "tx-verifier-with-monitor"],
labels = ["tx-verifier"],
trigger_mode = trigger_mode,
)

if terra_classic:
docker_build(
Expand Down
5 changes: 4 additions & 1 deletion devnet/eth-devnet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ spec:
containers:
- name: anvil
image: eth-node
# This command generates additional accounts compared to the default of 10. The purpose is to use dedicated
# accounts for different aspects of the test suite. When adding new integration tests, consider increasing
# the number of accounts below and using a fresh key for the new tests.
command:
- anvil
- --silent
- --mnemonic=myth like bonus scare over problem client lizard pioneer submit female collect
- --block-time=1
- --host=0.0.0.0
- --accounts=13
- --accounts=14
- --chain-id=1337
ports:
- containerPort: 8545
Expand Down
5 changes: 4 additions & 1 deletion devnet/eth-devnet2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ spec:
containers:
- name: anvil
image: eth-node
# This command generates additional accounts compared to the default of 10. The purpose is to use dedicated
# accounts for different aspects of the test suite. When adding new integration tests, consider increasing
# the number of accounts below and using a fresh key for the new tests.
command:
- anvil
- --silent
- --mnemonic=myth like bonus scare over problem client lizard pioneer submit female collect
- --block-time=1
- --host=0.0.0.0
- --accounts=13
- --accounts=14
- --chain-id=1397
ports:
- containerPort: 8545
Expand Down
10 changes: 10 additions & 0 deletions devnet/tx-verifier-monitor/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# There's nothing special about this version, it is simply the `latest` as of
# the creation date of this file.
FROM alpine:3.20.3@sha256:1e42bbe2508154c9126d48c2b8a75420c3544343bf86fd041fb7527e017a4b4a

RUN apk add --no-cache inotify-tools

COPY monitor.sh /monitor.sh
RUN chmod +x /monitor.sh

CMD ["/monitor.sh"]
13 changes: 13 additions & 0 deletions devnet/tx-verifier-monitor/Dockerfile.cast
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# These versions are pinned to match the Dockerfile in the `ethereum/`
# directory. Otherwise, there is nothing special about them and they can be
# updated alongside the other Dockerfile.
FROM --platform=linux/amd64 ghcr.io/foundry-rs/foundry:nightly-55bf41564f605cae3ca4c95ac5d468b1f14447f9@sha256:8c15d322da81a6deaf827222e173f3f81c653136a3518d5eeb41250a0f2e17ea as foundry
# node is required to install Foundry
FROM node:19.6.1-slim@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d2b73af9d73b044f5f57cfc

COPY --from=foundry /usr/local/bin/cast /bin/cast

COPY transfer-verifier-test.sh /transfer-verifier-test.sh
RUN chmod +x /transfer-verifier-test.sh

CMD ["/transfer-verifier-test.sh"]
64 changes: 64 additions & 0 deletions devnet/tx-verifier-monitor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Transfer Verifier -- Integration Tests

## EVM Integration Tests

### Overview

The Transfer Verifier tests involve interacting with the local ethereum devnet defined by the Tilt set-up in this repository.

The basic idea is as follows:
* Interact with the local Ethereum testnet. This should already have important pieces such as the Token Bridge and Core Bridge deployed.
* Use `cast` from the foundry tool set to simulate malicious interactions with the Token Bridge.
* Transfer Verifier detects the malicious messages and emits errors about what went wrong.
* The error messages are logged to a file
* A "monitor" script is used to detect the expected error message, waiting until the file is written to
* If the monitor script sees the expected error message in the error log, it terminates

## Components

### Scripts

#### transfer-verifier-test.sh

Contains the `cast` commands that simulate malicious interactions with the Token Bridge and Core Bridge. It is able to broadcast
transactions to the `anvil` instance that powers the Ethereum testnet while being able to impersonate arbitrary senders.

This lets us perform actions that otherwise should be impossible, like causing a Publish Message event to be emitted from the Core Bridge
without a corresponding deposit or transfer into the Token Bridge.

#### monitor.sh

A bash script that monitors the error log file for a specific error pattern. It runs in an infinite loop so it will
not exit until the error pattern is detected.

The error pattern is defined in `wormhole/devnet/tx-verifier.yaml` and matches an error string in the Transfer Verifier package.

Once the pattern is detected, a success message is logged to a status file. Currently this is unused but this set-up
could be modified to detect that this script has written the success message to figure out whether the whole test completed successfully.

### Pods

The files detailed below each have a primary role and are responsible for running one of the main pieces of the test functionality:

* The Transfer Verifier binary which monitors the state of the local Ethereum network
* The integration test script that generates activity that the Transfer Verifier classifies as malicious
* The monitor script which ensures that the Transfer Verifier successfully
detected the error we expected, and signals to Tilt that the overall test has
succeeded

#### devnet/tx-verifier.yaml

Runs the Transfer Verifier binary and redirects its STDERR to the error log file. This allows the output of the binary
to be monitored by `monitor.sh`.

#### devnet/tx-verifier-test.yaml

Runs the `transfer-verifier-test.sh` script which simulates malicious Token Bridge activity. Defines the RPC URL used
by that bash script, which corresponds to the `anvil` instance created in the Ethereum devnet.

#### devnet/tx-verifier-monitor.yaml

Defines the expected error string that should be emitted by the Transfer Verifier code assuming that it successfully recognizes
the malicious Token Bridge activity simulated by the `cast` commands in `transfer-verifier-test.sh`.

It also defines a path to the log file that contains this string.
25 changes: 25 additions & 0 deletions devnet/tx-verifier-monitor/monitor.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/sh

log_file="${ERROR_LOG_PATH:-/logs/error.log}"
error_pattern="${ERROR_PATTERN:-ERROR}"
status_file="/logs/status"

# Wait for log file to exist and be non-empty
while [ ! -s "${log_file}" ]; do
echo "Waiting for ${log_file} to be created and contain data..."
sleep 5
done

# Initialize status
echo "RUNNING" > "$status_file"
echo "Monitoring file '${log_file}' for error pattern: '${error_pattern}'"

# Watch for changes in the log file. If we find the error pattern that means we have
# succeeded. (Transfer verifier should correctly detect errors.
inotifywait -m -e modify "${log_file}" | while read -r directory events filename; do
if grep -q "$error_pattern" "$log_file"; then
echo "SUCCESS" > "$status_file"
echo "Found error pattern. Exiting."
exit 0
fi
done
120 changes: 120 additions & 0 deletions devnet/tx-verifier-monitor/transfer-verifier-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
set -euo pipefail

RPC="${RPC_URL:-ws://eth-devnet:8545}"

# mainnet values
# export CORE_CONTRACT="0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B"
# export TOKEN_BRIDGE_CONTRACT="0x3ee18B2214AFF97000D974cf647E7C347E8fa585"

# TODO these could be CLI params from the sh/devnet script
CORE_BRIDGE_CONTRACT=0xC89Ce4735882C9F0f0FE26686c53074E09B0D550
TOKEN_BRIDGE_CONTRACT=0x0290FB167208Af455bB137780163b7B7a9a10C16

MNEMONIC=0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d

ERC20_ADDR="0x47bdB2D7d6528C760b6f228b3B8F9F650169a10f" # Test token A

VALUE="1000" # Wei value sent as msg.value
TRANSFER_AMOUNT="10"

# This account is generated by anvil and can be confirmed by running `anvil --accounts=13`.
# The accounts at other indices are used by other tests in the test suite, so
# account[13] is used here to help encapsulate the tests.
ANVIL_USER="0x64E078A8Aa15A41B85890265648e965De686bAE6"
ETH_WHALE="${ANVIL_USER}"
FROM="${ETH_WHALE}"
# Anvil user1 normalized to Wormhole size. (The value itself it unchecked but must have this format.)
RECIPIENT="0x00000000000000000000000064E078A8Aa15A41B85890265648e965De686bAE6"
NONCE="234" # arbitrary

# Build the payload for token transfers. Declared on multiple lines to
# be more legible. Data pulled from an arbitrary LogMessagePublished event
# on etherscan. Metadata and fees commented out, leaving only the payload
PAYLOAD="0x"
declare -a SLOTS=(
# "0000000000000000000000000000000000000000000000000000000000055baf"
# "0000000000000000000000000000000000000000000000000000000000000000"
# "0000000000000000000000000000000000000000000000000000000000000080"
# "0000000000000000000000000000000000000000000000000000000000000001"
# "00000000000000000000000000000000000000000000000000000000000000ae"
"030000000000000000000000000000000000000000000000000000000005f5e1"
"000000000000000000000000002260fac5e5542a773aa44fbcfedf7c193bc2c5"
"9900020000000000000000000000000000000000000000000000000000000000"
"000816001000000000000000000000000044eca3f6295d6d559ca1d99a5ef5a8"
"f72b4160f10001010200c91f01004554480044eca3f6295d6d559ca1d99a5ef5"
"a8f72b4160f10000000000000000000000000000000000000000000000000000"
)
for i in "${SLOTS[@]}"
do
PAYLOAD="$PAYLOAD$i"
done

echo "DEBUG:"
echo "- RPC=${RPC}"
echo "- CORE_BRIDGE_CONTRACT=${CORE_BRIDGE_CONTRACT}"
echo "- TOKEN_BRIDGE_CONTRACT=${TOKEN_BRIDGE_CONTRACT}"
echo "- MNEMONIC=${MNEMONIC}"
echo "- FROM=${FROM}"
echo "- VALUE=${VALUE}"
echo "- RECIPIENT=${RECIPIENT}"
echo

# Fund the token bridge from the user
echo "Start impersonating Anvil key: ${ANVIL_USER}"
cast rpc \
anvil_impersonateAccount "${ANVIL_USER}" \
--rpc-url "${RPC}"
echo "Funding token bridge using the user's balance"
cast send --unlocked \
--rpc-url "${RPC}" \
--from $ANVIL_USER \
--value 100000000000000 \
${TOKEN_BRIDGE_CONTRACT}
echo ""
echo "End impersonating User0"
cast rpc \
anvil_stopImpersonatingAccount "${ANVIL_USER}" \
--rpc-url "${RPC}"

BALANCE_CORE=$(cast balance --rpc-url "${RPC}" $CORE_BRIDGE_CONTRACT)
BALANCE_TOKEN=$(cast balance --rpc-url "${RPC}" $TOKEN_BRIDGE_CONTRACT)
BALANCE_USER=$(cast balance --rpc-url "${RPC}" $ANVIL_USER)
echo "BALANCES:"
echo "- CORE_BRIDGE_CONTRACT=${BALANCE_CORE}"
echo "- TOKEN_BRIDGE_CONTRACT=${BALANCE_TOKEN}"
echo "- ANVIL_USER=${BALANCE_USER}"
echo

# === Malicious call to transferTokensWithPayload()
# This is the exploit scenario: the token bridge has called publishMessage() without a ERC20 Transfer or Deposit
# being present in the same receipt.
# This is done by impersonating the token bridge contract and sending a message directly to the core bridge.
# Ensure that anvil is using `--auto-impersonate` or else that account impersonation is enabled in your local environment.
# --private-key "$MNEMONIC" \
# --max-fee 500000 \
echo "Start impersonate token bridge"
cast rpc \
--rpc-url "${RPC}" \
anvil_impersonateAccount "${TOKEN_BRIDGE_CONTRACT}"
echo "Calling publishMessage as ${TOKEN_BRIDGE_CONTRACT}"
cast send --unlocked \
--rpc-url "${RPC}" \
--json \
--gas-limit 10000000 \
--priority-gas-price 1 \
--from "${TOKEN_BRIDGE_CONTRACT}" \
--value "0" \
"${CORE_BRIDGE_CONTRACT}" \
"publishMessage(uint32,bytes,uint8)" \
0 "${PAYLOAD}" 1
echo ""
cast rpc \
--rpc-url "${RPC}" \
anvil_stopImpersonatingAccount "${TOKEN_BRIDGE_CONTRACT}"
echo "End impersonate token bridge"

# TODO add the 'multicall' scenario encoded in the forge script

echo "Done Transfer Verifier integration test."
echo "Exiting."
32 changes: 32 additions & 0 deletions devnet/tx-verifier-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
apiVersion: batch/v1
kind: Job
metadata:
name: tx-verifier-test
spec:
# Number of successful pod completions needed
completions: 1
# Number of pods to run in parallel
parallelism: 1
# Time limit after which the job is terminated (optional)
# activeDeadlineSeconds: 100
# Number of retries before marking as failed
backoffLimit: 4
template:
metadata:
labels:
app: tx-verifier-test
spec:
restartPolicy: Never
containers:
- name: tx-verifier-test
image: tx-verifier-test
command:
- /bin/bash
- -c
- "/transfer-verifier-test.sh"
env:
- name: RPC_URL
value: "ws://eth-devnet:8545"
volumes:
- name: log-volume
emptyDir: {}
Loading

0 comments on commit 2615d55

Please sign in to comment.