مقدمة
سوف يصف هذا المنشور إنشاء حزمة ملفات قابلة للتنفيذ بسيطة لنظام التشغيل Linux x86_64. من المفترض أن يكون القارئ معتادًا على لغة البرمجة C ولغة التجميع للهيكل x86_64 وملفات ELF الخاصة بالجهاز. لضمان الوضوح ، تمت إزالة معالجة الأخطاء من التعليمات البرمجية في المقالة ولم يتم عرض تطبيقات بعض الوظائف ، ويمكن العثور على الرمز الكامل بالنقر فوق الارتباطات إلى github (
loader ،
packer ).
الفكرة هي: ننقل ملف ELF إلى باكر ، ونحصل على ملف جديد بالهيكل التالي عند الإخراج:
للضغط ، تقرر استخدام خوارزمية Huffman ، للتشفير - AES-CTR مع مفتاح 256 بت ، أي التنفيذ من kokke
tiny-AES-c . يتم استخدام 256 بايت من البيانات العشوائية لتهيئة مفتاح AES وناقل التهيئة باستخدام مولد الأرقام العشوائية الزائفة ، كما هو موضح أدناه:
for(int i = 0; i < 32; i++) { seed = (1103515245*seed + 12345) % 256; key[i] = buf[seed]; }
كان هذا القرار بسبب الرغبة في تعقيد الهندسة العكسية. حتى الآن ، أدركت أن المضاعفات غير ذات أهمية ، لكنني لم أبدأ في إزالتها ، حيث أنني لم أرغب في قضاء الوقت والطاقة في ذلك.
محمل
أولاً ، سيتم النظر في عمل أداة تحميل التشغيل. يجب ألا يحتوي المُحمل على أي تبعيات ، لذلك يجب كتابة جميع الوظائف الضرورية من مكتبة C القياسية بشكل مستقل (تنفيذ هذه الوظائف متاحًا
بالرجوع ). كما يجب أن تكون مستقلة موضعيًا.
وظيفة _Start
يبدأ محمل الإقلاع من وظيفة _start ، التي تنقل ببساطة argc و argv إلى main:
.extern main .globl _start .text _start: movq (%rsp), %rdi movq %rsp, %rsi addq $8, %rsi call main
الوظيفة الرئيسية
يبدأ ملف main.c بتعريف العديد من المتغيرات الخارجية:
extern void* loader_end;
تم إعلانها جميعًا على أنها خارجية من أجل العثور على موضع الأحرف المطابقة للمتغيرات (Elf64_Sym) في الحزمة وتغيير قيمها.
الوظيفة الرئيسية نفسها بسيطة جدا. تتمثل الخطوة الأولى في تهيئة مؤشرات إلى ملف ELF محزوم ، ومخزن مؤقت 256 بايت ، وإلى الجزء العلوي من المكدس. ثم يتم فك تشفير ملف ELF وتوسيعه ، ثم يتم وضعه في المكان المناسب في الذاكرة باستخدام دالة load_elf ، وأخيراً ، تعود قيمة سجل rsp إلى حالته الأصلية ، وتحدث قفزة إلى نقطة إدخال البرنامج:
#define SET_STACK(sp) __asm__ __volatile__ ("movq %0, %%rsp"::"r"(sp)) #define JMP(addr) __asm__ __volatile__ ("jmp *%0"::"r"(addr)) int main(int argc, char **argv) { uint8_t *payload = (uint8_t*)&loader_end;
تتم إعادة تعيين حالة AES وملف ELF الذي تم فك ضغطه لأغراض أمنية - بحيث يتم تخزين البيانات الرئيسية وفك تشفيرها في الذاكرة فقط لوقت الاستخدام.
بعد ذلك ، سننظر في تنفيذ بعض الوظائف.
load_elf
أخذت هذه الوظيفة من مستخدم github الذي يحمل اسم bediger من مستودع
userlandexec الخاص به ووضع اللمسات الأخيرة عليه ، لأن الوظيفة الأصلية تعطلت على ملفات مثل ET_DYN. حدث الفشل بسبب حقيقة أن قيمة الوسيطة الأولى لاستدعاء نظام mmap قد تم تعيينها على NULL ، وتم إرجاع العنوان قريبًا جدًا من البرنامج الرئيسي ، أثناء إجراء مكالمات لاحقة ل mmap ونسخ المقاطع إلى العناوين التي تم إرجاعها إليها ، وتم استبدال رمز البرنامج الرئيسي ، وحدثت التعليمة البرمجية للبرنامج الرئيسي. لذلك ، تقرر إضافة عنوان البداية كمعلمة إلى وظيفة load_elf. تعمل الوظيفة نفسها عبر جميع رؤوس البرامج ، وتخصص الذاكرة (يجب أن يكون رقمها مضاعفًا لحجم الصفحة) لقطاعات PT_LOAD من ملف ELF ، ونسخ محتوياتها إلى مناطق الذاكرة المخصصة وتعيين حقوق القراءة والكتابة والتنفيذ المناظرة لهذه المناطق:
elf_load_addr
تقوم هذه الدالة لملفات ET_EXEC ELF بإرجاع NULL ، نظرًا لأن الملفات من هذا النوع يجب أن تكون موجودة في العناوين المحددة فيها. بالنسبة إلى ملفات ET_DYN ، يتم أولاً حساب العنوان الذي يساوي الفرق بين العنوان الأساسي للبرنامج الرئيسي (أي محمل الإقلاع) ، ومقدار الذاكرة المطلوبة لوضع ELF في الذاكرة ، و 4096 ، 4096 - الفجوة اللازمة حتى لا يتم وضع ملف ELF بجوار البرنامج الرئيسي مباشرةً. بعد حساب هذا العنوان ، يتم التحقق مما إذا كانت منطقة الذاكرة تتقاطع ، من العنوان المحدد إلى العنوان الأساسي للبرنامج الرئيسي ، مع المنطقة من بداية ملف ELF غير المعبأ إلى نهايته. في حالة التقاطع ، يتم إرجاع العنوان مساويًا للفرق بين عنوان البدء لعنصر ELF غير المعبأ وكمية الذاكرة المطلوبة لوضعه ، وإلا يتم إرجاع العنوان المحسوب مسبقًا.
تم العثور على العنوان الأساسي للبرنامج عن طريق استخراج عنوان رؤوس البرنامج من المتجه المساعد (متجه ELF المساعد) ، والذي يقع بعد المؤشرات إلى متغيرات البيئة في المكدس ، وطرح حجم رأس ELF منه:
--------------------------------------------------------------------------- -> [ argc ] 8 [ argv[0] ] 8 [ argv[1] ] 8 [ argv[..] ] 8 * x [ argv[n – 1] ] 8 [ argv[n] ] 8 (= NULL) [ envp[0] ] 8 [ envp[1] ] 8 [ envp[..] ] 8 [ envp[term] ] 8 (= NULL) [ auxv[0] (Elf64_auxv_t) ] 16 [ auxv[1] (Elf64_auxv_t) ] 16 [ auxv[..] (Elf64_auxv_t) ] 16 [ auxv[term] (Elf64_auxv_t) ] 16 (= AT_NULL) [ ] 0 - 16 [ ] >= 0 [ ] >= 0 [ ] 8 (= NULL) < > 0 ---------------------------------------------------------------------------
الهيكل الذي وصف به كل عنصر من عناصر المتجه المساعد له الشكل:
typedef struct { uint64_t a_type;
إحدى قيم a_type الصالحة هي AT_PHDR ، ثم يشير a_val إلى رؤوس البرنامج. التالي هو رمز الدالة elf_load_addr:
void* elf_base_addr(void *rsp) { void *base_addr = NULL; unsigned long argc = *(unsigned long*)rsp; char **envp = rsp + (argc+2)*sizeof(unsigned long);
وصف البرنامج النصي رابط
من الضروري تحديد الأحرف للمتغيرات الخارجية الموضحة أعلاه ، وتأكد أيضًا من أن بيانات الكود والمحمل بعد التحويل في نفس القسم .text. يعد هذا ضروريًا لاستخراج رمز آلة التحميل بسهولة من خلال الاستغناء عن محتويات هذا القسم من الملف. لتحقيق هذه الأهداف ، تم كتابة البرنامج النصي رابط التالي:
ENTRY(_start) SECTIONS { . = 0; .text :{ *(.text) *(.text.startup) *(.data) *(.rodata) payload_size = .; QUAD(0) key_seed = .; QUAD(0) iv_seed = .; QUAD(0) loader_end = .; } }
تجدر الإشارة إلى أن QUAD (0) تضع 8 بايتات من الأصفار ، بدلاً من ذلك يقوم البديل باستبدال قيم محددة. لقص رمز الجهاز ، تمت كتابة أداة صغيرة تكتب أيضًا في بداية رمز الجهاز تحول نقطة الإدخال إلى أداة تحميل التشغيل من بداية أداة تحميل التشغيل ، وإزاحة قيم أحرف payload_size ، و key_seed و iv_seed من بداية أداة تحميل bootloader. رمز هذه الأداة متاح
هنا . هذا ينتهي وصف بووتلوأدر.
باكر مباشرة
النظر في الوظيفة الرئيسية للباكر. يستخدم وسيطين لسطر الأوامر: اسم ملف الإدخال هو argv [1] واسم ملف الإخراج هو argv [2]. أولاً ، يتم عرض ملف الإدخال في الذاكرة والتحقق من توافقه مع باكر. يعمل الرابط مع نوعين فقط من ملفات ELF: ET_EXEC و ET_DYN ، وفقط مع الملفات المترجمة بشكل ثابت. كان سبب إدخال هذا التقييد هو حقيقة أن أنظمة linux المختلفة لها إصدارات مختلفة من المكتبات المشتركة ، أي احتمال عدم تشغيل برنامج مترجم ديناميكيًا على نظام آخر غير النظام الأصلي كبير جدًا. الكود المقابل في الوظيفة الرئيسية:
size_t mapped_size; void *mapped = map_file(argv[1], &mapped_size); if(check_elf(mapped) < 0) return 1;
بعد ذلك ، إذا نجح ملف الإدخال في التحقق من التوافق ، فسيتم ضغطه:
size_t comp_size; uint8_t *comp_buf = huffman_encode(mapped, &comp_size);
بعد ذلك ، يتم إنشاء حالة AES ، ويتم تشفير ملف ELF المضغوط. يتم تحديد حالة AES بالهيكل التالي:
#define AES_ENTROPY_BUFSIZE 256 typedef struct { uint8_t entropy_buf[AES_ENTROPY_BUFSIZE];
كود المقابلة في الرئيسي:
AES_state_t aes_st; for(int i = 0; i < AES_ENTROPY_BUFSIZE; i++) state.entropy_buf[i] = rand() % 256; state.key_seed = rand(); state.iv_seed = rand(); AES_init_ctx_iv(&state.ctx, state.entropy_buf, state.key_seed, state.iv_seed); AES_CTR_xcrypt_buffer(&aes_st.ctx, comp_buf, comp_size);
بعد ذلك ، تتم تهيئة البنية التي تخزّن معلومات حول أداة تحميل التشغيل ، ويتم تغيير قيم الحمولة_الحجمية ، و key_seed ، و iv_seed في محمل الإقلاع- إلى تلك التي تم إنشاؤها في الخطوة السابقة ، وبعد ذلك تتم إعادة تعيين حالة AES. يتم تخزين معلومات حول أداة تحميل التشغيل في البنية التالية:
typedef struct { char *loader_begin;
كود المقابلة في الرئيسي:
loader_t loader; init_loader(&loader); *loader.payload_size_patch_offset = comp_size; *loader.key_seed_pacth_offset = aes_st.key_seed; *loader.iv_seed_patch_offset = aes_st.iv_seed; memset(&aes_st.ctx, 0, sizeof(aes_st.ctx));
في الجزء الأخير ، نقوم بإنشاء ملف إخراج ، وكتابة رأس ELF ، ورأس برنامج واحد ، ورمز محمل ، وملف ELF مضغوط ومشفّر ، ومخزن مؤقت 256 بايت فيه:
int out_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0755);
ينتهي الرمز الرئيسي للباكر هنا ، ثم سيتم النظر في الوظائف التالية: وظيفة تهيئة المعلومات حول أداة تحميل التشغيل ، وظيفة كتابة رأس ELF ووظيفة كتابة رأس البرنامج.
تهيئة معلومات أداة تحميل التشغيل
يتم تضمين رمز الجهاز لودر في باكر قابل للتنفيذ باستخدام رمز بسيط أدناه:
.data .globl _loader_begin .globl _loader_end _loader_begin: .incbin "loader" _loader_end:
لتحديد عنوانها في الذاكرة ، يتم التصريح عن المتغيرات التالية في ملف main.c:
extern void* _loader_begin; extern void* _loader_end;
بعد ذلك ، ضع في الاعتبار وظيفة init_loader. أولاً ، يتم قراءة القيم التالية بالتسلسل: إزاحة نقطة الإدخال من بداية أداة تحميل التشغيل (إدخال_التوفير) ، إزاحة حجم ملف ELF المحزوم من بداية أداة تحميل الإقلاع (payload_size_patch_offset) ، إزاحة القيمة الأولية للمولد للمفتاح من بداية أداة تحميل الإقلاع (key_seed_patch) التهيئة من بداية أداة تحميل التشغيل (iv_seed_patch_offset). بعد ذلك ، تتم إضافة عنوان أداة التحميل إلى القيم الثلاث الأخيرة ، لذا عند استبدال مؤشرات التخصيص وإسناد القيم إليها ، سنستبدل الأصفار المعينة في مرحلة التخطيط (QUAD (0)) بالقيم التي نحتاجها.
void init_loader(loader_t *l) { void *loader_begin = (void*)&_loader_begin; l->entry_offset = *(size_t*)loader_begin; loader_begin += sizeof(size_t); l->payload_size_patch_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->key_seed_pacth_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->iv_seed_patch_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->payload_size_patch_offset = (size_t)l->payload_size_patch_offset + loader_begin; l->key_seed_pacth_offset = (size_t)l->key_seed_pacth_offset + loader_begin; l->iv_seed_patch_offset = (size_t)l->iv_seed_patch_offset + loader_begin; l->loader_begin = loader_begin; l->loader_size = (void*)&_loader_end - loader_begin; }
write_elf_ehdr
void write_elf_ehdr(int fd, loader_t *loader) {
هنا يحدث التهيئة القياسية لرأس ELF وكتابتها اللاحقة إلى ملف ، الشيء الوحيد الذي يجب الانتباه إليه هو حقيقة أنه في ملفات ET_DYN ELF ، لا يتضمن الجزء الموصوف في رأس البرنامج الأول فقط التعليمات البرمجية القابلة للتنفيذ ، ولكن أيضًا رأس ELF وجميع الرؤوس برنامج. لذلك ، يجب أن يكون إزاحته من البداية مساوياً للصفر ، ويجب أن يكون الحجم هو مجموع حجم رأس ELF ، وجميع رؤوس البرنامج والرمز القابل للتنفيذ ، ويتم تحديد نقطة الإدخال على أنها مجموع حجم رأس ELF ، وحجم كل رؤوس البرامج والإزاحة من بداية الكود القابل للتنفيذ.
write_elf_phdr
void write_elf_phdr(int fd, loader_t *loader, size_t payload_size) {
هنا ، تتم تهيئة رأس البرنامج ثم كتابته إلى ملف. يجب الانتباه إلى الإزاحة نسبة إلى بداية الملف وحجم القطعة الموصوفة في رأس البرنامج. كما هو موضح في الفقرة السابقة ، لا يتضمن الجزء الموصوف في هذا الرأس فقط التعليمات البرمجية القابلة للتنفيذ ، ولكن أيضًا رأس ELF ورأس البرنامج. نحن أيضًا نجعل الجزء الذي يحتوي على رمز قابل للتنفيذ متاحًا للكتابة ، ويرجع ذلك إلى أن تطبيق AES المستخدم في أداة تحميل التشغيل يقوم بتشفير البيانات وفك تشفيرها.
بعض الحقائق عن عمل باكر
أثناء الاختبار ، لوحظ أن البرامج المترجمة بشكل ثابت مع glibc تذهب إلى segfault عند بدء التشغيل ، بناءً على هذه التعليمات:
movq٪ fs: 0x28 ،٪ rax
لم أتمكن من معرفة سبب حدوث ذلك ، سأكون سعيدًا إذا كنت تشارك المعلومات حول هذا الموضوع. بدلاً من glibc ، يمكنك استخدام musl-libc ، كل شيء يعمل معه دون فشل. أيضًا ، تم اختبار الرازم مع برامج golang المترجمة بشكل ثابت ، على سبيل المثال ، خادم http. للحوادث الثابتة الكاملة لبرامج golang ، يجب استخدام الأعلام التالية:
CGO_ENABLED = 0 go build -a -ldflags '-extldflags "-static"'.
آخر شيء تم اختباره بواسطة packer كان ملفات ET_DYN ELF بدون رابط ديناميكي. صحيح ، عند العمل مع هذه الملفات ، قد تفشل وظيفة elf_load_addr. في الممارسة العملية ، يمكن قصها من أداة تحميل التشغيل واستخدام عنوان ثابت ، على سبيل المثال 0x10000.
استنتاج
من الواضح أن هذا المعبئ ليس من المنطقي استخدامه للغرض المقصود منه ، لأن الملفات المحمية به يتم فك تشفيرها بسهولة. كان الهدف من هذا المشروع هو إتقان العمل بشكل أفضل مع ملفات ELF ، وممارسة إنشاءها ، بالإضافة إلى التحضير لإنشاء أداة تعبئة كاملة.