Immunefi Audit Competitions
ENS | Audit Competition

90794 w a critical explorer confirms an uppercase ensv2 transfer but signs the distinct lowercase token id causing direct nft theft

Submitted on Aug 27th 2026 at 10:14:57 UTC by @sl1de for Audit Competition | ENS

  • Report ID: #90794
  • Report Type: Websites & Apps
  • Report severity: Critical
  • Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/portal
  • Impacts:
    • Direct theft of user NFTs
    • Malicious interactions with an already-connected wallet, such as: Modifying transaction arguments or parameters Substituting contract addresses Submitting malicious transactions Signing or submitting a transaction against the wrong chain (for example when the request's target chain is not validated) Inducing a blanket token approval (for example setApprovalForAll on the registry or NameWrapper) to an attacker-controlled address

Description

Brief/Intro

Portal can display and confirm the transfer of an attacker-created uppercase ENS gift while its destructive transfer pipeline silently derives and signs the token ID of the victim's distinct lowercase ENS name. An attacker can register an uppercase raw V2 label directly to a victim who already owns the corresponding lowercase name. When the victim uses Portal to transfer the uppercase gift to the attacker, the transaction modal continues to identify the uppercase name, but the connected wallet signs safeTransferFrom for the lowercase name's ERC-1155 token ID. The verified result is that the attacker receives the victim's separate lowercase ENS NFT while the uppercase gift remains owned by the victim.

Vulnerability Details

Attack scenario

There are two actors and two distinct ENS V2 tokens:

  • The victim owns the legitimate lowercase name, for example caseasset-1908262601.eth.
  • The attacker registers the raw uppercase label CASEASSET-1908262601 directly to the victim as an unsolicited gift.
  • The attacker is the intended recipient when the victim later transfers that gift.

The private-fork proof confirms that the uppercase and lowercase labels remain distinct registrations with different on-chain token IDs:

RegistrationOwner before transferToken ID
caseasset-1908262601.ethVictim113537142548103639294285796724011834889495088856512828544734495664743420788736
CASEASSET-1908262601.ethVictim30959008988886769882135030643907345685439962380014932284671577023150141997056

The victim already owns the lowercase ENS NFT

The attacker pays to register the raw uppercase variant directly to the victim

Authentic Panoptes indexing returns both registrations as separate victim-owned rows and preserves the uppercase name

The victim clicks the uppercase row in Portal

The overview child displays Invalid name, but the enclosing profile layout and raw-name sidebar remain active

The victim enters the attacker-controlled recipient and starts the transfer

Portal's transaction modal says Transfer name - CASEASSET-1908262601.eth

The victim opens the connected wallet and approves the transaction described by Portal

Portal signs and broadcasts safeTransferFrom(victim, attacker, LOWERCASE_TOKEN_ID, 1, 0x)

The attacker becomes owner of caseasset-1908262601.eth while CASEASSET-1908262601.eth remains owned by the victim

The security invariant should be:

selected registration == displayed registration == permission-checked registration == signed token ID

The vulnerable flow instead produces:

selected, displayed, and owner-checked registration = uppercase gift
role and token lookup                              = lowercase target name
signed ERC-1155 token ID                           = lowercase target name

Root cause 1: Portal imports and routes the raw indexed name

The owned-name query returns Panoptes' raw domain.name without requiring it to equal its normalized form:

domains(where: { owner: $account }) {
  name
  expiryDate
  subdomainCount
  recordCount
}

for (const domain of domains) {
  result.push({
    name: domain.name,
    // ...
  })
}

The names table renders that value as a name entity. EntityBadge uses the same raw value as the route parameter:

<Link to="/$name" params={{ name }} className={primaryWrapperClass}>
  {pillNode}
</Link>

Authentic local Panoptes returned the uppercase registration exactly as:

{
  "id": "CASEASSET-1908262601.eth",
  "name": "CASEASSET-1908262601.eth",
  "labelName": "CASEASSET-1908262601",
  "owner": {
    "id": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8"
  },
  "parent": null
}

Root cause 2: the normalization guard protects only the overview child

The overview route correctly checks whether the name is normalized and renders Invalid name for the uppercase form:

const isValidName = isValidEnsName(name)

if (!isValidName) {
  return <InvalidNameMessage title="Invalid name" />
}

However, the parent /$name layout still resolves the raw route value and always renders ProfileSidebar around the child outlet:

<SidebarProvider>
  <ProfileSidebar name={name} />
  <SidebarInset>
    <Outlet />
  </SidebarInset>
</SidebarProvider>

ProfileSidebar constructs the Ownership link from that same raw name. The nested ownership and transfer routes do not repeat the canonical-name guard. The victim can therefore follow production UI controls from the rejected overview into the destructive transfer path.

The browser proof explicitly asserts all three facts:

overviewGuardRejected=true
productionSidebarNavigation=true
routeName=CASEASSET-1908262601.eth

Root cause 3: the owner check stays uppercase while the role check switches to lowercase

The transfer route accepts any raw string with two dot-separated labels:

export const is2LD = (name: string): boolean =>
  name.split('.').length === 2

It then asks the shared ENS owner resolver for the raw uppercase route name. That resolver passes the same raw string to ENSjs V2 owner and registry lookup. ENSjs serializes that name directly as DNS bytes, so the owner gate correctly verifies the victim's ownership of the uppercase gift. ENSjs and viem behave correctly for the raw input Portal supplies here. The application flaw is that Portal later changes the identity used for the role and token lookups without binding it back to this owner-checked registration.

The split begins only after that owner authorization. The transfer-role check explicitly calls getLabel(name):

const label = useMemo(() => {
  try {
    return getLabel(name)
  } catch {
    return null
  }
}, [name])

getLabel first applies ENS normalization:

export function getLabel(name: string): string {
  const normalized = ens_normalize(name)
  const labels = ens_split(normalized)
  const first = labels[0]
  // ...
  return String.fromCodePoint(...first.input)
}

For the proof values:

getLabel("CASEASSET-1908262601.eth") == "caseasset-1908262601"

Portal therefore checks ROLE_CAN_TRANSFER_ADMIN for the lowercase token rather than for the uppercase registration the victim selected. The registry-role helper and ENSjs correctly hash the caller-supplied label. Portal has already substituted that label through getLabel(name) before calling them.

Root cause 4: the final token lookup uses the normalized label while preview metadata keeps the raw name

The transfer preparation obtains its authoritative token ID by normalizing the route name through getLabel:

mutationFn: ({ recipient, options }) =>
  getEnsTokenId({
    label: getLabel(name),
    registryAddress,
  }).map((tokenId) => ({ recipient, tokenId, options }))

That result is stored and later supplied to the transfer step:

transferToken({
  ...common,
  registryAddress,
  tokenId,
  recipient,
  id,
})

ENSjs correctly hashes the label supplied by Portal and asks the selected registry for that label's token ID. The substituted identity is introduced by Portal's preceding getLabel(name) call, not by ENSjs.

At the same time, the transaction presented in the modal continues to use the unmodified raw route value:

return steps.map((step, i) => ({
  id: `transfer-${name}-${step}`,
  title: STEP_LABELS[step],
  transactionName: `${STEP_LABELS[step]} - ${name}`,
  // ...
}))

The production transaction-state component renders this raw-name field as the visible transaction heading:

<h3 className="text-base font-medium whitespace-normal leading-snug break-all min-w-0">
  {transaction.transactionName}
</h3>

The final helper also uses the raw name only for transaction-manager metadata:

{
  id,
  description: `Transfer ${name}`,
  publicClient,
  chainId,
}

The authorization-bearing calldata instead uses the already substituted token ID:

const data = encodeFunctionData({
  abi: erc1155Abi,
  functionName: 'safeTransferFrom',
  args: [walletWithAccount.account.address, recipient, tokenId, 1n, '0x'],
})

There is no comparison between the selected raw registration's identity and the normalized token ID before the wallet is opened.

Full call path

Attacker-created uppercase V2 registration
        |
        v
Panoptes domains.name preserves uppercase bytes
        |
        v
getV2NamesWithRolesForAddress returns raw name
        |
        v
Names table and EntityBadge route to raw /$name
        |
        v
Profile layout preserves sidebar after overview says Invalid name
        |
        v
Ownership and Transfer routes accept raw two-label name
        |
        +--> owner check authorizes the raw uppercase gift
        |
        +--> role check and getLabel(raw uppercase) use the lowercase target
        |
        v
getEnsTokenId(lowercase label) returns victim's target token ID
        |
        +--> modal metadata still says uppercase gift
        |
        v
transferToken encodes safeTransferFrom with lowercase token ID
        |
        v
Victim approves connected-wallet transaction
        |
        v
Lowercase NFT moves to attacker; uppercase gift does not move

Verified browser-to-chain result

I reproduced the complete path in a private local Sepolia fork using the frozen Portal source, the real V2 registrar and registry state, authentic local Panoptes indexing, the production names list, profile layout, sidebar navigation, ownership and transfer routes, transaction modal, transaction manager, a connected headless wallet, and the final ERC-1155 state transition.

The latest isolated evidence run produced:

ItemVerified value
Competition commitcda79acaad59711b943fc68207ebb3f1d0ff8596
Imported upstream commit63772fd872af472ced58b009499355f3430c2a86
Nodev22.23.2
pnpm10.27.0
Raw gift shown by PortalCASEASSET-1908262601.eth
Target victim namecaseasset-1908262601.eth
Uppercase gift token ID30959008988886769882135030643907345685439962380014932284671577023150141997056
Lowercase target token ID113537142548103639294285796724011834889495088856512828544734495664743420788736
Transaction modalTransfer name - CASEASSET-1908262601.eth
Signed functionsafeTransferFrom
Signed token IDLowercase target token ID
Local transaction0xa95352c5ba00e640cbf5461e7839ff2b6e776fb0a8c8cffbd1191f88d11055f5
Lowercase owner beforeVictim
Lowercase owner afterAttacker
Uppercase gift owner beforeVictim
Uppercase gift owner afterVictim
Playwright result1 passed
Joined resultPASS

The decoded browser-signed arguments were:

safeTransferFrom(
  from  = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8,
  to    = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC,
  id    = 113537142548103639294285796724011834889495088856512828544734495664743420788736,
  value = 1,
  data  = 0x
)

That token ID is the lowercase name's ID and is not the uppercase gift's ID.

Preconditions and boundaries

The following requirements are explicit:

  • The victim owns a lowercase V2 .eth name.
  • The attacker registers the corresponding raw uppercase label directly to the victim.
  • The victim chooses to transfer the unsolicited uppercase gift to an attacker-controlled address.
  • The victim opens the connected wallet and approves the transaction after Portal identifies it as the uppercase name.
  • Both names must still exist and be victim-owned when the flow begins. The raw owner gate therefore authorizes the uppercase gift, while the victim also has authority over the distinct lowercase token selected later.

The following are not required:

  • No stolen private key, session key, HCA authority, or browser storage access.
  • No malicious RPC, indexer, backend, wallet extension, or third-party provider.
  • No modification of transaction bytes after wallet approval.
  • No privileged contract role held by the attacker on the lowercase name.
  • No public-network transaction in the proof.

The recipient is not substituted. The victim enters the attacker-controlled address. The exploit substitutes the name and ERC-1155 token ID being transferred.

The overview page's Invalid name message is a real partial control and is disclosed. It does not block the parent profile sidebar, the Ownership page, the Transfer page, the enabled Transfer action, the transaction modal, or the wallet submission. The final Portal modal still names the uppercase gift.

Difference from known issue EXP-INPUT-005

This report does not submit the known Medium display-confusion issue.

EXP-INPUT-005 describes validators accepting homoglyphs, bidi and control characters, and leading or trailing dots. This proof uses ASCII case-folding on an otherwise ordinary ASCII label. More importantly, it establishes a new Critical consequence:

  • The uppercase and lowercase strings are separate, simultaneously active on-chain registrations.
  • Portal imports and displays the uppercase registration as a victim-owned asset.
  • Portal derives a different lowercase token ID only inside the destructive authorization path.
  • The connected wallet signs that different token ID.
  • The victim's lowercase NFT is transferred to the attacker while the selected uppercase NFT remains with the victim.

The competition instructions explicitly distinguish display confusion from a security consequence that changes the identity or transaction being authorized. This is a signed-token and direct-NFT-theft consequence, not a visual spoof alone.

Controls that work but do not stop the exploit

  • The overview route rejects the uppercase name as invalid. Destructive nested routes remain reachable through the still-mounted sidebar.
  • Malformed names that make getLabel throw fail closed. ASCII uppercase is valid input to normalization and silently becomes lowercase.
  • The transfer route correctly checks that the connected address owns the raw uppercase gift. That owner identity is never bound to the different lowercase token ID derived later.
  • The transfer-role guard checks ROLE_CAN_TRANSFER_ADMIN. It checks that role for the substituted lowercase label and therefore authorizes the wrong token.
  • Recipient validation rejects self-transfer and the zero address. The attacker supplies an ordinary valid recipient.
  • The chain, registry contract, from, recipient, amount, and calldata are internally consistent. The wrong field is the authorization-bearing token ID.
  • The chain enforces the signed calldata exactly. This proves the victim approved the substituted lowercase token ID rather than protecting the victim from the misleading application description.

Require canonical names on actionable routes

Require every actionable /$name/* route to reject a name unless its supplied bytes equal the one canonical form accepted by the application. Do this at the parent route before rendering the sidebar or any nested destructive route.

Preserve the selected asset identity

Do not normalize an indexed or selected asset into another asset during authorization. Carry an immutable domain identifier and the exact token ID from selection through owner checks, preview, signing, and post-state verification.

Validate the token before opening the wallet

Before opening the wallet, verify that the token ID to be signed is the token ID of the exact registration the user selected. Abort if normalization, case-folding, or any parser transformation changes that identity.

Show exact security-critical transaction details

Include the exact name and token ID in the security-critical transaction review. Do not build the visible transaction name from one string while encoding the token from another.

Add regression tests

Add browser-to-chain regression tests for uppercase/lowercase pairs, NFD/NFC pairs, variation selectors, and every other accepted input whose normalized representation differs from its indexed representation.

Impact Details

This maps directly to two listed Critical Websites & Applications impacts.

First, it causes direct theft of a user NFT. The verified execution transferred the victim's lowercase ENS V2 ERC-1155 NFT to the attacker. The victim-authorized uppercase gift did not move and remained owned by the victim.

Second, it causes a malicious interaction with an already-connected wallet by modifying a transaction argument. Portal tells the victim that the transaction is Transfer name - CASEASSET-1908262601.eth, but supplies the lowercase name's distinct token ID as the third argument to safeTransferFrom. The wallet signs and the chain executes that substituted argument.

From the attacker's perspective, the attacker creates a raw uppercase gift for the victim and receives the victim's separate lowercase name if the victim later chooses to transfer that gift to the attacker.

From the victim's perspective, the victim selects the uppercase row, follows Portal's normal ownership flow, enters the intended recipient, and approves a modal that still names the uppercase asset. The on-chain result transfers a different ENS NFT.

The loss is the victim's ownership of the lowercase ENS name and the value and control associated with that name. The proof does not rely on estimating a market price. It demonstrates the program's listed direct-NFT-theft state transition exactly.

References

Panoptes domain names are returned without a canonical-form invariant

The names table renders the raw name as an EntityBadge

EntityBadge routes the raw name into the profile route

The parent name route resolves the raw name and always mounts ProfileSidebar

The overview child displays Invalid name

ProfileSidebar keeps the raw name in its Ownership link

The Ownership route exposes Transfer without enforcing canonical name equality

The transfer route enforces wallet, ENSv2, owner, label-count, and role gates but omits canonical-name equality

Portal passes the supplied route name into its shared owner resolver

The shared owner resolver passes the raw name to ENSjs V2 owner and registry lookups

The shared owner resolver calls that V2 path with the supplied name

ENSjs sends the supplied name directly to Universal Resolver findOwner as DNS bytes

ENSjs sends the same supplied name directly to Universal Resolver findRegistries as DNS bytes

viem packetToBytes preserves label case and only strips a leading or trailing dot before byte encoding

getLabel normalizes before extracting the label

The transfer-role check uses the normalized label

Portal's registry-role hook forwards the label selected by the transfer-role check

ENSjs registry-role lookup hashes the caller-supplied label and passes that resource to hasRoles

The transfer flow derives the token ID from getLabel but keeps the raw name in modal metadata

getEnsTokenId delegates the normalized label to ENSjs

ENSjs token lookup hashes the caller-supplied label and asks the selected registry for its current token ID

The production transaction-state component displays transactionName

The final helper encodes safeTransferFrom with the substituted token ID while describing the raw name

Proof of Concept

Safety and environment

This proof runs entirely against an isolated local Anvil fork using throwaway development accounts. It does not send transactions to public Sepolia or any other public network. It does not use production funds, names, wallets, sessions, API credentials, or a public Panoptes deployment.

The supplied ASCII_CASE_SEPOLIA_FORK_URL is used only by Anvil to read the initial Sepolia state. Every registration, wallet approval, transfer, and ownership assertion executes locally inside the disposable fork.

The runner uses pinned Anvil and Panoptes container image digests. It:

  • Requires a researcher-controlled Sepolia RPC URL and does not contain a fallback provider.
  • Requires the exact frozen competition commit.
  • Refuses to reuse its Compose project or any occupied local port.
  • Uses dedicated loopback ports for Anvil, Panoptes, and Portal.
  • Uses the standard throwaway Anvil mnemonic only inside the private fork.
  • Starts a local Portal Vite instance from the frozen source.
  • Uses authentic local Panoptes indexing rather than a mocked name response.
  • Stops and removes only the containers, network, and volume it created, including after failure.

PoC evidence bundle

The complete runnable PoC, clean verification log, and screenshots are available here:

Download ens-portal-uppercase-token-substitution-poc.zip

SHA-256:

13787b8d6d8a3addea126aa1b7540c9e040ea8e0368943e02f054c7b872ce852  ens-portal-uppercase-token-substitution-poc.zip

The archive contains:

poc/registration-jawdrop/run-ascii-case-transfer-clean.sh
poc/registration-jawdrop/ascii-case-transfer.ts
poc/registration-jawdrop/ascii-case-transfer.compose.yml
poc/registration-jawdrop/ascii-case-panoptes.json
poc/registration-jawdrop/vite.ascii-case.config.ts
e2e/poc/portal-ascii-case-transfer.playwright.config.ts
e2e/projects/portal/tests/portal-ascii-case-transfer.poc.spec.ts
scans/registration-jawdrop-ascii-case-clean.log
scans/registration-jawdrop-ascii-case-dashboard.png
scans/registration-jawdrop-ascii-case-modal.png
scans/registration-jawdrop-ascii-case-panoptes.json
POC-README.md
SHA256SUMS.txt

Prerequisites

  • Linux with Bash.
  • Node.js v22.
  • Corepack and pnpm v10.
  • Docker with Docker Compose v2.
  • Chromium installed at /usr/bin/chromium.
  • Git, curl, jq, unzip, and ss.
  • A researcher-controlled Sepolia RPC URL with ordinary read and fork support.
  • Local ports 19545, 19655, and 19301 available. Alternate ports can be supplied through the documented environment variables.
  • Network access for the initial frozen dependency installation, pinned container image download, and read-only fork initialization.

Obtain the frozen competition repository

Create a clean working directory and clone the in-scope repository:

mkdir ens-portal-uppercase-reproduction
cd ens-portal-uppercase-reproduction

git clone --branch audit-comp-ready \
  https://github.com/immunefi-team/audit-comp-ens.git \
  audit-comp-ens

cd audit-comp-ens
git checkout cda79acaad59711b943fc68207ebb3f1d0ff8596

Verify the competition commit:

test "$(git rev-parse HEAD)" = \
  "cda79acaad59711b943fc68207ebb3f1d0ff8596"

git show -s --format='%H%n%B' HEAD

The commit message identifies the imported upstream commit as:

63772fd872af472ced58b009499355f3430c2a86

Install frozen dependencies

node --version
corepack pnpm --version
corepack pnpm install --frozen-lockfile

Expected major versions:

Node.js: v22.x
pnpm:    10.x

Download, verify, and overlay the PoC

Download the Google Drive archive and save it as:

ens-portal-uppercase-token-substitution-poc.zip

Place it beside the cloned audit-comp-ens directory, then verify and extract it:

cd ..

printf '%s  %s\n' \
  '13787b8d6d8a3addea126aa1b7540c9e040ea8e0368943e02f054c7b872ce852' \
  'ens-portal-uppercase-token-substitution-poc.zip' | sha256sum -c -

unzip ens-portal-uppercase-token-substitution-poc.zip -d audit-comp-ens
chmod +x audit-comp-ens/poc/registration-jawdrop/run-ascii-case-transfer-clean.sh

cd audit-comp-ens
bash -n poc/registration-jawdrop/run-ascii-case-transfer-clean.sh

The checksum command must print:

ens-portal-uppercase-token-substitution-poc.zip: OK

The decisive included evidence files have these checksums:

006160ec3bcd9fcde97af20928549179fd62406d28b710f590b9a0ea371bdba2  scans/registration-jawdrop-ascii-case-clean.log
2d8b05fd685756c5214ad1722a4d2a37ab88b16f9a2f87601674220e63bd5673  scans/registration-jawdrop-ascii-case-dashboard.png
0a340a5b6d0372f0111c4cbf720e6ee526d9c40162fab289d14fb55cdd449b6  scans/registration-jawdrop-ascii-case-modal.png
b71e9a27428f340d77e365167b3a17359a563e3bbf2353ac86b03d8ba81bdc25  scans/registration-jawdrop-ascii-case-panoptes.json

Supply the private-fork endpoint

Supply a researcher-controlled endpoint without putting it in source files or the shell command history:

read -rsp 'Researcher-controlled Sepolia RPC URL: ' ASCII_CASE_SEPOLIA_FORK_URL
export ASCII_CASE_SEPOLIA_FORK_URL
printf '\n'

Running without this variable fails before creating or changing any local service:

ASCII_CASE_SEPOLIA_FORK_URL must be set to a researcher-controlled Sepolia RPC URL.

Execute the isolated browser-to-chain proof

Optionally choose a unique lowercase ASCII label containing at least one letter:

export CASE_LABEL="caseasset-$(date +%s)"

Run the joined proof from the repository root:

poc/registration-jawdrop/run-ascii-case-transfer-clean.sh \
  2>&1 | tee scans/triager-uppercase-transfer-run.log

The runner starts Anvil, local Panoptes indexing and API services, and Portal. It then executes one Playwright test with one worker.

Observe two distinct registrations

The setup stage registers the lowercase name to the victim. The attacker then pays to register the uppercase raw label directly to the same victim.

The output must contain SETUP_PASS and show:

{
  "lowerName": "caseasset-1908262601.eth",
  "upperName": "CASEASSET-1908262601.eth",
  "victim": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
  "attacker": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"
}

The actual numeric suffix follows CASE_LABEL and may differ. The two printed token IDs must be unequal. The runner aborts if either registration is missing, either owner is wrong, or the token IDs collapse.

Observe authentic Panoptes preserving the uppercase gift

The runner waits for local Panoptes to index the fork transactions and then queries its GraphQL API. It must print:

authenticPanoptesUppercaseGift=PASS

The browser test independently reads that row and requires all of the following:

name      = CASEASSET-<suffix>.eth
labelName = CASEASSET-<suffix>
owner     = victim
parent    = null

No mocked indexer response is permitted. The test exits if E2E_MOCK_INDEXER=true.

Observe the production Portal path

The Playwright test connects the victim's throwaway headless wallet and opens the production address names page.

It asserts that:

  1. Authentic Panoptes supplies the uppercase gift row, while setup has already proved that its on-chain identity differs from the lowercase registration.
  2. The victim clicks the uppercase row. The included dashboard screenshot also shows both registrations.
  3. The raw uppercase value remains in the route.
  4. The overview page displays the honest partial warning Invalid name.
  5. The enclosing production sidebar still exposes Ownership.
  6. The Ownership page exposes Transfer.
  7. The victim enters the attacker address and starts the transfer.
  8. The transaction modal displays Transfer name - CASEASSET-<suffix>.eth.

The runner writes the visual evidence to:

scans/registration-jawdrop-ascii-case-dashboard.png
scans/registration-jawdrop-ascii-case-modal.png

The dashboard screenshot shows both distinct registrations. The modal screenshot shows the uppercase gift as the named transfer immediately before the wallet is opened.

Observe the substituted signed token ID

The test authorizes the transaction through the connected local wallet, locates the exact mined browser transaction, and decodes its calldata.

For the included evidence run, Portal displayed:

Transfer name - CASEASSET-1908262601.eth

The browser transaction decoded as:

safeTransferFrom(
  from  = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8,
  to    = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC,
  id    = 113537142548103639294285796724011834889495088856512828544734495664743420788736,
  value = 1,
  data  = 0x
)

The signed ID is the lowercase target token ID. It is not the uppercase gift token ID:

uppercase gift token ID:
30959008988886769882135030643907345685439962380014932284671577023150141997056

lowercase target token ID:
113537142548103639294285796724011834889495088856512828544734495664743420788736

The included local browser transaction is:

0xa95352c5ba00e640cbf5461e7839ff2b6e776fb0a8c8cffbd1191f88d11055f5

This is a private-fork transaction hash and may differ on rerun.

Observe the NFT ownership result

The browser test reads both token states before and after the transaction. It requires this exact outcome:

lowercase owner before = victim
lowercase owner after  = attacker

uppercase owner before = victim
uppercase owner after  = victim

This proves the selected and displayed gift did not transfer. The victim's different lowercase NFT transferred to the attacker.

Observe successful completion and cleanup

The final output must contain:

1 passed
browserResult=PASS
joinedResult=PASS

The exit trap then removes the dedicated Panoptes API, Panoptes indexer, Anvil container, volume, and network.

Confirm that no PoC services remain:

ASCII_CASE_SEPOLIA_FORK_URL="$ASCII_CASE_SEPOLIA_FORK_URL" \
docker compose \
  --project-name asciicasetransferpoc \
  -f poc/registration-jawdrop/ascii-case-transfer.compose.yml \
  ps --status running --services

It must print no service names.

Negative controls

  • The uppercase and lowercase strings are unequal and their on-chain token IDs are unequal.
  • Both registrations are owned by the victim before transfer.
  • Authentic Panoptes returns the uppercase gift rather than a mocked or altered row.
  • The overview child rejects the uppercase input, proving the report does not hide that warning.
  • Production sidebar navigation still reaches Ownership and Transfer despite that warning.
  • The modal continues to identify the uppercase gift before wallet approval.
  • The exact browser transaction decodes to the lowercase token ID.
  • The lowercase NFT changes owner and the uppercase gift does not.
  • The recipient address, chain, registry, amount, and calldata decode are asserted. The divergence is isolated to the NFT token ID.
  • The proof uses no public-chain writes, compromised RPC, malicious indexer, stolen key, session token, HCA authority, or privileged role on the victim's lowercase name.

Proof boundaries

The victim must intentionally approve a transfer to the attacker-controlled recipient. The security issue is that Portal names one NFT in its selection and confirmation but signs a different NFT's token ID.

The overview page visibly says Invalid name. The proof does not claim otherwise. The exploitable failure is that the parent layout keeps the raw-name sidebar active, the nested destructive routes omit the same guard, and the final transaction modal affirmatively identifies the uppercase gift.

The proof does not claim a smart-contract vulnerability. Contract behavior is used only to establish the two distinct registrations and verify the application-created state transition.