เหตุใดรหัสเครื่องดั้งเดิมจึงไม่สามารถถอดรหัสได้อย่างง่ายดาย


16

ด้วยภาษาที่ใช้เครื่องเสมือนของ bytecode เช่น Java, VB.NET, C #, ActionScript 3.0 เป็นต้นบางครั้งคุณได้ยินเกี่ยวกับความง่ายในการดาวน์โหลดตัวแยกข้อมูลออกจากอินเทอร์เน็ตเรียกใช้ bytecode ในช่วงเวลาที่ดีและ บ่อยครั้งเกิดขึ้นกับสิ่งที่ไม่ไกลจากซอร์สโค้ดต้นฉบับในไม่กี่วินาที สมมุติว่าภาษาแบบนี้มีความเสี่ยงเป็นพิเศษ

ฉันเพิ่งเริ่มสงสัยว่าทำไมคุณไม่ได้ยินเพิ่มเติมเกี่ยวกับเรื่องนี้เกี่ยวกับรหัสไบนารี่พื้นเมืองเมื่ออย่างน้อยคุณก็รู้ว่าภาษานั้นถูกเขียนเป็นภาษาดั้งเดิม (และภาษาใดที่จะพยายามถอดรหัส) เป็นเวลานานฉันคิดว่ามันเป็นเพียงเพราะภาษาเครื่องพื้นเมืองบ้าคลั่งมากและซับซ้อนกว่า bytecode ทั่วไป

แต่โค้ดไบต์มีลักษณะอย่างไร ดูเหมือนว่านี้:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

และรหัสเครื่องดั้งเดิมมีลักษณะอย่างไร (เป็นเลขฐานสิบหก) แน่นอนมันมีลักษณะเช่นนี้:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

และคำแนะนำนั้นมาจากกรอบความคิดที่ค่อนข้างคล้ายกัน:

1000: mov EAX, 20
1001: mov EBX, loc1
1002: mul EAX, EBX
1003: push ECX

ดังนั้นเมื่อภาษาพยายามถอดรหัสไบนารีพื้นเมืองบางตัวให้พูด C ++ มันยากอะไรกับมันบ้าง ความคิดเพียงสองอย่างที่นึกขึ้นมาในทันทีคือ 1) จริงๆแล้วมันมีความซับซ้อนมากกว่า bytecode หรือ 2) บางอย่างเกี่ยวกับความจริงที่ว่าระบบปฏิบัติการมักจะแบ่งหน้าโปรแกรมและกระจายส่วนที่ทำให้เกิดปัญหามากเกินไป หากหนึ่งในความเป็นไปได้เหล่านั้นถูกต้องโปรดอธิบาย แต่อย่างใดทำไมคุณไม่เคยได้ยินเรื่องนี้มาก่อน

บันทึก

ฉันกำลังจะยอมรับคำตอบอย่างใดอย่างหนึ่ง แต่ฉันต้องการพูดถึงบางสิ่งบางอย่างก่อน เกือบทุกคนอ้างถึงความจริงที่ว่าซอร์สโค้ดต้นฉบับที่ต่างกันอาจแมปกับรหัสเครื่องเดียวกัน ชื่อตัวแปรโลคอลหายไปคุณไม่ทราบว่าใช้ลูปประเภทใดเป็นต้น

อย่างไรก็ตามตัวอย่างเช่นสองที่ถูกกล่าวถึงเป็นเพียงเล็กน้อยในสายตาของฉัน คำตอบบางส่วนมีแนวโน้มที่จะระบุว่าความแตกต่างระหว่างรหัสเครื่องและแหล่งที่มาดั้งเดิมนั้นมีความสำคัญมากกว่าสิ่งที่น่าสนใจเล็กน้อย

