技术|“狩零人”威胁攻击分析报告!

原创
2238 天前
2005

事件

近期,降维安全实验室(johnwick.io)接到白细胞安全社区(whitecell.io)反馈的一起丢币事件.我们立即与丢币用户取得了联系,沟通丢币的过程,又分析了他丢币时所用的手机系统,可是并没有发现用户有泄露私钥的可能.但是用户购买的价值几十万的数字货币又实实在在地瞬间被黑客转移,且已被兑换成ETH,BTC等主流币.这背后究竟隐藏了何种玄机?下面就带大家一起走进科学.

丢币事件相关交易记录:

https://etherscan.io/address/0xa9cbada29093adaf1ba685ac4c6b0486a05876c7#tokentxns


分析

以太坊的私钥

在分析之前,先给大家简要说明下以太坊的私钥是什么.

它是一个64个字符的16进制数(32字节),用户需妥善保管,一旦丢失,也就意味着失去了对以太坊账户的控制权. 如果我们已经创建过一个以太坊账户,并导出了其私钥,那么即使卸载了钱包应用,只需要在别处再次导入这个私钥,就可以恢复我们的账户.

自作聪明的钱包

但是如果用户导入的私钥不足/超过32字节,钱包应用应当如何处理呢? 有些钱包应用(如imToken)会直接拒绝这种畸形数据,并提示用户输入了无效私钥.还有些钱包(如下面案例)会在后台自作主张地帮用户填0/截断成32字节,并成功导入修改后的私钥,强行达成共识.

在此次事件涉及到的钱包应用就如此处理用户输入数据的.经过技术分析,我们定位到问题出在钱包使用的公开库 keythereum上. 如果代码中检测到用户输入的私钥长度小于32字节,那么就使用 Buffer.concat连接合并 Buffer.alloc创建的补齐用全0数据和用户输入的私钥,构成32字节的"私钥".

相关代码片段及注释如下:

  1. privateKeyToAddress: function (privateKey) {
  2. var privateKeyBuffer, publicKey;
  3. privateKeyBuffer = this.str2buf(privateKey);
  4. if (privateKeyBuffer.length < 32) { // <-- "私钥"长度小于32字节
  5. privateKeyBuffer = Buffer.concat([ // 拼接成32字节
  6. Buffer.alloc(32 - privateKeyBuffer.length, 0), // 填0 buffer
  7. privateKeyBuffer // 用户输入"私钥"
  8. ]);
  9. }
  10. publicKey = secp256k1.publicKeyCreate(privateKeyBuffer, false).slice(1);
  11. return "0x" + keccak256(publicKey).slice(-20).toString("hex");
  12. },
  13. Buffer.concat = function concat (list, length) {
  14. if (!isArray(list)) {
  15. throw new TypeError('"list" argument must be an Array of Buffers')
  16. }
  17. if (list.length === 0) {
  18. return Buffer.alloc(0)
  19. }
  20. var i
  21. if (length === undefined) {
  22. length = 0
  23. for (i = 0; i < list.length; ++i) {
  24. length += list[i].length
  25. }
  26. }
  27. var buffer = Buffer.allocUnsafe(length)
  28. var pos = 0
  29. for (i = 0; i < list.length; ++i) {
  30. var buf = list[i]
  31. if (!Buffer.isBuffer(buf)) {
  32. throw new TypeError('"list" argument must be an Array of Buffers')
  33. }
  34. buf.copy(buffer, pos)
  35. pos += buf.length
  36. }
  37. return buffer
  38. }
  39. Buffer.allocUnsafe = function (size) {
  40. return allocUnsafe(null, size)
  41. }
  42. function allocUnsafe (that, size) {
  43. assertSize(size)
  44. that = createBuffer(that, size < 0 ? 0 : checked(size) | 0)
  45. if (!Buffer.TYPED_ARRAY_SUPPORT) {
  46. for (var i = 0; i < size; ++i) {
  47. that[i] = 0
  48. }
  49. }
  50. return that
  51. }

大意的用户

回到开头的丢币事件,既然用户没有泄露私钥,那他是如何被黑客转移走所有数字货币的呢? 我们在审查用户整个购买数字货币的流程中,发现了一条奇怪的交易, 在(TxHash 0xebef7c15fb184be685208a32565848c70dc53495091919b0cc6134db090c3bea)用户购买数字货币的账号前置补0后居然是用户自己账号的私钥!!!

具体计算过程如下:


顺着这个线索,我们反复沟通,我们终于还原了客户被攻击的场景:

  1. 客户在复制自己先前保存的私钥过程中,粗心大意,误将交易所/项目方账户地址当作私钥复制到该款钱包的"导入私钥"输入框
  2. 该钱包应用没有对用户输入的原始数据做校验,直接补0,生成了一个账户地址.
  3. 客户用这个生成的账户,先后购买了1,371,196.8173516和214,400.50169127数量的 DistributedCreditChain(DCC)代币.
  4. 监控以太坊主链并发现了此问题的黑客随后将用户的代币悉数转走洗白.

后续

我们想这样的错误绝对不是孤案,于是我们写了个脚本爬取了以太坊从创世区块开始截至目前的所有地址,约5500万.然后以这些以太坊地址(20字节)为蓝本,前置补0填充构成私钥(32字节),并以此导出公钥,哈希出地址. 再拿这些地址在前述的5500万地址中进行碰撞查询,初步结果让人惊讶,至少有125个地址可以由这种模式得出.我们粗略检查了其中的地址,发现一些账号中的数字货币疑似已被黑客洗走.我们将这些守株待兔收割的黑客称作"狩零人",并将此种攻击命名为"狩零人"攻击.BTW,此类攻击已加入我们的智子威胁感知系统,使用本系统的合作伙伴可以第一时间获知预警信息.

部分碰撞出的地址如下: