Пишем упаковщик по шагам. Шаг третий. Распаковываем.

Предыдущий шаг здесь.

Идем дальше! Пришло время написать распаковщик, именно этим мы начнем заниматься в этом шаге. Обрабатывать исходную таблицу импорта мы пока не будем, так как и в этом уроке нам будет, чем заняться.

Начнем мы вот с чего. Для работы распаковщика нам стопроцентно потребуются две WinAPI-функции: LoadLibraryA и GetProcAddress. В своем старом упаковщике я писал стаб распаковщика на MASM32 и вообще не создавал таблицу импорта. Я искал адреса этих функций в ядре, что несколько сложно и хардкорно, кроме того, это может вызвать неиллюзорные подозрения у антивирусов. Давайте в этот раз создадим обычную таблицу импортов и сделаем так, чтобы загрузчик сам нам сообщил адреса этих функций! Разумеется, набор из двух этих функций в таблице импорта так же подозрителен, как и полное их отсутствие, но ничто нам не мешает в будущем добавить еще другие левые случайные импорты из различных DLL-файлов. Куда загрузчик будет записывать адреса этих двух функций? Пора расширить нашу структуру packed_file_info!

//Структура, хранящая информацию об упакованном файлеstruct packed_file_info
{
  BYTE number_of_sections;//Количество секций в оригинальном файле
  DWORD size_of_packed_data;//Размер упакованных данных
  DWORD size_of_unpacked_data;//Размер оригинальных данных
 
  DWORD load_library_a;//Адрес процедуры LoadLibraryA из kernel32.dll
  DWORD get_proc_address;//Адрес процедуры GetProcAddress из kernel32.dll
  DWORD end_of_import_address_table;//Конец IAT};

Я добавил в структуру три поля. В первые два загрузчик впишет адреса функций LoadLibraryA и GetProcAddress из kernel32.dll. Последнее поле указывает на конец адресной таблицы импорта (import address table, IAT), и в него мы запишем ноль, чтобы дать понять загрузчику, что больше никаких функций нам не надо. Про это я еще расскажу немного дальше.

Теперь необходимо создать новую таблицу импорта. В этом нам сильно поможет моя библиотека для работы с PE. (На старую оригинальную мы пока что наплюем).

//....//Устанавливаем для нее необходимый виртуальный размер
      image.set_section_virtual_size(added_section, total_virtual_size);//....
 
      std::cout<<"Creating imports..."<< std::endl;
 
      //Создаем импорты из библиотеки kernel32.dll
      pe_base::import_library kernel32;
      kernel32.set_name("kernel32.dll");//Выставили имя библиотеки
 
      //Создаем импортируемую функцию
      pe_base::imported_function func;
      func.set_name("LoadLibraryA");//Ее имя
      kernel32.add_import(func);//Добавляем ее к библиотеке
 
      //И вторую функцию
      func.set_name("GetProcAddress");
      kernel32.add_import(func);//Тоже добавляем
 
      //Получаем относительный адрес (RVA) поля load_library_a//нашей структуры packed_file_info, которую мы расположили в самом//начале добавленной секции, помните?
      DWORD load_library_address_rva = pe_base::rva_from_section_offset(added_section,
        offsetof(packed_file_info, load_library_a));
 
      //Устанавливаем этот адрес как адрес//таблицы адресов импорта (import address table)
      kernel32.set_rva_to_iat(load_library_address_rva);
 
      //Создаем список импортируемых библиотек
      pe_base::imported_functions_list imports;//Добавляем к списку нашу библиотеку
      imports.push_back(kernel32);
 
      //Настроим пересборщик импортов
      pe_base::import_rebuilder_settings settings;//Original import address table нам не нужна (пояснения ниже)
      settings.build_original_iat(false);//Будем переписывать IAT именно по тому адресу,//который указали (load_library_address_rva)
      settings.save_iat_and_original_iat_rvas(true, true);//Расположим импорты прямо за концом упакованных данных
      settings.set_offset_from_section_start(added_section.get_raw_data().size());//Пересоберем импорты
      image.rebuild_imports(imports, added_section, settings);

Начало кода понятно - создали импорт библиотеки, добавили к ней пару функций, создали список импортируемых библиотек из одной-единственной kernel32.dll. Поясню строку, где мы устанавливаем RVA к IAT (kernel32.set_rva_to_iat). Я уже раньше писал кое-что об импортах PE-файла. Расскажу вкратце еще разок. Для каждой импортируемой библиотеки в таблице импортов создается следующая структура:

Пишем упаковщик по шагам. Шаг третий. Распаковываем.

Загрузчик записывает адреса импортируемых функций в Import Address Table (IAT) для каждой импортируемой DLL, а имена или ординалы импортируемых функций он берет из Original Import Address Table (или, по-другому, Import Lookup Table). Можно обойтись и без последней, например, все компиляторы Borland всегда так делают, плевать они хотели на Import Lookup Table. В этом случае у нас в единственной таблице Import Address Table содержатся сразу ординалы или имена импортируемых функций, и туда же, поверх этих данных, загрузчик запишет адреса непосредственно импортированных функций. Мы тоже не будем делать Original Import Address Table, обойдемся без нее (меньше места импорт займет), поэтому отключаем эту опцию в пересборщике импортов.

Вызов settings.save_iat_and_original_iat_rvas настраивает пересборщик таким образом, что он не будет создавать свои собственные IAT и Original IAT, а запишет все по тем адресам, которые уже указаны в каждой библиотеке (помните вызов kernel32.set_rva_to_iat?).

Далее мы просто пересобираем таблицу импортов. В очередной раз запустим недоупаковщик, передав ему его же имя, и посмотрим, что получилось. Убедимся, что все прошло так, как и было задумано:

Пишем упаковщик по шагам. Шаг третий. Распаковываем.

Теперь запустим получившийся бинарник в OllyDbg и убедимся, что загрузчик записал адреса двух нужных нам функций туда, куда надо:

Пишем упаковщик по шагам. Шаг третий. Распаковываем.

Как видно, по адресам 0x1009 и 0x100D записались именно те адреса, которые нам нужны, значит, все сделано правильно. (Адрес точки входа пока что совершенно левый, и нет никакого распаковщика, поэтому файл по-прежнему не запустится, но мы уже достигли многого).

Идем дальше. Необходимо подготовить наши сорсы для написания распаковщика. Вынесем все структуры из файла main.cpp в файл structs.h, его содержимое будет таким:

#pragma once#include <Windows.h>#pragma pack(push, 1)//Структура, хранящая информацию об упакованной секцииstruct packed_section
{char name[8];//Имя секции
  DWORD virtual_size;//Виртуальный размер
  DWORD virtual_address;//Виртуальный адрес (RVA)
  DWORD size_of_raw_data;//Размер "сырых" данных
  DWORD pointer_to_raw_data;//Файловое смещение сырых данных
  DWORD characteristics;//Характеристики секции};
 
//Структура, хранящая информацию об упакованном файлеstruct packed_file_info
{
  BYTE number_of_sections;//Количество секций в оригинальном файле
  DWORD size_of_packed_data;//Размер упакованных данных
  DWORD size_of_unpacked_data;//Размер оригинальных данных
 
  DWORD load_library_a;//Адрес процедуры LoadLibraryA из kernel32.dll
  DWORD get_proc_address;//Адрес процедуры GetProcAddress из kernel32.dll
  DWORD end_of_import_address_table;//Конец IAT};#pragma pack(pop)

Тут пояснять ничего не нужно, мы просто перенесли код. В main.cpp, в свою очередь, подключим этот файл:

//Заголовочный файл с нашими структурами#include "structs.h"

И наступает время хардкора! Будем писать распаковщик. Немного поразмыслив, я решил не использовать MASM32, а писать его на C с элементами C++ и ассемблерными вставками - читаемость кода будет выше. Итак, создаем новый проект в солюшене и называем его unpacker. Добавляем к нему файлы unpacker.cpp и parameters.h (создаем). Далее в настройках выставляем всё то же самое, что мы делали с проектом lzo-2.06 в самом первом шаге, чтобы сборка была самой минимальной по размеру и базонезависимой. Точку входа (Linker - Advanced - Entry Point) выставляем в unpacker_main. Далее, в Configuration Manager'е (см. шаг 1) выставляем, чтобы этот проект всегда собирался в конфигурации Release:

Пишем упаковщик по шагам. Шаг третий. Распаковываем.

Проставим у проекта simple_pe_packer зависимость от проекта unpacker (Project Dependencies, как в шаге 1) и добавим файл parameters.h в инклюды проекта упаковщика - в этот файл мы будем вписывать необходимые параметры для сборки распаковщика:

//Заголовочный файл с параметрами распаковщика#include "../unpacker/parameters.h"

Теперь начнем писать сам распаковщик. Открываем файл unpacker.cpp...

//Подключаем файл со структурами из проекта упаковщика#include "../simple_pe_packer/structs.h"//Создадим функцию без пролога и эпилогаvoid __declspec(naked) unpacker_main(){//Пролог вручную
  __asm
  {
    push ebp;
    mov ebp, esp;
    sub esp, 128;}
 
  //... описано далее ...//
 
  //Эпилог вручную
  _asm
  {
    leave;
    ret;}}

Итак, начинаю разъяснения. Сначала мы подключили файл, содержащий объявления структур упаковщика - в распаковщике они нам пригодятся. Далее мы создаем точку входа - процедуру unpacker_main. Обратите внимание, что это особо объявленная функция - naked. Это говорит компилятору о том, что не нужно создавать для этой функции пролог и эпилог (стековый фрейм) автоматически. Нам это необходимо сделать вручную, а зачем - поясню в следующем уроке. Пока что мы создаем точь-в-точь такие же пролог и эпилог, которые делает сам компилятор MSVC++. Строка "sub esp, 128" выделяет на стеке 128 байтов - этого нам пока должно хватить для подручных нужд. В этом шаге распаковщик не будет делать чего-то серьезного. Пролог и эпилог нужны нам, чтобы мы могли выделять память на стеке без лишних проблем. В самом конце мы пишем инструкцию ret - возврат в ядро. Теперь напишем самое простое тело упаковщика. Пусть он будет просто приветствовать нас, выдавая Message Box с текстом "Hello!".

//Адрес загрузки образаunsignedint original_image_base;//Относительный адрес первой секции,//в которую упаковщик кладет информацию для//распаковщика и сами упакованные данныеunsignedint rva_of_first_section;
 
  //Эти инструкции нужны только для того, чтобы//заменить в билдере распаковщика адреса на реальные
  __asm
  {
    mov original_image_base, 0x11111111;
    mov rva_of_first_section, 0x22222222;}

Здесь мы объявили две локальные переменные. Первая будет содержать действительный адрес загрузки образа, а вторая - относительный адрес самой первой секции, в которую, как вы помните, мы кладем всю необходимую для распаковщика информацию и сами упакованные данные. Вместо чисел 0x11111111 и 0x22222222 мы с помощью упаковщика будем записывать реальные значения.

//Получаем указатель на структуру с информацией,//которую для нас заботливо приготовил упаковщикconst packed_file_info* info;//Она находится в самом начале//первой секции упакованного файла
  info =reinterpret_cast<const packed_file_info*>(original_image_base + rva_of_first_section);
 
  //Два тайпдефа прототипов функций LoadLibraryA и GetProcAddresstypedef HMODULE (__stdcall* load_library_a_func)(constchar* library_name);typedef INT_PTR (__stdcall* get_proc_address_func)(HMODULE dll, constchar* func_name);
 
  //Считаем их адреса из структуры packed_file_info//Их нам туда подложил загрузчик
  load_library_a_func load_library_a;
  get_proc_address_func get_proc_address;
  load_library_a =reinterpret_cast<load_library_a_func>(info->load_library_a);
  get_proc_address =reinterpret_cast<get_proc_address_func>(info->get_proc_address);

Здесь, кажется, все понятно. В начале первой секции упакованного файла лежит структура packed_file_info, которую мы создаем в упаковщике. В ней есть еще три поля, заполняемые самим загрузчиком - мы так устроили таблицу импортов, как вы помните. Из этих полей мы получаем адреса функций LoadLibraryA и GetProcAddress. Вы еще можете спросить, зачем я сначала объявляю все переменные, и только позже присваиваю им значения, ведь я мог бы это делать одной строкой. Все дело в том, что в naked-функциях нельзя одновременно объявить переменную и сразу же присвоить ей значение.

И последняя (пока что) часть кода распаковщика:

//Создаем буфер на стекеchar buf[32];//user32.dll*reinterpret_cast<DWORD*>(&buf[0])='resu';*reinterpret_cast<DWORD*>(&buf[4])='d.23';*reinterpret_cast<DWORD*>(&buf[8])='ll';
 
  //Загружаем библиотеку user32.dll
  HMODULE user32_dll;
  user32_dll = load_library_a(buf);
 
  //Тайпдеф прототипа функции MessageBoxAtypedefint(__stdcall* message_box_a_func)(HWND owner, constchar* text, constchar* caption, DWORD type);
 
  //MessageBoxA*reinterpret_cast<DWORD*>(&buf[0])='sseM';*reinterpret_cast<DWORD*>(&buf[4])='Bega';*reinterpret_cast<DWORD*>(&buf[8])='Axo';
 
  //Получаем адрес функции MessageBoxA
  message_box_a_func message_box_a;
  message_box_a =reinterpret_cast<message_box_a_func>(get_proc_address(user32_dll, buf));
 
  //Hello!*reinterpret_cast<DWORD*>(&buf[0])='lleH';*reinterpret_cast<DWORD*>(&buf[4])='!!o';
 
  //Выводим месадж бокс
  message_box_a(0, buf, buf, MB_ICONINFORMATION);

Здесь в целом тоже все должно быть понятно, кроме странного заполнения строк. Мы выделили буфер buf на стеке. Строки все у нас также должны быть исключительно на стеке - мы ничего не можем писать в секцию данных, так как это неизбежно приведет к появлению релокаций, и код станет базозависимым. Именно поэтому мы так нелепо по 4 байта записываем строки непосредственно в стековый буфер. Нужно еще помнить про обратный порядок байтов, с которым работает архитектура x86, а мы именно под нее пишем код, поэтому буквы в кусках строк по 4 байта расположены в обратном порядке.

Сначала мы загружаем библиотеку user32.dll, затем получаем из нее адрес процедуры MessageBoxA, а затем вызываем ее. Вот и всё с распаковщиком!

Но осталась еще одна вещь - нам надо код распаковщика каким-то образом вставить в упакованный файл и настроить. Я решил это дело автоматизировать. Для этого добавим новый проект с именем unpacker_converter в солюшен. Цель этого проекта такова: он будет открывать получающийся после компиляции распаковщика файл unpacker.exe, считывать данные из его единственной секции (по сути, код) и преобразовывать его в h-файл, который мы заинклюдим в проекте simple_pe_packer. Пропишем в проекте unpacker_converter include-директорию как в проекте simple_pe_packer, чтобы можно было подключать h-файлы библиотеки для работы с PE-файлами, добавим в проект файл main.cpp и начнем писать код.

#include <iostream>#include <fstream>#include <vector>#include <string>#include <iomanip>//Заголовочный файл библиотеки для работы с PE-файлами#include <pe_32_64.h>
 
//Директивы для линкования с собранной библиотекой PE#ifndef _M_X64#ifdef _DEBUG#pragma comment(lib, "../../Debug/pe_lib.lib")#else#pragma comment(lib, "../../Release/pe_lib.lib")#endif#else#ifdef _DEBUG#pragma comment(lib, "../../x64/Debug/pe_lib.lib")#else#pragma comment(lib, "../../x64/Release/pe_lib.lib")#endif#endif
 
int main(int argc, char* argv[]){//Подсказка по использованиюif(argc !=3){
    std::cout<<"Usage: unpacker_converter.exe unpacker.exe output.h"<< std::endl;return0;}
 
  //Открываем файл unpacker.exe - его имя//и путь к нему хранятся в массиве argv по индексу 1
  std::ifstream file(argv[1], std::ios::in| std::ios::binary);if(!file){//Если открыть файл не удалось - сообщим и выйдем с ошибкой
    std::cout<<"Cannot open "<< argv[1]<< std::endl;return-1;}
 
  try{
    std::cout<<"Creating unpacker source file..."<< std::endl;
 
    //Пытаемся открыть файл как 32-битный PE-файл//Последние два аргумента false, потому что нам не нужны//"сырые" данные привязанных импортов файла и //"сырые" данные отладочной информации//При упаковке они не используются, поэтому не загружаем эти данные
    pe32 image(file, false, false);
 
    //Получаем список секций распаковщика
    pe_base::section_list& unpacker_sections = image.get_image_sections();//Убедимся, что она одна (так как в нем нет импортов, релокаций)if(unpacker_sections.size()!=1){
      std::cout<<"Incorrect unpacker"<< std::endl;return-1;}
 
    //Получаем ссылку на данные этой секции
    std::string& unpacker_section_data = unpacker_sections.at(0).get_raw_data();//Удаляем нулевые байты в конце этой секции,//которые компилятор добавил для выравнивания
    pe_base::strip_nullbytes(unpacker_section_data);
 
    //Открываем выходной файл для записи h-файла//Его имя хранится в argv[2]
    std::ofstream output_source(argv[2], std::ios::out| std::ios::trunc);
 
    //Начинаем формировать исходный код
    output_source << std::hex<<"#pragma once"<< std::endl<<"unsigned char unpacker_data[] = {";//Текущая длина считанных данныхunsignedlong len =0;//Общая длина данных секции
    std::string::size_type total_len = unpacker_section_data.length();
 
    //Для каждого байта данных...for(std::string::const_iterator it = unpacker_section_data.begin(); it != unpacker_section_data.end();++it, ++len){//Добавляем необходимые переносы, чтобы//получившийся код был читаемымif((len %16)==0)
        output_source << std::endl;
 
      //Записываем значение байта
      output_source
        <<"0x"<< std::setw(2)<< std::setfill('0')<<static_cast<unsignedlong>(static_cast<unsignedchar>(*it));
 
      //И, если необходимо, запятуюif(len != total_len -1)
        output_source <<", ";}
 
    //Конец кода
    output_source <<" };"<< std::endl;}catch(const pe_exception& e){//Если по какой-то причине открыть его не удалось//Выведем текст ошибки и выйдем
    std::cout<< e.what()<< std::endl;return-1;}
 
  return0;}

