交互脚本自动化:效率与安全的平衡点
自动化的收益是时间,成本是把私钥交给代码——而私钥泄露不是部分损失,是该钱包全部资产瞬间归零且不可追回。安全的平衡点只有一条线:撸毛钱包与资产钱包物理隔离、私钥永不硬编码、任何第三方脚本都视为不可信直到你读完它。
本文要点
- 撸毛钱包必须用独立助记词,与持有资产的钱包完全不共享派生路径。
- 私钥永不写进代码、配置文件或聊天记录;用环境变量或本地加密文件读取。
- 任何要求你导入私钥或助记词的第三方脚本,默认按恶意处理。
- 审计脚本先看三处:网络请求目标、私钥的使用位置、依赖清单里的可疑包。
- 行为随机化能减弱时序指纹,但脚本天然产生的路径一致性无法完全消除。
自动化真正的风险边界在哪
自动化本身不危险,危险的是自动化需要的权限。手动操作时私钥待在钱包插件里,每笔交易你都要看一眼再签名;脚本化之后,私钥必须以某种形式让程序能读到,且程序可以在无人监督的情况下发起任意交易。权限从「每次确认」变成了「一次性授予、长期有效」,这是风险性质的改变,不是程度的改变。
由此产生两条边界。第一条是资产边界:脚本能碰到的钱包里,最多只放你愿意在最坏情况下全部损失的金额。这条边界靠钱包隔离实现,与代码质量无关——即使脚本完美无误,你的机器也可能被入侵。
第二条是信任边界:哪些代码可以读到私钥。自己写的、逐行读过的脚本在边界内;从聊天群里下载的、带着几十个 npm 依赖的「一键工具」在边界外。多数事故不是发生在第一条边界,而是第二条——用户把持有主要资产的钱包私钥,导入了一个没读过的脚本。
钱包隔离:第一道也是最有效的防线
隔离的标准不是「用不同地址」,而是用不同助记词。同一助记词派生出的多个地址在密码学上共享一个种子:泄露助记词等于泄露全部派生地址,换地址完全没有防护作用。这是最常见的误解之一。
推荐的最小结构是三层。资产层:硬件钱包,独立助记词,只做转入转出,永不连接任何 DApp,永不接触任何脚本。操作层:撸毛专用,独立助记词,只放当期需要的 Gas 与门槛资金。中转层:一个用于分发与归集的临时地址,同样独立,用完即弃。
注意操作层的资金上限应当动态管理:空投代币到账时,操作层钱包的价值会突然跳升到远超你原本设定的可承受损失额度。因此「到账后尽快转到资产层」应当写进流程,而不是等想起来再做。这也是恶意脚本最常选择的下手时点。
| 层级 | 助记词 | 允许的操作 | 绝对禁止 | 常驻金额 |
|---|---|---|---|---|
| 资产层 | 独立,离线生成,硬件钱包保管 | 接收、转出 | 连接 DApp、导入任何软件、参与交互 | 主要资产 |
| 操作层 | 独立,与资产层无关 | DApp 交互、脚本自动化 | 存放超出可全损额度的资产 | 当期 Gas + 门槛资金 |
| 中转层 | 独立,可定期更换 | 分发、归集 | 长期存放、参与交互 | 接近零,用完清空 |
这个结构还有个副作用是降低关联风险:中转层的存在虽然方便,但它在链上会把所有接收方连成一个簇。若你在意关联判定,中转层应当避免用于给多个操作层地址分发资金——这与女巫检测怎么做的里讲的资金隔离原则是冲突的,需要在便利与关联风险之间自己取舍。
私钥怎么读:三种做法与一种绝对禁止
先说绝对禁止的:私钥以明文形式出现在代码或提交到版本库的文件里。这条被违反的频率高得惊人,原因是它在写第一版脚本时最省事,而事后往往忘了改。GitHub 上有自动化爬虫专门扫描提交历史里的私钥,从推送到被清空常常只需几分钟。
可接受的做法有三种,安全性递增。环境变量:私钥存在 shell 环境或 .env 文件里,代码从 os.environ 读取,.env 必须写进 .gitignore。它防住了代码泄露,但没防住磁盘被读取。
本地加密文件:私钥用口令加密后存盘,运行时输入口令解密到内存。这是脚本化场景下的合理默认,代价是无法完全无人值守(每次启动要输口令)。硬件签名:私钥永不离开硬件设备,脚本只构造交易、由设备签名,安全性最高但需要人工确认每笔交易,与全自动化目标冲突。
import os
import json
import getpass
from eth_account import Account
# ---------- 反面示例:绝对不要这样写 ----------
# PRIVATE_KEY = '0xabc123...' # 明文硬编码,一旦文件泄露即资产全损
def load_key_from_env(var='WALLET_PRIVATE_KEY'):
"""做法一:环境变量。.env 必须加入 .gitignore,不要提交。"""
key = os.environ.get(var)
if not key:
raise RuntimeError(f'环境变量 {var} 未设置')
os.environ.pop(var, None) # 读完立即从环境中移除,缩小暴露窗口
return Account.from_key(key)
def create_encrypted_keystore(private_key, path='wallet.keystore'):
"""做法二(一次性):把私钥加密成 keystore 文件,口令不入盘。"""
pwd = getpass.getpass('设置加密口令: ')
encrypted = Account.encrypt(private_key, pwd)
with open(path, 'w', encoding='utf-8') as f:
json.dump(encrypted, f)
os.chmod(path, 0o600) # 仅当前用户可读
print(f'已写入 {path},请立即删除任何明文私钥备份')
def load_key_from_keystore(path='wallet.keystore'):
"""做法二(每次运行):口令通过交互输入,不写进代码或环境。"""
with open(path, 'r', encoding='utf-8') as f:
encrypted = json.load(f)
pwd = getpass.getpass('解密口令: ')
return Account.from_key(Account.decrypt(encrypted, pwd))
def sign_and_send(w3, account, tx):
"""签名前打印关键字段,人工可核对。自动化不等于放弃可观测性。"""
tx['nonce'] = w3.eth.get_transaction_count(account.address)
print(f"to={tx['to']} value={tx.get('value', 0)} gas={tx.get('gas')}")
signed = account.sign_transaction(tx)
return w3.eth.send_raw_transaction(signed.raw_transaction)
# 额外原则:
# 1) 日志中永不打印私钥、助记词或解密后的对象;
# 2) 异常堆栈可能包含变量内容,捕获异常时不要把原始对象带进日志;
# 3) 脚本崩溃后不要把内存转储(core dump)发给任何人排查。
代码里三条注释比代码本身更容易被忽略。日志与异常堆栈是私钥泄露的常见途径:把报错信息贴到群里求助时,堆栈中可能正好包含了传入的密钥参数。同理,很多人会把整个脚本目录打包发给别人帮忙看,其中就带着 .env 或 keystore 文件。
python
怎么审计一个别人写的脚本
现实是大部分人不会自己写脚本。如果一定要用别人的,最低限度的审计有明确的检查顺序,二十分钟能完成,能筛掉绝大多数明显恶意的代码。
第一步看网络请求。全局搜索 http、fetch、requests、axios、socket,列出所有外部地址。合法脚本只应该访问链上 RPC 节点和项目方官方域名。出现任何陌生域名、IP 地址、或从远程拉取代码再执行(eval、exec、动态 import)的行为,直接放弃。
第二步看私钥的流向。搜索 privateKey、mnemonic、secret、seed 等关键词,确认它只被用于本地签名,从未出现在任何网络请求的参数里、从未被写入文件、从未被打印。这一步是核心。
第三步看依赖清单。检查 requirements.txt 或 package.json 里的每个包名,警惕与知名库仅差一两个字符的拼写变体(typosquatting),以及版本号写成开放范围的依赖——后者意味着作者可以在你不改代码的情况下推送新版本。所有依赖都应锁定到确定版本。
第四步看混淆与打包。如果代码经过压缩、编码或以二进制形式提供,无法逐行阅读,那它就应当被当作不可信。没有任何正当理由需要把一个交互脚本混淆起来。
| 检查项 | 怎么查 | 危险信号 | 处置 |
|---|---|---|---|
| 外部网络请求 | 搜索 http/fetch/requests/axios | 陌生域名、裸 IP、远程加载代码 | 放弃使用 |
| 私钥使用位置 | 搜索 privateKey/mnemonic/seed | 出现在请求体、文件写入或日志中 | 放弃使用 |
| 动态执行 | 搜索 eval/exec/Function/importlib | 任何形式的运行时代码加载 | 放弃使用 |
| 依赖清单 | 逐个核对包名与版本 | 拼写近似的仿冒包、开放版本范围 | 锁定版本或替换 |
| 代码可读性 | 直接打开阅读 | 混淆、压缩、二进制分发 | 放弃使用 |
| 授权额度 | 搜索 approve | 无限额度授权、授权给非官方合约 | 改为按次限额授权 |
| 运行环境 | 是否在隔离环境 | 直接在存有资产钱包的主机上运行 | 改用独立机器或虚拟机 |
| 首次运行验证 | 小额空跑一次 | 首次即要求大额或全额授权 | 先用可忽略金额测试 |
| 权限最小化 | 检查文件与网络权限 | 脚本请求读取整个用户目录 | 限制工作目录 |
| 日志内容 | 运行后检查日志文件 | 日志中出现密钥片段 | 清理并停止使用 |
行为随机化:能做什么,不能做什么
脚本的效率来自重复执行相同流程,而相同流程正是最容易被识别的特征。随机化试图缓解这个矛盾,但它的作用范围有限,值得说清边界。
能有效缓解的:执行时间(不要固定间隔和固定时刻)、交易金额(避免整数与固定尾数)、Gas 设置(用节点估算值而非固定自定义值)、操作顺序(同一批任务打乱顺序)。这几项都属于参数层面的指纹,随机化确实能打散。
无法解决的:交互路径的结构一致性。如果你的脚本对每个钱包都执行「桥入 → 兑换 → 存入 → 借出 → 归还」这一固定序列,那么无论金额和时间怎么随机,这条路径本身就是簇特征。真实用户的路径是发散且经常半途而废的,脚本的路径是完备且一致的。
还有一个反向风险:过度随机化本身可疑。真实用户的行为有习惯性聚集(多在固定几个时段活跃、常用默认 Gas),完美均匀随机反而偏离真实分布。目标应是「看起来像一个真实用户」,而这恰恰是脚本最难做到的部分。相关判定逻辑见女巫检测怎么做的。
一个务实的平衡点
把上面的内容压缩成可执行的默认配置:自动化只用于数据读取与流程编排,签名环节保留人工确认或限定在独立环境的专用钱包上。这样你拿到了自动化的大部分效率收益,同时把最坏情况的损失锁在操作层钱包的余额之内。
具体分工可以这样切。脚本负责查询状态、判断哪些任务待做、生成待签名的交易列表、记录执行日志——这些都不需要私钥。签名与发送则用操作层钱包在隔离环境执行,且该钱包余额始终低于你的可全损额度。
这个方案的代价是无法完全无人值守,也无法管理几十个钱包。但从撸毛路线图里的期望值测算看,钱包数量的边际收益本来就在快速递减,而私钥风险是唯一可能造成超出计划损失的项目——它的损失不受你事先设定的成本上限约束。对能造成计划外全损的风险,应该用结构而不是用小心来防。
- 为撸毛生成全新助记词,与任何持有资产的钱包无关。
- 私钥用 keystore 加密存盘,运行时输入口令;.env 与 keystore 均加入 .gitignore。
- 脚本先在测试网或以可忽略金额空跑一次,确认交易目标与金额符合预期。
- 每次到账后尽快把代币转入资产层,不让操作层余额长期超过可全损额度。
- 定期检查并撤销不再使用的合约授权,尤其是无限额度授权。
什么是常见问题?
用一个全新钱包运行来源不明的脚本,是不是就安全了?
钱包层面的损失被限制住了,但主机层面的风险还在。恶意脚本可以读取磁盘上的其他文件(包括其他钱包的 keystore、浏览器插件数据、剪贴板历史)、安装持久化后门,或劫持你之后复制的地址。真正的隔离需要独立的运行环境(虚拟机或专用机器),而不只是独立的钱包。
把私钥放在 .env 文件里够安全吗?
比硬编码好,但只防住了代码泄露这一种途径。.env 是明文文件,任何能读取你磁盘的程序都能拿到它,包括你运行的其他脚本、被入侵的依赖包和恶意浏览器扩展。加密 keystore 加交互输入口令的方案要好得多,因为明文私钥只在内存中短暂存在。如果必须用 .env,至少要设置文件权限为仅当前用户可读,并确认它在 .gitignore 里。
脚本已经用了几个月没出问题,说明它安全吗?
不能。恶意脚本的常见设计是延迟触发:先正常工作建立信任,等到监测到地址余额超过某个阈值或有空投代币到账时,才一次性转走全部资产。这种设计正是为了让「用了很久没事」成为受害者放松警惕的理由。判断安全性只能靠阅读代码和限制权限,运行时长不提供任何证据。
完全不用脚本,纯手动操作会不会更好?
在安全维度上确实更好:私钥始终待在钱包插件里,每笔交易都经人工确认,没有计划外全损的路径;在关联风险上也更好,因为手动操作天然产生发散的路径和不规则的时序。代价是时间成本大幅上升,钱包数量受限。是否值得取决于你的时间单价和参与的项目数量——如果按完整成本核算后只做少数几个高期望值项目,手动往往就够了。