แต่ตัวอย่างเช่นเมื่อพูดถึงสิ่งต่าง ๆ เช่นชื่อตัวแปรโลคอลและประเภทลูป bytecode จะสูญเสียข้อมูลนี้เช่นกัน (อย่างน้อยสำหรับ ActionScript 3.0) ผมเคยดึงสิ่งที่กลับผ่าน Decompiler ก่อนและฉันไม่ได้สนใจจริงๆไม่ว่าจะเป็นตัวแปรที่ถูกเรียกหรือstrMyLocalString:String loc1ฉันยังสามารถดูในขอบเขตขนาดเล็กท้องถิ่นและดูว่ามันถูกใช้โดยไม่มีปัญหามาก และforลูปก็เหมือนกันมากกับwhileวนรอบถ้าคุณคิดเกี่ยวกับมัน นอกจากนี้แม้ว่าฉันจะเรียกใช้ซอร์สผ่าน irrFuscator (ซึ่งต่างจาก secureSWF ไม่ได้ทำอะไรมากไปกว่าแค่การสุ่มตัวแปรสมาชิกและชื่อฟังก์ชั่น) มันยังดูเหมือนว่าคุณสามารถแยกตัวแปรและฟังก์ชั่นบางอย่างในคลาสที่เล็กลง ดูวิธีการใช้งานกำหนดชื่อของคุณเองให้พวกเขาและทำงานจากที่นั่น

เพื่อให้เรื่องนี้เป็นเรื่องใหญ่รหัสเครื่องจะต้องสูญเสียข้อมูลมากไปกว่านั้นและคำตอบบางส่วนอาจเป็นไปได้


35
เป็นการยากที่จะสร้างวัวขึ้นมาจากแฮมเบอร์เกอร์
Kaz Dragon

4
ปัญหาหลักคือไบนารีแบบเนทีฟจะเก็บข้อมูลเมตาน้อยมากเกี่ยวกับโปรแกรม มันไม่เก็บข้อมูลเกี่ยวกับคลาส (ทำให้ C ++ ยากต่อการถอดรหัส) และไม่แม้แต่เกี่ยวกับฟังก์ชั่นเสมอไป - มันไม่จำเป็นเนื่องจาก CPU จะประมวลโค้ดในลักษณะเชิงเส้นที่ค่อนข้างเป็นธรรมชาติ นอกจากนี้มันเป็นไปไม่ได้ที่จะแยกความแตกต่างระหว่างรหัสและข้อมูล ( ลิงค์ ) สำหรับข้อมูลเพิ่มเติมคุณอาจต้องการที่จะต้องพิจารณาการค้นหาหรืออีกครั้งขอให้ที่RE.SE
ntoskrnl

คำตอบ:


39

ในการรวบรวมทุกขั้นตอนคุณจะสูญเสียข้อมูลที่ไม่สามารถกู้คืนได้ ยิ่งคุณสูญเสียข้อมูลจากแหล่งดั้งเดิมมากเท่าไหร่ก็ยิ่งยากต่อการถอดรหัส

คุณสามารถสร้าง de-คอมไพเลอร์ที่มีประโยชน์สำหรับรหัสไบต์เนื่องจากข้อมูลจำนวนมากถูกเก็บรักษาไว้จากแหล่งดั้งเดิมที่เก็บไว้เมื่อสร้างรหัสเครื่องสุดท้าย

ขั้นตอนแรกของการคอมไพเลอร์คือการเปลี่ยนแหล่งที่มาเป็นบางส่วนสำหรับการเป็นตัวแทนระดับกลางมักจะแสดงเป็นต้นไม้ ตามเนื้อผ้าต้นนี้ไม่มีข้อมูลที่ไม่ใช่ความหมายเช่นความคิดเห็นพื้นที่สีขาวเป็นต้นเมื่อสิ่งนี้ถูกโยนทิ้งไปคุณจะไม่สามารถกู้คืนต้นกำเนิดดั้งเดิมจากต้นไม้ต้นนั้นได้