Не буду детально описывать этот код - многое вам уже будет знакомо. Скажу только, что он просто формирует из файла unpacker.exe файл unpacker.h вида:

#pragma onceunsignedchar unpacker_data[]={0x55, 0x8b, 0xec, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0xc7, 0x45, 0xfc, 0x11, 0x11, 0x11, 0x11, 
0xc7, 0x45, 0xf8, 0x22, 0x22, 0x22, 0x22, 0x8b, 0x45, 0xfc, 0x03, 0x45, 0xf8, 0x8b, 0x48, 0x09, 
0x8b, 0x70, 0x0d, 0x8d, 0x45, 0xd8, 0x50, 0xc7, 0x45, 0xd8, 0x75, 0x73, 0x65, 0x72, 0xc7, 0x45, 
0xdc, 0x33, 0x32, 0x2e, 0x64, 0xc7, 0x45, 0xe0, 0x6c, 0x6c, 0x00, 0x00, 0xff, 0xd1, 0x8d, 0x4d, 
0xd8, 0x51, 0x50, 0xc7, 0x45, 0xd8, 0x4d, 0x65, 0x73, 0x73, 0xc7, 0x45, 0xdc, 0x61, 0x67, 0x65, 
0x42, 0xc7, 0x45, 0xe0, 0x6f, 0x78, 0x41, 0x00, 0xff, 0xd6, 0x6a, 0x40, 0x8d, 0x4d, 0xd8, 0x51, 
0x51, 0x6a, 0x00, 0xc7, 0x45, 0xd8, 0x48, 0x65, 0x6c, 0x6c, 0xc7, 0x45, 0xdc, 0x6f, 0x21, 0x00, 
0x00, 0xff, 0xd0, 0xc9, 0xc3};

