Audience: On-call team, protocol maintainers, anyone with admin key access.
Scope: Compromised or lost admin key for the StellarForge token-factory contract on mainnet.
Status: Active — review and re-approve after every mainnet deployment and after any change to admin key custody.
- Threat model
- How compromise would be detected
- Authority and contacts
- Immediate response (first five minutes)
- Short-term stabilisation (first hour)
- [Recovery (first 24 hours)](#6-recovery-first-24 hours)
- Break-glass recovery mechanism
- Post-incident steps
- Communication plan
- Tabletop exercise checklist
The factory contract's admin address is a single point of catastrophic trust. An attacker in possession of the admin key can:
| Action | Effect |
|---|---|
update_fees(admin, base_fee, metadata_fee) |
Set arbitrarily high fees to drain users who call the contract |
set_fee_split(admin, splits) |
Redirect collected fees to an attacker-controlled address |
upgrade(admin, new_wasm_hash) |
Replace the contract with arbitrary attacker code |
transfer_admin(admin, new_admin) / update_admin(admin, new_admin) |
Lock out the legitimate operator permanently |
pause(admin) |
Halt the factory, denying service to all token creators |
Upgrade detection gap: upgrade currently emits no on-chain event (see issue #9). Until event emission is added, detection of a malicious WASM swap requires active polling of the on-chain WASM hash. This runbook treats upgrade detection latency as high-risk and calls it out explicitly.
Detection relies on multiple independent signals. Any single signal is enough to begin the response procedure.
All Sentry events captured during transaction lifecycles are tagged with network, contractId, and functionName (see issue #944). An unusual spike in:
functionName: deployTokenerrors withInsufficientFeecodes (fee manipulation)functionName: pollTransactionfailures across many unrelated users
should trigger investigation of the admin state.
Set a Sentry alert rule:
- Filter:
network = mainnet,functionName = deployToken,code = InsufficientFee - Condition: event count > 3 in 5 minutes
- Action: page on-call channel immediately
Subscribe to factory contract events via Horizon's Server-Sent Events (SSE) endpoint:
curl -N "https://horizon.stellar.org/accounts/<FACTORY_CONTRACT>/operations?cursor=now"Alert on:
- Any
adm_updevent (admin transfer — extremely rare in normal operation) - Any
feesevent with unusually large values - Any
pauseevent not preceded by a planned maintenance notice
Until upgrade emits an event, poll the on-chain WASM hash at least once per hour via a monitoring script:
#!/usr/bin/env bash
# check-wasm-hash.sh — run this on a cron schedule (every 5 minutes on mainnet)
EXPECTED_HASH="<paste-the-deployed-wasm-hash-here>"
CURRENT_HASH=$(stellar contract invoke \
--id "$FACTORY_CONTRACT_ID" \
--network mainnet \
-- get_state | jq -r '.wasm_hash // empty')
if [ "$CURRENT_HASH" != "$EXPECTED_HASH" ]; then
echo "ALERT: WASM hash changed from $EXPECTED_HASH to $CURRENT_HASH" | \
curl -X POST -d @- "$ALERT_WEBHOOK_URL"
fiStore EXPECTED_HASH after each intentional upgrade and update the script immediately.
Token creators reporting sudden fee increases or failed create_token calls with no contract-level change on your end are a strong indicator of fee manipulation.
Maintain a live copy of this table in a private team channel. Do not embed real names or personal contact details in this public document.
| Role | Responsibility | Contact |
|---|---|---|
| Incident commander | Declares the incident, coordinates all actions, is the single decision-maker | See team contact list |
| Admin key custodian | Has access to the hardware wallet / multisig device holding the admin key | See team contact list |
| Break-glass custodian | Has access to the backup admin (break-glass) account | See team contact list |
| Communications lead | Drafts and publishes user-facing notices | See team contact list |
| Legal / compliance | Advises on disclosure obligations | See team contact list |
Minimum team size for executing on-chain recovery: two people (incident commander + admin key custodian). Never execute admin-level transactions alone.
Do not act on a single noisy signal. Cross-check two or more of the detection signals in section 2 before proceeding.
# 1. Check current admin address
stellar contract invoke \
--id "$FACTORY_CONTRACT_ID" \
--network mainnet \
-- get_state | jq '.admin'
# 2. Check current fees
stellar contract invoke \
--id "$FACTORY_CONTRACT_ID" \
--network mainnet \
-- get_base_fee
# 3. Check current WASM hash against known-good value
stellar contract invoke \
--id "$FACTORY_CONTRACT_ID" \
--network mainnet \
-- get_state | jq '.wasm_hash'Even if you are the admin key custodian, do not act alone. Call the incident commander first. If unreachable within 2 minutes, escalate to the next person in the authority chain.
If the admin key has not yet been used by the attacker to transfer admin rights away:
stellar contract invoke \
--id "$FACTORY_CONTRACT_ID" \
--source "$ADMIN_SECRET_KEY" \
--network mainnet \
-- pause \
--admin "$ADMIN_ADDRESS"pause halts create_token, create_tokens_batch, mint_tokens, and set_metadata. It does not halt burn, so users can always recover their own balances. Fees already collected cannot be retrieved via this mechanism.
⚠️ If the attacker has already calledtransfer_adminto a new address, yourpausecall will fail withUnauthorized. Skip to step 4.
If the admin key is still operable but you suspect imminent key-theft (e.g. private key was exposed in logs):
stellar contract invoke \
--id "$FACTORY_CONTRACT_ID" \
--source "$ADMIN_SECRET_KEY" \
--network mainnet \
-- transfer_admin \
--admin "$ADMIN_ADDRESS" \
--new_admin "$BREAK_GLASS_ADDRESS"This transfers control to the pre-agreed break-glass account (see section 7), rendering the compromised key inoperative for any further admin actions.
Immediately post the following to the team's incident Slack/Discord channel:
🚨 INCIDENT DECLARED — StellarForge mainnet factory
Time: <UTC timestamp>
Incident commander: <name>
Suspected action: <fee manipulation | admin transfer | WASM upgrade | unknown>
Admin key status: <operable | compromised — transferred away | unknown>
Factory paused: yes / no / failed
Next action: <describe>
- Monitor Sentry and Horizon for continued anomalous transactions.
- Assess whether funds were drained via
set_fee_splitbefore the pause. - Do not unpause until the admin key custody situation is fully resolved and a new admin address has been established.
- The contract is now under attacker control.
- Do not attempt to call contract functions with the old admin key — the transactions will fail and may leak information about your response timing.
- Begin the break-glass procedure in section 7.
- Note: if the attacker calls
upgradewith a malicious WASM, the contract code itself is replaced. Verify the WASM hash before trusting any contract return values.
Determine whether users were overcharged:
# Look for fee events with unusually high values
stellar contract invoke \
--id "$FACTORY_CONTRACT_ID" \
--network mainnet \
-- get_base_feeCompare against the last known legitimate value from the deployment log. Document all affected transactions for later user communication.
# Get the current on-chain WASM hash
CURRENT_HASH=$(stellar contract invoke \
--id "$FACTORY_CONTRACT_ID" \
--network mainnet \
-- get_state | jq -r '.wasm_hash')
# Compare against the known-good hash from the deployment log
echo "Deployed hash: $KNOWN_GOOD_HASH"
echo "On-chain hash: $CURRENT_HASH"If the hashes differ and no authorised upgrade was performed, treat the contract as fully compromised. Do not use it until re-deployed with a new contract ID (see issue #32 for the forensic WASM-comparison process).
- Generate a new admin keypair on an air-gapped machine or hardware wallet.
- Once the break-glass account holds admin rights, call
transfer_adminfrom the break-glass account to the new admin address. - Revoke the compromised key from all systems immediately.
Before unpausing the factory, conduct an emergency review of how the admin key was compromised:
- Was it stored insecurely (clipboard, environment variable, unencrypted file)?
- Was the hardware wallet device physically accessed?
- Was phishing involved?
Document findings and apply remediation before resuming operations.
A upgrade to attacker-controlled WASM is the most severe scenario. The existing contract at the original address is now malicious and must not be used.
Recovery steps:
- Deploy a new factory contract at a fresh contract address.
- Re-initialize with the new admin address.
- Notify all integrators and the frontend config must be updated.
- Publish a security advisory explaining the impact.
Only unpause after:
- Admin key custody is restored to a known-good state.
- WASM integrity is confirmed against the known-good hash.
- Fee configuration is verified to be correct.
- At least one additional team member has independently verified steps 1–3.
stellar contract invoke \
--id "$FACTORY_CONTRACT_ID" \
--source "$NEW_ADMIN_SECRET_KEY" \
--network mainnet \
-- unpause \
--admin "$NEW_ADMIN_ADDRESS"This section documents the pre-agreed backup admin account that allows recovery from a scenario where the primary admin key is lost or the attacker has not yet taken over admin rights.
The break-glass account is a separate Stellar keypair held by a designated break-glass custodian. It is never used for routine operations. Its sole purpose is to receive admin rights via transfer_admin in an emergency, then:
- Pause the factory.
- Transfer admin to a freshly-generated recovery key.
- Facilitate any necessary fee or WASM restoration.
| Requirement | Detail |
|---|---|
| Storage | Hardware wallet (Ledger or equivalent) held by the break-glass custodian |
| Location | Physically separate from the primary admin hardware wallet |
| Access | Break-glass custodian only; known to the incident commander |
| Testing | Confirmed usable (non-zero XLM balance, key unlocked) at least once per quarter |
Do this before mainnet deployment. Record the result in the deployment log.
# Generate the break-glass keypair on an air-gapped machine
stellar keys generate break-glass --offline
# Fund the break-glass account with at least 5 XLM for transaction fees
stellar keys address break-glass
# Transfer 5+ XLM to this address from the treasury
# Record the break-glass public address (never commit the private key)
BREAK_GLASS_ADDRESS=$(stellar keys address break-glass)
echo "Break-glass address: $BREAK_GLASS_ADDRESS"Add BREAK_GLASS_ADDRESS to the deployment log. The private key stays on the hardware wallet.
When a compromise is confirmed and the primary admin key is still operable, immediately transfer admin rights to the break-glass address:
stellar contract invoke \
--id "$FACTORY_CONTRACT_ID" \
--source "$ADMIN_SECRET_KEY" \
--network mainnet \
-- transfer_admin \
--admin "$ADMIN_ADDRESS" \
--new_admin "$BREAK_GLASS_ADDRESS"For higher assurance, configure the admin address as a multisig account requiring M-of-N signers before executing any administrative transaction. This means a single leaked key cannot take any unilateral action.
Setup outline (Stellar multisig):
# Set the admin account to require 2-of-3 signers
stellar transaction new \
--source "$ADMIN_ADDRESS" \
--network mainnet \
-- set-options \
--master-weight 1 \
--low-threshold 2 \
--med-threshold 2 \
--high-threshold 2 \
--signer "$SIGNER_B_PUBLIC_KEY:1" \
--signer "$SIGNER_C_PUBLIC_KEY:1"With multisig, transfer_admin and upgrade calls require assembling a transaction and collecting M signatures before submission. This introduces latency in an emergency but dramatically reduces the blast radius of any single key compromise.
Complete all of the following after the immediate threat is neutralised.
# Decompile and diff the on-chain WASM against the last known-good build
stellar contract fetch \
--id "$FACTORY_CONTRACT_ID" \
--network mainnet \
--out-file recovered.wasm
# Compare
diff <(wasm-dis known-good.wasm) <(wasm-dis recovered.wasm)Document any differences as potential attacker modifications.
Review:
- When did the admin key leave the hardware wallet?
- Which machines accessed it?
- What processes had
ADMIN_SECRET_KEYin their environment?
- Treasury account keys (if the attacker potentially had access to the same environment).
- Pinata API keys (IPFS).
- Sentry DSN (if the compromise involved the deployment environment).
- Any CI/CD secrets that co-existed with the admin key.
- Identify all
create_tokenormint_tokenscalls that were charged inflated fees. - Identify any tokens whose metadata was manipulated via
set_metadatawhile attacker-controlled fees were in place. - Determine whether any funds in the treasury account were drained via
set_fee_split.
| Time | Action |
|---|---|
| T+0 | Incident declared, factory paused |
| T+1 h | Internal post-mortem started |
| T+24 h | Initial public notice posted (see below) |
| T+72 h | Full incident report published |
| T+30 d | Follow-up confirming remediation complete |
Note: StellarForge operates in the "emerging markets fintech-adjacent" space. Depending on the jurisdiction of affected users and the scale of financial impact, applicable data-breach or financial-services notification laws may set shorter or stricter timelines than those above. Consult Legal before publishing the T+24 h notice.
Subject: Security Incident Notice — StellarForge Token Factory
We are writing to inform you of a security incident affecting the StellarForge
token factory contract on Stellar mainnet.
What happened: [brief description — e.g. admin key was compromised; attacker
temporarily set an inflated creation fee]
When: [UTC time range]
Impact: [number of affected transactions / users; estimated fee overcharge;
any data that was exposed]
What we've done: The factory was paused at [time]. The admin key has been
rotated. [Other remediation steps.]
What you should do: [any user action required, e.g. contact support for fee
refund, do not use the old contract address]
More information: [link to full incident report]
We sincerely apologise for this incident and will publish a full post-mortem
within 72 hours.
- GitHub Security Advisory (private → public after 30 days or sooner)
- Project Discord / Telegram announcement channel
- Direct email to known token creators (if email addresses are available)
- Update the frontend banner to display a maintenance / incident notice
Run this exercise with the actual team at least once before mainnet launch and once per quarter thereafter. It should take approximately 90 minutes.
- Each team member has read the full runbook.
- Break-glass address is confirmed funded and accessible.
- WASM hash monitoring script is deployed and confirmed alerting.
- Sentry alert rules for anomalous fee events are active.
- Incident commander and break-glass custodian can reach each other out of band (phone, not just Slack).
- Walk through section 4 step-by-step on testnet: pause, transfer_admin to break-glass, verify.
- Walk through section 5.4 WASM integrity check on testnet.
- Confirm that the communication templates in section 9 are up to date.
- Document who participated and the date. File in the deployment log.
- Mainnet deployment checklist
- SECURITY.md — responsible disclosure policy
- docs/contract-abi.md — contract interface reference
- Issue #9 — upgrade event emission (closes the WASM-change detection gap)
- Issue #32 — forensic WASM comparison tooling
- Issue #36 — Sentry correlation for faster anomaly detection