ขั้นตอนต่อไปคือการทำให้แผนภูมิเป็นภาษากลางบางรูปแบบที่ทำให้การปรับให้เหมาะสมง่ายขึ้น มีตัวเลือกค่อนข้างน้อยที่นี่และแต่ละโครงสร้างพื้นฐานคอมไพเลอร์มีของตัวเอง อย่างไรก็ตามโดยทั่วไปข้อมูลเช่นชื่อตัวแปรโลคัลโครงสร้างโฟลว์การควบคุมขนาดใหญ่ (เช่นไม่ว่าคุณจะใช้สำหรับหรือขณะลูป) หายไป การเพิ่มประสิทธิภาพที่สำคัญบางอย่างมักเกิดขึ้นที่นี่การแพร่กระจายอย่างคงที่การเคลื่อนที่ของโค้ดไม่แปรผันฟังก์ชันอินไลน์และอื่น ๆ ซึ่งแต่ละอันจะแปลงการแทนค่าเป็นตัวแทนที่มีฟังก์ชันการทำงานที่เทียบเท่า แต่มีลักษณะที่แตกต่างกันมาก

ขั้นตอนหลังจากนั้นคือการสร้างคำสั่งเครื่องจริงซึ่งอาจเกี่ยวข้องกับสิ่งที่เรียกว่าการปรับให้เหมาะสม "peep-hole" ที่สร้างรูปแบบคำสั่งทั่วไปที่ปรับให้เหมาะสม

ในแต่ละขั้นตอนคุณจะสูญเสียข้อมูลมากขึ้นเรื่อย ๆ จนกระทั่งในที่สุดคุณจะสูญเสียมากจนเป็นไปไม่ได้ที่จะกู้คืนสิ่งใด ๆ ที่คล้ายกับรหัสต้นฉบับ

ในทางกลับกันโค้ดไบต์มักจะบันทึกการปรับให้เหมาะสมที่น่าสนใจและการเปลี่ยนแปลงจนกระทั่งขั้นตอน JIT (คอมไพเลอร์ทันเวลาพอดี) เมื่อสร้างรหัสเครื่องเป้าหมาย Byte-code มี meta-data จำนวนมากเช่นประเภทตัวแปรโลคัลโครงสร้างคลาสเพื่ออนุญาตให้คอมไพล์รหัสไบต์เดียวกันเพื่อรวบรวมรหัสเครื่องเป้าหมายหลายรหัส ข้อมูลทั้งหมดนี้ไม่จำเป็นในโปรแกรม C ++ และถูกทิ้งในกระบวนการรวบรวม

มี decompilers สำหรับรหัสเครื่องเป้าหมายต่าง ๆ แต่มักจะไม่ให้ผลลัพธ์ที่เป็นประโยชน์ (สิ่งที่คุณสามารถแก้ไขและคอมไพล์ใหม่) เนื่องจากแหล่งต้นฉบับมากเกินไปจะหายไป หากคุณมีข้อมูลการดีบั๊กสำหรับการปฏิบัติการคุณสามารถทำงานได้ดียิ่งขึ้น แต่ถ้าคุณมีข้อมูลการดีบักคุณอาจมีแหล่งต้นฉบับด้วย


5
ความจริงที่ว่าข้อมูลถูกเก็บไว้เพื่อให้ JIT สามารถทำงานได้ดีขึ้นเป็นกุญแจสำคัญ
btilly

C ++ DLLs นั้นสามารถถอดรหัสได้ง่ายหรือไม่?
Panzercrisis

1
ไม่เป็นอะไรเลยฉันจะถือว่ามีประโยชน์
chuckj

1
เมตาดาต้าไม่ได้ "เพื่ออนุญาตให้รวบรวมรหัสไบต์เดียวกันกับหลายเป้าหมาย" มันมีไว้เพื่อสะท้อน การแสดงสื่อกลางที่กำหนดเป้าหมายซ้ำได้ไม่จำเป็นต้องมีข้อมูลเมตาใด ๆ
SK-logic

2
นั่นไม่เป็นความจริง ข้อมูลส่วนใหญ่มีไว้เพื่อการสะท้อน แต่การสะท้อนกลับไม่ได้ใช้เพียงอย่างเดียว ตัวอย่างเช่นอินเทอร์เฟซและคำจำกัดความคลาสที่ใช้ในการสร้างกำหนดเขตข้อมูลชดเชยสร้างตารางเสมือน ฯลฯ บนเครื่องเป้าหมายช่วยให้พวกเขาสามารถสร้างในวิธีที่มีประสิทธิภาพมากที่สุดสำหรับเครื่องเป้าหมาย ตารางเหล่านี้ถูกสร้างโดยคอมไพเลอร์และ / หรือตัวเชื่อมโยงเมื่อสร้างโค้ดเนทีฟ เมื่อดำเนินการเสร็จแล้วข้อมูลที่ใช้ในการสร้างจะถูกยกเลิก
chuckj