Эти данные являются шестнадцатеричным представлением данных первой и единственной секции кода распаковщика. Он у нас пока совсем простой и маленький. Как же сделать, чтобы unpacker_converter автоматически генерировал для нас такой h-файл при пересборке распаковщика? Необходимо поправить настройку проекта unpacker (Build Events - Post-Build Event):

"..\unpacker_converter.exe" "..\Release\unpacker.exe" "..\simple_pe_packer\unpacker.h"

Почему я в этой настройке не использовал макрос $(Configuration)? Потому что он для проекта unpacker всегда будет раскрываться в "Release", так как и в дебаге, и в релизе этот проект собирается как Release (мы это меняли ранее в Configuration Manager'е). Поэтому мы просто будем копировать файл unpacker_converter.exe из ЕГО текущей конфигурации в корень проекта, и оттуда его уже сможет взять проект unpacker. Таким образом, последнее, что мы делаем, это правим конфигурацию проекта unpacker_converter (Build Events - Post-Build Event):

copy /Y "..\$(Configuration)\unpacker_converter.exe" "..\unpacker_converter.exe"

Осталось расставить зависимости (Project Dependencies): unpacker от unpacker_converter (вохможно, это не совсем логично, ну да ладно). После этого у нас все будет собираться и в Release, и в Debug-конфигурации.

Поясню, что мы запишем в файл parameters.h. Его содержимое будет таким:

#pragma once
 
staticconstunsignedint original_image_base_offset =0x0C;staticconstunsignedint rva_of_first_section_offset =0x13;

Мы пишем смещения относительно начала кода распаковщика (в собранном бинарном виде) двух чисел - 0x11111111 и 0x22222222. Эти числа будут перезаписываться упаковщиком, а смещения 0xC (12) и 0x13 (19) просчитываются в любом HEX-редакторе или с помощью автогенеренного файла unpacker.h. Меняться они уже вряд ли будут, так как код перед двумя ассемблерными командами mov в распаковщике мы больше дописывать не будем.

Добавим в include проекта simple_pe_packer автогенеренный файл unpacker.h:

//Тело распаковщика (автогенеренное)#include "unpacker.h"

Завершающим этапом урока будет вставка тела распаковщика в упаковываемый файл. В прошлом шаге мы делали так:

//В будущем тут будет код распаковщика и что-то еще
      unpacker_section.get_raw_data()="Nothing interesting here...";

Теперь будем вставлять туда код распаковщика и настраивать его:

//...{//Получаем ссылку на данные секции распаковщика
        std::string& unpacker_section_data = unpacker_section.get_raw_data();//Записываем туда код распаковщика//Этот код хранится в автогенеренном файле//unpacker.h, который мы подключили в main.cpp
        unpacker_section_data = std::string(reinterpret_cast<constchar*>(unpacker_data), sizeof(unpacker_data));//Записываем по нужным смещениям адрес//загрузки образа*reinterpret_cast<DWORD*>(&unpacker_section_data[original_image_base_offset])= image.get_image_base_32();//и виртуальный адрес самой первой секции упакованного файла,//в которой лежат данные для распаковки и информация о них//В самом начале это секции, как вы помните, лежит//структура packed_file_info*reinterpret_cast<DWORD*>(&unpacker_section_data[rva_of_first_section_offset])= image.get_image_sections().at(0).get_virtual_address();}
 
      //Добавляем и эту секциюconst pe_base::section& unpacker_added_section = image.add_section(unpacker_section);//Выставляем новую точку входа - теперь она указывает//на распаковщик, на самое его начало
      image.set_ep(image.rva_from_section_offset(unpacker_added_section, 0));//...

Всё! Теперь распаковщик будет настраиваться и добавляться в упакованный файл! Давайте убедимся в этом. Как всегда, упакуем сами себя, получив на выходе файл packed_simple_pe_packer.exe. Запустим его и увидим долгожданное окошко, ради которого было проделано столько работы!

Пишем упаковщик по шагам. Шаг третий. Распаковываем.

Итак, распаковщик правильно собирается, настраивается, преобразуется и запускается, что не может не радовать. В следующих уроках мы заставим его выполнять более осмысленную работу!

Как всегда, прикладываю полный солюшен (кроме библиотеки для работы с PE-файлами) упаковщика: Own PE packer step 3

Также рекомендую почитать

Пишем упаковщик по шагам. Шаг третий. Распаковываем. Обсудить на форуме


Источник: http://feedproxy.google.com/~r/kaimi/dev/~3/AyU_Ru9_U4Q/

Читать комменты и комментировать

Добавить комментарий / отзыв



Защитный код
Обновить

Пишем упаковщик по шагам. Шаг третий. Распаковываем. | | 2012-09-17 00:00:00 | | Блоги и всяко-разно | | Предыдущий шаг здесь.Идем дальше! Пришло время написать распаковщик, именно этим мы начнем заниматься в этом шаге. Обрабатывать исходную таблицу импорта мы пока не будем, так как и в этом уроке нам будет, чем заняться.Начнем мы вот с чего. Для работы распаковщ | РэдЛайн, создание сайта, заказать сайт, разработка сайтов, реклама в Интернете, продвижение, маркетинговые исследования, дизайн студия, веб дизайн, раскрутка сайта, создать сайт компании, сделать сайт, создание сайтов, изготовление сайта, обслуживание сайтов, изготовление сайтов, заказать интернет сайт, создать сайт, изготовить сайт, разработка сайта, web студия, создание веб сайта, поддержка сайта, сайт на заказ, сопровождение сайта, дизайн сайта, сайт под ключ, заказ сайта, реклама сайта, хостинг, регистрация доменов, хабаровск, краснодар, москва, комсомольск |
 
Поделиться с друзьями: