背景
CertiK先前的文章《鏈上打新局中局,大規模RugPull手法揭秘》中,揭示了一個針對打新機器人的大規模退出騙局自動化收割機地址0xdf1a,該地址在短短兩個月左右就完成了超過200次退出騙局(以下統稱RugPull),但這個團體並非只有一種RugPull手法。
先前的文章中以MUMI代幣為例描述了該地址背後團伙的RugPull手法:透過代碼後門直接修改稅收地址的代幣餘額,卻沒有修改代幣的總供應量,也沒有發送Transfer事件,從而使查看了etherscan的用戶也無法發現專案方偷偷鑄造代幣的行為。
今天的文章則是以代幣「ZhongHua」為例解析該團伙的另一種RugPull手法:用複雜的稅收功能邏輯,掩蓋可用於RugPull的轉帳功能。接下來,我們透過「ZhongHua」代幣案例,分析地址0xdf1a的另一種RugPull手法細節。
深入騙局
在這個案例中,專案方共計用9,990億個ZhongHua兌換出了約5.884個WETH,耗乾了池子的流動性。為了深入了解整個RugPull騙局,我們從頭梳理一下事件脈絡。
部署代幣
1月18日凌晨1點40分(UTC時間,下文同),攻擊者地址(😈0x74fc) 部署了名為ZhongHua的ERC20代幣(🪙0x71d7),並預挖了10億個代幣發送給攻擊者地址(😈0x74fcfc)。
預挖代幣數量與合約原始碼內定義的數量一致。
添加流動性
1點50分(代幣創建10分鐘後),攻擊者地址(😈0x74fc)向Uniswap V2 Router授予ZhongHua代幣的approve權限,以準備添加流動性。
1分鐘後,攻擊者地址(😈0x74fc)調用Router中addLiquidityETH函數添加流動以創建ZhongHua-WETH流動性池(🦄0x5c8b),將預挖的所有代幣和1.5個ETH添加到流動性池中,最後獲得約1.225個LP代幣。
從上述代幣轉帳記錄中我們可以看到,有一筆轉帳是攻擊者(😈0x74fc)發送了0個代幣給ZhongHua代幣合約自身。
這筆轉帳不屬於添加流動性的常規轉賬,透過查看代幣合約源碼發現,其中實現了一個_getAmount函數,該函數負責從轉賬的from地址扣款併計算所要收取的手續費,然後將手續費發送至代幣地址,再觸發表示代幣地址收到手續費的Transfer事件。
_getAmount函數中會判斷轉帳的sender是不是_owner,若是_owner則將手續費置為0。 _owner在Ownable合約部署時由建構子constructor的輸入參數賦予。
而ZhongHua代幣合約繼承了Ownable合約,並在部署時將部署者msg.sender作為Ownable建構子的輸入參數。
因此攻擊者地址(😈0x74fc)就是代幣合約的_owner。而加入流動性的那筆0代幣轉帳正是透過_getAmount函數發出,因為_getAmount會在transfer和transferFrom函數內被呼叫。
永久鎖定流動性
1點51分(流動性池創建的1分鐘內),攻擊者地址(😈0x74fc)將透過添加流動性獲取的全部1.225個LP代幣直接發送至0xdead地址,以完成對LP代幣的永久鎖定。
同MUMI代幣案例一樣,當LP被鎖定後,理論上攻擊者地址(😈0x74fc)便不再具備透過移除流動性進行RugPull的能力。而在由地址0xdf1a主導的針對打新機器人的RugPull騙局中,這一步驟主要是用來騙過打新機器人的反詐騙腳本。
至此,在使用者看來所有的預挖Token都用於添加到流動性池中,並未有異常情況出現。
RugPull
凌晨2點10分(ZhongHua代幣創建約30分鐘後),攻擊者地址2(👹0x5100)部署了專門用於RugPull的攻擊合約(🔪0xc403)。
同MUMI代幣的案例一樣,專案方沒有用部署ZhongHua代幣合約的那個攻擊地址,且用於RugPull的攻擊合約不開源,目的都是為了提高技術人員溯源的難度,大部分RugPull騙局都有這樣的特點。
上午7點46分(代幣合約創建約6小時後),攻擊者地址2(👹0x5100)進行了RugPull。
他透過呼叫攻擊合約(🔪0xc403)的「swapExactETHForTokens」方法,從攻擊合約中轉出9,990億個ZhongHua代幣兌換出了約5.884個ETH,並耗盡了池子中大部分流動性。
由於攻擊合約(🔪0xc403)不開源,我們對其字節碼進行了反編譯,結果如下:
https://app.dedaub.com/ethereum/address/0xc40343c5d0e9744a7dfd8eb7cd311e9cec49bd2e/decompiled
攻擊合約(🔪0xc403)的「swapExactETHForTokens」函數主要功能就是先用approve為UniswapV2 Router授予最大數量的ZhongHua代幣轉帳權限,再透過Router將呼叫者指定數量為「xt」的ZhongHua代幣(攻擊合約( 🔪0xc403)擁有的)兌換成ETH,並發送給攻擊合約(🔪0xc403)中聲明的“_rescue”地址。
可以看到「_rescue」對應的位址正是攻擊合約(🔪0xc403)的部署者:攻擊者位址2(👹0x5100)。
此筆RugPull交易的輸入參數xt為999,000,000,000,000,000,000,對應9,990億個ZhongHua代幣(ZhongHua的decimal為9)。
最終專案方用9,990億個ZhongHua將流動性池中的WETH耗幹,完成RugPull。
和先前文章的MUMI案例一樣,我們需要先確認攻擊合約(🔪0xc403)中ZhongHua代幣的來源。從前文我們得知ZhongHua代幣的總供應量為10億,而在RugPull結束後,我們在區塊瀏覽器中查詢到的ZhongHua代幣總供應量依舊是10億,但是攻擊合約(🔪0xc403)出售的代幣數量卻是9,990億,是合約記錄的總供應量的999倍,這些遠超總供應量的代幣又是從何而來?
我們查看了合約的ERC20轉帳事件歷史,發現和MUMI代幣的RugPull案例一樣,ZhongHua代幣案例中攻擊合約(🔪0xc403)同樣沒有ERC20代幣的轉入事件。
在MUMI的案例中,稅收合約的代幣來自於代幣合約中直接對balance的修改,使得稅收合約直接擁有遠超總供應量的代幣。由於MUMI代幣合約在修改balance時不對應修改代幣的totalSupply,也不觸發Transfer事件,因此我們無法看到MUMI案例中稅收合約的代幣轉入記錄,彷彿稅收合約用來RugPull的代幣像是憑空出現的一樣。
回到ZhongHua這個案例,攻擊合約(🔪0xc403)中的ZhongHua代幣也像是憑空出現的一樣,因此我們也去ZhongHua代幣合約中搜尋「balance」這個關鍵字。
結果顯示整個代幣合約僅有三處對balance變數的修改,分別在「_getAmount」、「_transferFrom」和「_transferBasic」函數中。
其中「_getAmount」用來處理收取轉帳手續費的邏輯,「_transferFrom」和「_transferBasic」則是在處理轉帳邏輯,並沒有出現如下圖MUMI代幣一般明顯地直接修改balance的語句。
更關鍵的是,MUMI代幣合約直接修改稅收合約的balance時沒有觸發Tranfer事件,這也是我們無法在區塊瀏覽器中查詢到稅收合約的代幣轉入事件,但稅收合約卻能擁有大量代幣的原因。
然而在ZhongHua代幣合約中,無論是「_getAmount」、「_transferFrom」或「_transferBasic」函數,它們在對balance進行修改後,都有正確觸發Transfer事件,這與我們前面查詢與攻擊合約(🔪0xc403)相關的Transfer事件時無法發現代幣轉入的Tranfer事件的情況是衝突的。
難道與MUMI的案例不同,這次攻擊合約(🔪0xc403)中的代幣真是憑空出現的?
手法揭秘
攻擊合約的代幣從何而來
在分析案例的過程中,當我們發現ZhongHua合約中每一次修改balance都正確觸發了Transfer事件,卻又始終找不到與攻擊合約(🔪0xc403)相關的代幣轉入記錄或Transfer事件時,就需要找到新的分析思路。
我們查詢了大量的轉帳記錄,也一度把合約中的「performZhongSwap」函數當作突破口,該函數負責將代幣合約中的代幣出售,在我們分析的其他的RugPull事件中,存在不少以這類別函數作為RugPull後門的案例。
儘管檢查了其他函數,結果還是一無所獲。於是我們開始將視野放到了「transfer」函數本身,無論攻擊者以什麼方式進行RugPull,「transfer」函數的實作邏輯一定都包含著最重要的資訊。
致命的Transfer
代幣合約中「transfer」函數直接呼叫了「_transferFrom」函數。
看上去「transfer」函數進行代幣轉帳操作,轉帳完成後會觸發Transfer事件。
但在進行代幣轉帳前,「transfer」函數會先用「_isNotTax」函數判斷轉帳的sender是否為免稅地址:若不是則用「_getAmount」函數收稅;若是則不收稅,將代幣直接發送到recipient。而問題也正是出在這裡。
前文也提到,在「_getAmount」的實作中,代幣合約校驗了sender的餘額,並對sender進行了扣款,然後將手續費發送至代幣合約。
而問題在於,「_getAmount」僅在sender不是免稅地址的時候被呼叫。當sender是免稅位址時,則直接為recipient的餘額加上amount。
此時問題變得十分明確:當免稅地址作為sender轉帳時,代幣合約並沒有去校驗sender的餘額是否充足,甚至都沒有從sender的balance中減去amount的操作。這也意味著只要是代幣合約定義的免稅地址,就可以向任意地址發送任意數量的代幣。這就是攻擊合約(🔪0xc403)能直接轉出999倍於總供應量的代幣的原因。
經檢查後發現,代幣合約僅在建構函式中將_taxReceipt設定為免稅地址,而_taxReceipt對應的地址正是攻擊合約(🔪0xc403)。
自此確定了ZhongHua代幣的RugPull的手法:攻擊者利用特定的邏輯規避了對特權地址的餘額校驗,使得特權地址能憑空轉出代幣,進而完成RugPull。
如何獲利
利用上述的漏洞,攻擊者位址2(👹0x5100)直接呼叫擁有特權的攻擊合約(🔪0xc403)的「swapExactETHForTokens」完成RugPull。在「swapExactETHForTokens」函數中,攻擊合約(🔪0xc403)為Uniswap V2 Router授予了代幣轉帳權限,然後直接呼叫Router的代幣兌換函數,用9,990億個ZhongHua代幣兌換出了池子中的5.88個ETH。
實際上,除了上述這筆進行RugPull的交易之外,專案方還透過攻擊合約(🔪0xc403)在中途出售過11次代幣,累計獲得9.64ETH;加上最後一筆RugPull交易,共計獲得15.52ETH。而成本不過是用於添加流動性的1.5個ETH、用於部署合約的少量手續費以及用於誘導打新機器人而進行主動兌換所花費的少量ETH。
甚至專案方中途還用不同的EOA地址去調用該攻擊合約(🔪0xc403)進行代幣出售,看上去是不同的sender在出售代幣,以偽裝其不斷套現的真實意圖。
總結
現在回過頭來思考整個ZhongHua代幣的RugPull案例,發現其手法本身很簡單,不過是取消特權地址的代幣餘額校驗而已。但是為什麼在分析這個案例的時候卻沒有那麼順利?主要原因可能有2點:
1.安全防護和攻擊的視野不同。對於安全從業者來說,代碼中的餘額校驗是最基礎需要完成的安全保障,因此大多數安全從業者都會潛意識地認為“transfer”函數理所當然地應當地會完成對用戶餘額的校驗,放鬆對這類漏洞的警覺(或認為這類漏洞太基礎,攻擊者不會採用)。
然而站在攻擊者視角,最有效的攻擊方式往往是最樸素的:不校驗餘額作為一種既有效又容易被忽略的RugPull手法,沒有不使用的理由。事實也確實如此,至少從案例表徵來看,ZhongHua代幣案例的RugPull手法留下的痕跡是最少的,追蹤起來難度遠大於其他類型的RugPull,最後仍需要透過人工審計代碼定位代碼後門。
2.專案方在有意識地掩蓋特權位址不需要校驗餘額的後門代碼。專案方甚至單獨為非特權地址實現了一套完整的稅收轉帳計算邏輯、代幣地址提現再複投的邏輯,使得代幣實現了複雜的轉賬邏輯看上去也合情合理。而其他普通地址進行轉帳時也與正常行為無異,在不仔細看代碼的前提下完全無法發現任何端倪。
對比這個團隊針對MUMI代幣和ZhongHua代幣的RugPull手法案例,二者都是透過相對隱蔽的方式使得特權地址擁有支配大量代幣的權利。
在MUMI代幣RugPull案例中,專案方直接修改balance,且不修改totalSupply,也不觸發Transfer事件,使得用戶無法感知特權地址已經擁有巨額代幣。
而ZhongHua代幣案例則是更徹底,透過直接不校驗特權地址的餘額,使得除看源碼以外的任何手段都無法發現特權地址已經擁有了無上限的代幣(用balanceOf查詢特權地址的餘額會顯示是0,但卻可以轉出無限的代幣)。
ZhongHua代幣的RugPull案例反映出代幣標準的潛在安全問題,ERC20代幣標準在安全性方面只能用來約束君子而無法防範小人。攻擊者往往在實現符合標準的業務邏輯的前提下,藏下令人難以發覺的後門。如果透過將代幣行為標準化,雖然減少了功能的靈活性,但避免了隱藏後門的可能性,提供了更多的安全保障。