Phishing через eth_sign
Почему eth_sign опасен для пользователей, как фишинговый сайт маскирует подпись и как заменить её на EIP-712 typed data.
Почему eth_sign опасен
eth_sign просит кошелёк подписать произвольные байты. Пользователь часто не видит понятный текст и не понимает, что именно подтверждает.
Фишинговый сайт может показать в интерфейсе "Подпишите вход", а в кошелёк отправить данные, которые другой контракт или backend примет как разрешение на действие.
Показываем плохой и безопасный запрос
Создайте файл scripts/phishing-eth-sign.ts:
import { ethers } from "ethers";
const wallet = ethers.Wallet.createRandom();
const dangerousPayload = ethers.solidityPackedKeccak256(
["address", "address", "uint256"],
[
wallet.address,
"0x000000000000000000000000000000000000dEaD",
ethers.parseEther("10"),
],
);
const ethSignSignature = await wallet.signingKey.sign(dangerousPayload).serialized;
console.log("Адрес пользователя:", wallet.address);
console.log("Сырые данные для подписи:", dangerousPayload);
console.log("Подпись как в eth_sign:", ethSignSignature);
const recoveredFromRawHash = ethers.recoverAddress(dangerousPayload, ethSignSignature);
console.log("Восстановленный адрес из raw hash:", recoveredFromRawHash);
const domain = {
name: "Kitaev Docs Demo",
version: "1",
chainId: 1,
verifyingContract: "0x0000000000000000000000000000000000000001",
};
const types = {
Login: [
{ name: "purpose", type: "string" },
{ name: "wallet", type: "address" },
{ name: "nonce", type: "uint256" },
],
};
const value = {
purpose: "Вход на сайт kitaev.tech",
wallet: wallet.address,
nonce: 1n,
};
const typedDataSignature = await wallet.signTypedData(domain, types, value);
const recoveredFromTypedData = ethers.verifyTypedData(
domain,
types,
value,
typedDataSignature,
);
console.log("Подпись EIP-712:", typedDataSignature);
console.log("Восстановленный адрес из EIP-712:", recoveredFromTypedData);Запуск:
npx tsx scripts/phishing-eth-sign.tsВ первом случае пользователь подписывает непонятный hash. Во втором кошелёк может показать домен, тип сообщения и поля: purpose, wallet, nonce.
Как выглядит проверка на backend
Создайте файл scripts/verify-login.ts:
import { ethers } from "ethers";
const wallet = ethers.Wallet.createRandom();
const nonce = 42n;
const domain = {
name: "Kitaev Docs Demo",
version: "1",
chainId: 1,
verifyingContract: "0x0000000000000000000000000000000000000001",
};
const types = {
Login: [
{ name: "purpose", type: "string" },
{ name: "wallet", type: "address" },
{ name: "nonce", type: "uint256" },
],
};
const message = {
purpose: "Вход на сайт kitaev.tech",
wallet: wallet.address,
nonce,
};
const signature = await wallet.signTypedData(domain, types, message);
const recoveredAddress = ethers.verifyTypedData(domain, types, message, signature);
if (recoveredAddress.toLowerCase() !== wallet.address.toLowerCase()) {
throw new Error("Подпись не принадлежит этому адресу");
}
console.log("Подпись валидна для:", recoveredAddress);Здесь backend проверяет не просто подпись, а конкретные структурированные данные. Если злоумышленник поменяет purpose, wallet, nonce, chainId или verifyingContract, восстановленный адрес уже не будет подходить к ожидаемому сообщению.
Как это работает
eth_sign — низкоуровневый метод подписи raw bytes. В современных кошельках он часто отключён или помечен как опасный.
wallet.signingKey.sign() в примере — имитация raw-подписи. Она показывает суть eth_sign: подпись привязана к hash, но не объясняет пользователю смысл данных.
signTypedData() — подпись EIP-712. Она добавляет домен приложения и структуру сообщения, поэтому кошелёк может показать читаемые поля.
verifyTypedData() — восстанавливает адрес подписанта из typed data и подписи. Backend сравнивает его с адресом пользователя и проверяет nonce.
Что использовать вместо eth_sign
Для входа на сайт используйте EIP-4361 Sign-In with Ethereum или EIP-712 typed data с одноразовым nonce.
Для подтверждения действия показывайте человеку конкретные поля: контракт, сеть, сумму, срок действия и получателя.
Для транзакций не просите подпись сообщения вместо обычной транзакции. Если действие меняет state, пользователь должен видеть transaction request в кошельке.
Частые ошибки
Просить eth_sign для логина → используйте SIWE или EIP-712 с nonce, доменом и сроком действия.
Подписывать hash без контекста → пользователь не может проверить, что именно он подтверждает.
Не проверять nonce на backend → старую подпись можно переиспользовать для повторного входа.
Что дальше
- Подпись сообщений EIP-191 — как работает человекочитаемая подпись обычного сообщения
- EIP-712 typed data — как подписывать структурированные данные для кошельков и backend
- MetaMask Browser Provider — как безопасно запрашивать аккаунт и подпись в браузере