11

การสูญเสียข้อมูลตามที่ระบุไว้โดยคำตอบอื่น ๆ คือจุดหนึ่ง แต่มันไม่ใช่ผู้แจกไพ่ ท้ายที่สุดคุณไม่คาดหวังว่าโปรแกรมดั้งเดิมจะกลับมาคุณเพียงต้องการการแสดงในภาษาระดับสูง หากมีการแทรกโค้ดไว้คุณสามารถปล่อยให้เป็นเช่นนั้นหรือแยกตัวประกอบการคำนวณทั่วไปออกมาโดยอัตโนมัติ ในหลักการคุณสามารถเลิกทำการเพิ่มประสิทธิภาพได้หลายอย่าง แต่มีการดำเนินการบางอย่างที่โดยหลักการแล้วไม่สามารถย้อนกลับได้ (โดยไม่มีการคำนวณอย่างน้อยจำนวนอนันต์)

ตัวอย่างเช่นกิ่งไม้อาจกลายเป็นกระโดดข้ามการคำนวณ รหัสเช่นนี้:

select (x) {
case 1:
    // foo
    break;
case 2:
    // bar
    break;
}

อาจรวบรวมไว้ (ขออภัยที่นี่ไม่ใช่แอสเซมเบลอร์จริง):

0x1000:   jump to 0x1000 + 4*x
0x1004:   // foo
0x1008:   // bar
0x1012:   // qux

ทีนี้ถ้าคุณรู้ว่า x สามารถเป็น 1 หรือ 2 คุณสามารถดูการกระโดดและย้อนกลับได้อย่างง่ายดาย แต่แล้วที่อยู่ 0x1012 ล่ะ คุณควรสร้างcase 3มันขึ้นมาด้วยหรือไม่ คุณต้องติดตามโปรแกรมทั้งหมดในกรณีที่เลวร้ายที่สุดเพื่อหาว่าอนุญาตให้ใช้ค่าใดได้บ้าง ยิ่งไปกว่านั้นคุณอาจต้องพิจารณาอินพุตของผู้ใช้ที่เป็นไปได้ทั้งหมด! แก่นแท้ของปัญหาคือคุณไม่สามารถแยกแยะข้อมูลและคำแนะนำออกจากกันได้

ที่ถูกกล่าวว่าฉันจะไม่พูดในแง่ร้ายอย่างสิ้นเชิง ดังที่คุณอาจสังเกตเห็นใน 'แอสเซมเบลอร์' ข้างต้นถ้า x มาจากภายนอกและไม่รับประกันว่าจะเป็น 1 หรือ 2 คุณจะมีข้อบกพร่องที่ไม่ดีซึ่งทำให้คุณสามารถกระโดดไปที่ใดก็ได้ แต่ถ้าโปรแกรมของคุณปราศจากข้อผิดพลาดประเภทนี้มันง่ายกว่าที่จะให้เหตุผล (ไม่มีอุบัติเหตุที่ภาษากลางที่ "ปลอดภัย" เช่น CLR IL หรือ Java bytecode นั้นง่ายต่อการถอดรหัสและตั้งเมตาดาต้าไว้ด้วยกัน) ดังนั้นในทางปฏิบัติมันควรจะถอดรหัสได้ดีและมีความประพฤติดีโปรแกรม ฉันกำลังคิดถึงกิจวัตรสไตล์การใช้งานเฉพาะตัวที่ไม่มีผลข้างเคียงและอินพุตที่กำหนดไว้อย่างชัดเจน ฉันคิดว่ามี decompilers สองสามตัวที่สามารถให้ pseudocode สำหรับฟังก์ชั่นที่เรียบง่าย แต่ฉันไม่ได้มีประสบการณ์กับเครื่องมือเหล่านี้มากนัก


