
مستندات وأدوات معكوسة الهندسة لتشفير البرامج الثابتة لـ 8BitDo
توثيق وأدوات هندسة عكسية للتشفير المستخدم في البرامج الثابتة (firmware) من قِبل 8BitDo لعدة منتجات مبنية على GD32.
راجع 8cryptdo.py للحصول على أداة فك التشفير وإعادة التشفير التي تطبّق الخوارزمية
الموصوفة في هذا المستند.
قد تتيح الأداة إمكانية إنشاء برامج ثابتة مخصّصة. ومع ذلك، لا يتضمّن هذا المستودع أيًا من بيانات البرامج الثابتة الرسمية. يمكن العثور على سكربت لتنزيل أحدث ملفات البرامج الثابتة في مستودع fwupd/8bitdo-firmware، مع أرشفة بعض ثنائيات البرامج الثابتة الأقدم.
بالنظر إلى ملف برنامج ثابت .dat من الظاهر، توجد ترويسة نص صريح بحجم 28 بايت.
import struct
raw = open("sn30-v2_07.dat", "rb").read()
version, addr, payload_len = struct.unpack("<III", raw[:12])
# version = 207 -> firmware == v2.07
# addr = 0x08003400 -> destination (probably)
# payload_len = 99328 -> section payload length in bytes
مع ملفات البرامج الثابتة الحديثة توجد قيمة 32-بت غير معروفة موضوعة بعد هذه. باقي الترويسة أصفار.
في بعض ملفات البرامج الثابتة يظهر نمط: هناك طبقة تمزج كل كلمة في الكلمة التالية.
def rotr(x, r):
return ((x >> r) | (x << (32 - r))) & 0xFFFFFFFF
dechained = [words[0]]
for i in range(1, len(words)):
dechained.append(words[i] ^ rotr(words[i - 1], 3))
يُعاد ضبط هذا التسلسل عند كل حدّ كتلة من 128 كلمة. الكلمة الأولى في الكتلة
(pos = 0) ليس لها حدّ سابق، وpos = 1..127 تتسلسل من الكلمة التي قبلها.
الطبقة بدون مفتاح لا تضيف أي أمان. إزالتها تكشف وسيطًا أنظف (dechained)، حيث
الشيء الوحيد المتبقي الذي يخفي النص الصريح هو تدفق المفاتيح (keystream).
P[i] = dechained[i] ^ keystream[i]
يحتوي مستودع fwupd/8bitdo-firmware على فهرس للبرامج الثابتة الأقدم. بدا أن كل هذه البرامج الثابتة تحتوي على طبقة التسلسل.
إجراء عملية XOR بين بعضها بعد إزالة طبقة التسلسل يكشف عن سلاسل طويلة من الأصفار وبنية قابلة للقراءة.
plaintext_diff = [a ^ b for a, b in zip(dechained_a, dechained_b)]
إذا استُخدم نفس تدفق المفاتيح في كليهما...
a = dechained_a[i] ^ dechained_b[i]
b = (P_a[i] ^ keystream[i]) ^ (P_b[i] ^ keystream[i])
c = P_a[i] ^ P_b[i]
assert a == b == c
فإن تدفق المفاتيح يُلغى.
هذا هو لوح متعدد الاستخدامات (many-time pad) الكلاسيكي. تدفق المفاتيح هذا آمن فقط إذا استُخدم مرة واحدة. لسبب ما، قررت 8BitDo استخدام ليس فقط تدفق مفاتيح لكل منتج، بل تدفق مفاتيح واحد عبر عدة منتجات. أضعف هذا بشكل كبير أمان شفرتهم وجعل بقية التحليل هنا ممكنًا.
بمجرد أن تعرف أن نصين صريحين قد خُضعا لعملية XOR معًا، يمكنك تخمين جزء من أحدهما
وطرح تخمينك لقراءة الآخر. هذا يستعيد keystream في ذلك الموضع.
keystream[i] = dechained[i] ^ P[i]
for fw in all_fw:
P[fw][i] = dechained[fw][i] ^ keystream[i]
سيكون هذا أسهل مع النصوص، لكن الحيلة الأكثر إنتاجية هنا كانت إعادة تحديد موقع تعليمات ARM عبر الإصدارات. يمكن أن تظهر دالة واحدة في إصدارين من البرامج الثابتة في مواضع مُزاحة. حيث يكون تدفق المفاتيح معروفًا في إصدار ما، يمكنك محاذاة الكود المشترك في إصدار آخر وحصاد قيم تدفق مفاتيح جديدة.
استغلال هذا رفع كلمات تدفق المفاتيح المعروفة من ~1,500 إلى عدة آلاف، أي حوالي 18% من البرنامج الثابت قابل للقراءة.
مع عيّنة أكبر من keystream، أصبحت الأنماط مرئية. كانت له مواضع تفصلها 16 كلمة
وتتقدم بمقدار شبه ثابت، وكل بايت يتحرك بمقدار متوقّع.
من خلال التجربة والخطأ، اكتُشف أن كل كلمة تدفق مفاتيح في كتلة تتبع صيغة:
STEP = 0x92A753FA
A_MUL = 0x80000301
ROT = 18
UINT32_MAX = 0xFFFFFFFF
def rotr(x, r):
r &= 31
return ((x >> r) | (x << (32 - r))) & UINT32_MAX if r else x
def block_base(block):
return (block * A_MUL + block // 2) & UINT32_MAX
def keystream(block, pos, block_key):
counter = (block_base(block) + pos * STEP) & UINT32_MAX
mask = rotr(block_key, ROT * pos)
return counter ^ mask
داخل الكتلة، سِر بعدّاد بسيط (block_base(block) + pos*STEP)، وأجرِ XOR معه بقناع
يبدأ من block_key ويدور 18 بت في كل خطوة. الكتلة بأكملها المكوّنة من 128 كلمة
يحدّدها رقم واحد بحجم 32-بت، هو block_key.
كلمة واحدة معروفة تفتح كتلة كاملة. إعادة ترتيب الصيغة تعطي block_key من أي كلمة
تدفق مفاتيح معروفة:
def block_key_from_known(block, pos, known_keystream):
counter = (block_base(block) + pos * STEP) & UINT32_MAX
return rotr(known_keystream ^ counter, -ROT * pos & 31)
من أين جاء مفتاح كل كتلة؟ إنه يتحلّل إلى نصفين بحجم 16-بت مأخوذين من جدول بذور بحجم 256 مدخلًا:
def block_key_of(block, seed_table):
hi = seed_table[block]
lo = seed_table[block ^ 0xF9]
return (hi << 16) | lo
يبدو أن جدول البذور نفسه لا يتبع صيغة محددة، فهو على الأرجح 512 بايت ثابتة مخزّنة
في مكان ما في محمّل الإقلاع (bootloader) لكل جهاز. ومع ذلك، فإن عملية XOR مع
0xF9 توفّر تأثيرًا جانبيًا جميلًا: قانون المرآة. الكتلة وشريكتها block ^ 0xF9
مبنيّتان من نفس مدخلي الجدول لكن مبادَلين.
mirror = block_key_of(block ^ 0xF9, seed_table)
assert mirror == rotr(block_key_of(block, seed_table), 16)
هذا كسر مناطق كانت غير معروفة سابقًا. فبدلًا من تخمين block_key كامل بحجم 32-بت
بشكل أعمى، يمكنك تخمين نصفين بحجم 16-بت وتقييم أي اختيار يحوّل الكتلتين إلى سلاسل
أو تعليمات ARM معقولة.
وبهذا، تم الحصول على تغطية 100% لجميع بيانات البرامج الثابتة التي تستخدم هذه الشيفرة المحددة.
يبدو أن هذه الشيفرة تغطي معظم منتجات 8BitDo حوالي عام 2020، والمنتجات الحالية التي لا تزال تستخدم شرائح GD32 (بما في ذلك SN30 Pro).
تبدو بعض الأجهزة مثل M30 وZero 2 متعددة الأقسام، حيث تستخدم بعض بيانات البرامج الثابتة هذه الشيفرة وبرنامج ثابت آخر بمخطط تشفير مختلف.
الأجهزة الأحدث، على الأقل تلك التي تستخدم SoC مختلفًا، يبدو أن لديها مخطط تشفير أقوى للبرامج الثابتة. يُظهر بعض التحليل الساذج أنه قد يكون مشتركًا، لكن الأمر غير مؤكد. نحتاج إلى التحقيق في ذلك بشكل أعمق.