Merge pull request #6055 from mempool/mononaut/poison-fixes

address poisoning detection fixes
This commit is contained in:
wiz 2025-10-09 08:15:51 +07:00 committed by GitHub
commit 3f4900a6fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 78 additions and 12 deletions

View file

@ -390,8 +390,31 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy {
// Check for address poisoning similarity matches
this.similarityMatches.set(tx.txid, new Map());
const comparableVouts = tx.vout.slice(0, 20).filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v.scriptpubkey_type));
const comparableVins = tx.vin.slice(0, 20).map(v => v.prevout).filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v?.scriptpubkey_type));
const comparableVouts = tx.vout.slice(0, 20).filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v.scriptpubkey_type) && !this.isFakeScripthash(v));
const comparableVins = tx.vin.slice(0, 20).map(v => v.prevout).filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v?.scriptpubkey_type) && !this.isFakeScripthash(v));
// Count unique addresses per type & position
const typeCount = new Map<string, { voutAddrs: Set<string>, vinAddrs: Set<string> }>();
for (const vout of comparableVouts) {
const count = typeCount.get(vout.scriptpubkey_type) || { voutAddrs: new Set(), vinAddrs: new Set() };
count.voutAddrs.add(vout.scriptpubkey_address);
typeCount.set(vout.scriptpubkey_type, count);
}
for (const vin of comparableVins) {
const count = typeCount.get(vin.scriptpubkey_type!) || { voutAddrs: new Set(), vinAddrs: new Set() };
count.vinAddrs.add(vin.scriptpubkey_address);
typeCount.set(vin.scriptpubkey_type!, count);
}
// We compare each vout to every distinct vin and every other vout address of the same type
let totalUniquePairs = 0;
for (const { voutAddrs, vinAddrs } of typeCount.values()) {
const V = voutAddrs.size;
const I = vinAddrs.size;
totalUniquePairs += (V * (V - 1)) / 2 + V * I;
}
// Adjust threshold to correct for the birthday paradox
const adjustedThreshold = totalUniquePairs > 0 ? ADDRESS_SIMILARITY_THRESHOLD * totalUniquePairs : ADDRESS_SIMILARITY_THRESHOLD;
for (const vout of comparableVouts) {
const address = vout.scriptpubkey_address;
const addressType = vout.scriptpubkey_type;
@ -403,9 +426,39 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy {
...comparableVins.filter(v => v.scriptpubkey_type === addressType && v.scriptpubkey_address !== address)
]) {
const similarity = checkedCompareAddressStrings(address, compareAddr.scriptpubkey_address, addressType as AddressType, this.stateService.network);
if (similarity?.status === 'comparable' && similarity.score > ADDRESS_SIMILARITY_THRESHOLD) {
let group = similarityGroups.get(address) || lastGroup++;
if (similarity?.status === 'comparable' && similarity.score > adjustedThreshold) {
// Get or create group numbers for both addresses
let group1 = similarityGroups.get(address);
let group2 = similarityGroups.get(compareAddr.scriptpubkey_address);
let group: number;
if (group1 !== undefined && group2 !== undefined) {
// Both have groups - merge by using the lower group number
group = Math.min(group1, group2);
// Update all addresses with the higher group number to use the lower one
if (group1 !== group2) {
const higherGroup = Math.max(group1, group2);
for (const [addr, g] of similarityGroups.entries()) {
if (g === higherGroup) {
similarityGroups.set(addr, group);
}
}
}
} else if (group1 !== undefined) {
// Only first address has a group
group = group1;
} else if (group2 !== undefined) {
// Only second address has a group
group = group2;
} else {
// Neither has a group - create a new one
group = lastGroup++;
}
// Assign the group to both addresses
similarityGroups.set(address, group);
similarityGroups.set(compareAddr.scriptpubkey_address, group);
const bestVout = this.similarityMatches.get(tx.txid)?.get(address);
if (!bestVout || bestVout.score < similarity.score) {
this.similarityMatches.get(tx.txid)?.set(address, { score: similarity.score, match: similarity.left, group });
@ -413,8 +466,6 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy {
// opportunistically update the entry for the compared address
const bestCompare = this.similarityMatches.get(tx.txid)?.get(compareAddr.scriptpubkey_address);
if (!bestCompare || bestCompare.score < similarity.score) {
group = similarityGroups.get(compareAddr.scriptpubkey_address) || lastGroup++;
similarityGroups.set(compareAddr.scriptpubkey_address, group);
this.similarityMatches.get(tx.txid)?.set(compareAddr.scriptpubkey_address, { score: similarity.score, match: similarity.right, group });
}
}
@ -423,6 +474,12 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy {
}
}
// assume any address with 12 or more contiguous repeated substrings is fake
fakeScriptHashRegex = new RegExp(/(.+?)\1{11,}/);
isFakeScripthash(vout: Vout): boolean {
return this.fakeScriptHashRegex.test(vout.scriptpubkey_address);
}
onScroll(): void {
this.loadMore.emit();
}

View file

@ -264,7 +264,7 @@ export type AddressSimilarityResult =
| { status: 'incomparable' }
| AddressSimilarity;
export const ADDRESS_SIMILARITY_THRESHOLD = 10_000_000; // 1 false positive per ~10 million comparisons
export const ADDRESS_SIMILARITY_THRESHOLD = 1_000_000; // 1 false positive per ~1 million comparisons
function fuzzyPrefixMatch(a: string, b: string, rtl: boolean = false): { score: number, matchA: string, matchB: string } {
let score = 0;
@ -280,12 +280,18 @@ function fuzzyPrefixMatch(a: string, b: string, rtl: boolean = false): { score:
b = b.split('').reverse().join('');
}
let discounted = false;
while (ai < a.length && bi < b.length && !done) {
if (a[ai] === b[bi]) {
// matching characters
prefixA += a[ai];
prefixB += b[bi];
score++;
if (discounted) {
score += 0.5;
} else {
score ++;
}
discounted = false;
ai++;
bi++;
} else if (!gap) {
@ -312,6 +318,7 @@ function fuzzyPrefixMatch(a: string, b: string, rtl: boolean = false): { score:
bi++;
}
gap = true;
discounted = true;
} else {
done = true;
}
@ -340,7 +347,7 @@ export function compareAddressInfo(a: AddressTypeInfo, b: AddressTypeInfo): Addr
const left = fuzzyPrefixMatch(a.address, b.address);
const right = fuzzyPrefixMatch(a.address, b.address, true);
// depending on address type, some number of matching prefix characters are guaranteed
const prefixScore = isBase58 ? 1 : ADDRESS_PREFIXES[a.network || 'mainnet'].bech32.length;
const prefixScore = isBase58 ? 1 : (ADDRESS_PREFIXES[a.network || 'mainnet'].bech32.length + 1);
// add the two scores together
const totalScore = left.score + right.score - prefixScore;

View file

@ -2,9 +2,9 @@
@if (similarity) {
<div class="address-text">
<a class="address" style="display: contents;" [routerLink]="['/address/' | relativeUrl, address]" title="{{ address }}">
<span class="prefix">{{ similarity.match.prefix }}</span>
<span class="infix" [ngStyle]="{'text-decoration-color': groupColors[similarity.group % (groupColors.length)]}">{{ address.slice(similarity.match.prefix.length || 0, -similarity.match.postfix.length || undefined) }}</span>
<span class="postfix"> {{ similarity.match.postfix }}</span>
<span class="prefix">{{ similarity.match.prefix.slice(0, 16) }}</span>
<span class="infix" [ngStyle]="{'text-decoration-color': groupColors[similarity.group % (groupColors.length)]}">{{ address.slice(min(similarity.match.prefix.length, 16) || 0, -min(similarity.match.postfix.length, 16) || undefined) }}</span>
<span class="postfix"> {{ similarity.match.postfix.slice(-16) }}</span>
</a>
<span class="poison-alert" *ngIf="similarity" i18n-ngbTooltip="address-poisoning.warning-tooltip" ngbTooltip="This address is deceptively similar to another output. It may be part of an address poisoning attack.">
<fa-icon [icon]="['fas', 'exclamation-triangle']" [fixedWidth]="true"></fa-icon>

View file

@ -11,6 +11,8 @@ export class AddressTextComponent {
@Input() info: AddressTypeInfo | null;
@Input() similarity: { score: number, match: AddressMatch, group: number } | null;
min = Math.min;
groupColors: string[] = [
'var(--primary)',
'var(--success)',