9

สาเหตุที่รหัสเครื่องไม่สามารถแปลงกลับเป็นรหัสต้นฉบับดั้งเดิมได้ง่ายคือข้อมูลจำนวนมากสูญหายระหว่างการรวบรวม เมธอดและคลาสที่ไม่ถูกเอ็กซ์พอร์ตสามารถถูก inline ชื่อตัวแปรโลคัลหายไปชื่อไฟล์และโครงสร้างหายไปทั้งหมดคอมไพเลอร์สามารถทำการออปติไมซ์ที่ไม่ชัดเจนได้ อีกเหตุผลหนึ่งคือไฟล์ต้นฉบับที่แตกต่างกันหลายไฟล์สามารถสร้างแอสเซมบลีที่เหมือนกันได้แน่นอน

ตัวอย่างเช่น:

int DoSomething()
{
    return Add(5, 2);
}

int Add(int x, int y)
{
    return x + y;
}

int main()
{
    return DoSomething();
}

อาจจะรวบรวมไปที่:

main:
mov eax, 7;
ret;

แอสเซมบลีของฉันค่อนข้างสนิม แต่ถ้าคอมไพเลอร์สามารถตรวจสอบว่าการปรับให้เหมาะสมสามารถทำได้อย่างถูกต้องมันจะทำเช่นนั้น นี่เป็นเพราะไบนารีที่คอมไพล์ไม่จำเป็นต้องรู้ชื่อDoSomethingและAddเช่นเดียวกับความจริงที่ว่าAddเมธอดมีสองพารามิเตอร์ที่กำหนดชื่อคอมไพเลอร์ก็รู้ว่าDoSomethingวิธีการคืนค่าคงที่และมันสามารถอินไลน์ทั้งการเรียกเมธอดและ วิธีการของตัวเอง

วัตถุประสงค์ของคอมไพเลอร์คือการสร้างแอสเซมบลีไม่ใช่วิธีการรวมไฟล์ซอร์ส


ลองพิจารณาเปลี่ยนคำสั่งสุดท้ายเป็นเพียงretแค่บอกว่าคุณคิดว่าการโทรแบบ C
chuckj

3

หลักการทั่วไปในที่นี้คือการทำแผนที่หลายต่อหนึ่งและขาดตัวแทนที่ยอมรับ

สำหรับตัวอย่างง่ายๆของปรากฏการณ์แบบตัวต่อตัวคุณสามารถคิดได้ว่าจะเกิดอะไรขึ้นเมื่อคุณใช้ฟังก์ชั่นที่มีตัวแปรโลคอลและรวบรวมไปยังรหัสเครื่อง ข้อมูลทั้งหมดเกี่ยวกับตัวแปรหายไปเพราะมันกลายเป็นที่อยู่หน่วยความจำ สิ่งที่คล้ายกันเกิดขึ้นสำหรับลูป คุณสามารถใช้ a forหรือwhileloop และถ้ามันมีโครงสร้างที่ถูกต้องคุณอาจได้รับรหัสเครื่องที่เหมือนกันพร้อมjumpคำแนะนำ

สิ่งนี้ยังทำให้ขาดตัวแทนที่เป็นที่ยอมรับจากซอร์สโค้ดต้นฉบับสำหรับคำแนะนำของรหัสเครื่อง เมื่อคุณพยายามที่จะแปลความหมายลูปคุณจะแมปjumpคำสั่งกลับไปยังโครงสร้างลูปได้อย่างไร? คุณทำให้พวกเขาเป็นforห่วงหรือwhileลูป

ปัญหานี้ทำให้โกรธมากขึ้นโดยความจริงที่ว่าคอมไพเลอร์ที่ทันสมัยดำเนินการในรูปแบบต่างๆของการพับและอินไลน์ ดังนั้นเมื่อคุณได้รับรหัสเครื่องมันเป็นไปไม่ได้เลยที่จะบอกว่าระดับสูงสร้างรหัสเครื่องระดับต่ำมาจากไหน

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.