• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья Введение в Lua пишем свой NSE для Nmap

petrinh1988

X-pert
Эксперт
Регистрация
27.02.2024
Сообщения
243
Реакции
493
Автор petrinh1988
Источник https://xss.pro


Друзья, понимаю негодование старожилов по поводу статьи из серии “введение в язык программирования…”. Самого бесят подобные статьи на этом сайте, все же не мальчики-одуванчики тут собрались, а профи и стремящиеся ими стать люди. В связи с чем, мы не будем долго запрягать, пробежимся по основным концепциям и займемся общественно полезной (ну или общественно опасной) деятельностью.

В данной статье предлагаю познакомиться с Lua. Для меня это новый язык, поэтому ожидать какие-то сложные мощные концепции здесь не стоит. Скорее это анализ синтаксиса и особенностей языка в сравнении с более менее знакомыми мне. В разные периоды мне приходилось писать на JS, Python, C#, PHP, VB. И еще кучка разного рода языков, коды на которых приходилось править/расширять.

Почему Lua? Если отбросить все лишнее, меня Lua заинтересовал возможножностью писать NSE-скрипты для Nmap и не только для него. Lua легко встраивается, создавая широкие возможности для расширения приложений. Некий аналог Javascript, который предпочитают многие разработчики. В части Nmap, появляется возможность быстро и легко писать свои чекеры, фаззеры, брутфорсеры и даже эксплоиты, на основе довольно развитой инфраструктуры. Сообщество Nmap реально мощное. Плюс, существует большое количество уже написанных скриптов, которые можно использовать для основы своих или для понимания, как работает та или иная функция.

Так как в сетях я гораздо хуже, чем в веб-уязвимостях, все примеры будут ориентированы на веб. Поэтому и статья в вебе.

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

Технические штуки​

Писать мы будем для встроенного интерпретатора nmap, поэтому установка Lua в целом не обязательна. Это нужно исключительно для того, чтобы можно было запускать отдельные куски команд, чтобы посмотреть как себя ведет тот или иной код, чтобы понять логику Lua в целом.

В Linux установка по классике: скачали архив, распаковали и запустили make. В Windows 11 процедура установки еще проще. Открываем терминал и пишем:

Код:
winget install "Lua for Windows"

1733854803506.png


После этого, можно спокойно запускать интерпретатор lua через пробел указав файл который нужно запустить. Visual Studio Code из коробки поддерживает Lua:

1733854787356.png


Чтобы VS Code сразу понимал, что NSE это скрипт написанный на Lua, добавим ассоциацию. Жмем Ctrl+Shift+P, вбиваем “settings.json”. Выбираем “Open User Settings”

1733854773156.png


В открывшемся конфиге добавляем

Код:
"files.associations": {
      "*.nse": "lua"
  }

Теперь Visual Studio прекрасно понимает с чем мы работаем и подсвечивает синтаксис

1733854747151.png


Основы Lua​

Первое впечатление двоякое. Какая-то странная помесь Turbo Pascal и Python. Причем, достаточно своеобразная, так как конструкция ниже прекрасно сработает. Это при том, что никаких разделений, вроде точки с запятой, в ней нет и все написано в одну строку:

Код:
a = 5 b =2 print(a+b)

Запись ниже тоже корректна и прекрасно работает:

Код:
a = 8; b =2; print(a+b)

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

В самих NSE-скриптах можно встретить подобную конструкцию

Код:
hostrule = function() return true end

В данном случае, функция всегда будет возвращать “true”.

Переменные​

Начнем с переменных, с ними все очень просто. Объявлять переменную отдельно не нужно, достаточно присвоения и можно сразу начинать пользоваться. При этом, если случайно попытались работать с переменной, которая не была объявлена, ошибки не произойдет. Переменная, которой нет, представляет собой “nil”. Если переменная уже не нужна и важно её удалить, достаточно снова присвоить ей “nil”.

В наименовании переменных все достаточно стандартно, начинается с буквы или подчеркивания. Можно использовать локальные символы и давать названия переменным вроде “açaí”, но есть нюанс. Если этот же код запустить на системе, на которой нет соответствующего языкового пакета, все сломается.

Типы данных у переменных динамические, как в том же Python. Я бы записал это в минусы, а не плюсы языка. Если переменная может внезапно изменить тип, нужно внимательно контролировать этот процесс иначе может случиться неприятный сюрприз.

У языка есть восемь типов данных: number(число), string(строка), boolean(логический тип), nil (тип "ничего"), table,function, userdata, thread.

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

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

Код:
text = [[
    Любой текст,
    В кучу строк
    С двойными “кавычками”
    C ‘одинарными’ кавычками
]]

1733854711226.png


Упс, что-то пошло не так с кодировкой… Так будет лучше:

1733854676130.png


Длина строки! Специально выделил, так как этот функционал достаточно часто используемый. Чтобы определить длину, нужно перед переменной поставить решетку “#”. Кстати, конкатенация производится при помощи двух точек:

1733854662336.png


Наверное. Самая непривычная для меня история, это булевы значения. Ложью является только “false” и “nil”, все остальное “true”, Даже “0” это “true”. Пустая строка тоже “true”

1733854637096.png


Тип “table” это некий аналог “dict” в Python. Ну или ассоциативный массив в других языках. Причем, ассоциацией может выступать, как числа, так и литеральные значения. Поддерживаются и многомерные (вложенные) таблицы. Доступ к значениям можно получить, как через квадратные скобки, так и как к свойству через точку, если индекс это литерал.

Накидал простой пример для демонстрации работы с таблицами в Lua:

1733854613538.png


Обратите внимание, что первый индекс массива “1”, а не “0”. Такова особенность Lua. При этом, ничего не мешает создать элемент [0], но он будет не нулевым по факту. При выводе циклом, видно что элемент [0] занял третью позицию.

Доломать ваш мозг? Возьму предыдущий код, уберу все лишнее и добавлю две строчки. Первая это вставка элемента через функцию table.insert(). Вторая это конкатенация элементов таблицы через table.concat()

1733854591134.png


Что происходит с “Zero Index”? Он как бы есть в таблице, но при этом выводится последним. Даже с учетом того, что добавлялся перед третьим элементом. Плюс полностью выпал из конкатенации. Но зайдем чуть дальше ))))

Код:
function printTable(x)
    for key,value in pairs(x) do
        print('['..key.."] = " .. value)
    end
 
    print(table.concat(x, ','))
end

new_table = {1, "b"}
new_table[0] = 'Zero Index'
table.insert(new_table, 'baba')
printTable(new_table)
new_table[4] = 'Hello'
table.insert(new_table, 'World')
printTable(new_table)

1733854559226.png


Есть над чем подумать))) Кстати, таблицы можно объявлять сразу как структуры:

Код:
new_table = { name="hacker", value="1337", hello_world="101"}

1733854543489.png



Остались userdata и thread. Последнего мы касаться не будем, по крайней мере в этой статье, поэтому оставлю его без рассмотрения. Что касается userdata, это специализированные структуры созданные снаружи. В контексте nmap, можно посмотреть на переменную хост или порт, которые будем использовать постоянно.

1733854511171.png


По сути, это очень похоже на таблицу, но она не может существовать в рамках отдельного скрипта Lua. Только переменная созданная извне и имеющая четкий указатель на область в памяти. Как я понимаю, это переменная, которой можно оперировать, как из скрипта, так и из кода внешнего приложения.

Область видимости​

Все переменные, по умолчанию, являются глобальными. Насколько я понимаю, существует два пространства имен _ENV и _G. Каждая новая переменная, а по сути именованная часть памяти, помещается в одно из этих пространств имен при первом “соприкосновении” интерпретатора с ней. Без явного указания “local” при первом присваивании, переменная становится глобальной.

Зарезервированные слова:​

Поняв, как устроены переменные, предлагаю посмотреть на список зарезервированных слов:

Код:
and       break     do        else      elseif
    end       false     for       function  if
    in        local     nil       not       or
    repeat    return    then      true      until
    while

Что интересно, зарезервированные слова это далеко не все доступные “из коробки” функции. По какой-то причине, остальные доступные по умолчанию функции, можно спокойно переопределять. Ради интереса набросал простой пример:

1733854480897.png


Что произошло с самой функций tonumber останется загадкой… Полный список стандартных функций Lua можно посмотреть здесь.

Последние нюансы​

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

Руководство по яызку, говорит нам, что есть следующие операции сравнения:

Код:
<   >   <=  >=  ==  ~=

Единственный непривычный, это ~=. По факту, это “не равно”.

1733854458208.png


Само логическое ветвление доступно в виде if:

Код:
If condition then
    …
elseif condition then
    …
else
    …
end

Если нужен switch .. case, потребуется написать костыль. Нашел вот такой пример:

Код:
local value = 1

local switch = {
 [1] = function()
  print("The value is 1")
 end,
 [10] = function()
  print("The value is 10")
 end,
 ["default"] = function()
  print("Unknown value")
 end
}

if switch[value] then
   switch[value]()
else
   switch["default"]()
end

Циклов нам доступно несколько. Начнем с привычного for:

Код:
for i=0,10 do print(i) end

В данном случае, цикл выполнит 10 итераций и напечатает числа от 0 до 10. Что если нужно наоборот, пройти от 10 до 1? Просто поменять числа местами не получится — цикл снова пройдет от 0 до 10. Нужно указать отрицательный шаг:

Код:
for i=10,0,-1 do print(i) end

Если количество итераций неизвестно и нужно ориентироваться на какое-то логическое условие, можно использовать “while”:

Код:
while(condition)
do
   …
   statement(s)
   …
end

Соответственно, если нужно чтобы произошла хотя бы одна итерация цикла, можно использовать “repeat — until”

Код:
repeat
   …
   statement(s)
   …
until( condition )

Как объявлять функции, вы уже видели:

Код:
function(income_variables)
   …
   statement(s)
   …
   return value
end

Либо через переменную:

Код:
add = function(a,b) return a+b end
print(add(1,2))

Функция может принимать переменное количество аргументов. Для этого используется оператор (...) и локальная переменная arg:

Код:
function add(...)
    local result = 0
    for index,value in pairs(arg) do
        print(index, value)
        if tonumber(index) ~= nil then
            result = result + tonumber(value)
        end
    end

    return 'Result is ' .. result
end

print(add(1,2,3,4))

Проверка на то, является ли индекс числом сделана из-за последнего элемента, который имеет имя “n” и содержит в себе количество элементов таблицы arg:

1733854387139.png


Функция может возвращать несколько значений, перечисленных через запятую. При этом, их также можно получать сразу перечислив переменные тоже через запятую:

1733854370464.png


Причем, если количество элементов будет не совпадать, ничего страшного не произойдет:

Код:
a,b,c = 13,37
print(a,b,c)

В целом, данной вводной информации уже достаточно, чтобы начать писать код. Все остальное будем осваивать сталкиваясь на практике.

Знакомство с NSE​

Чтобы лучше понять скрипты, предлагаю посмотреть на уже существующие. Для этого идеально подойдет скрипт “/usr/share/nmap/scripts/auth-spoof.nse”. Он один из самых коротких, а значит содержит необходимый минимум:

1733854330944.png


Важно понять простую, которую подсмотрел в одной из презентаций по программированию NSE. Структура любого скрипта включает в себя три составляющих:

The Head - это импорты нужных библиотек и переменные описывающие скрипт.
The Rule - это правила работы с портами. Они задаются через shortport
The Action - запускающая функция, которая творит магию

The Head​

Первое, это импорты. В данном случае, подключаются две библиотеки: comm (сокращение от common) и shortport. Полный список доступных библиотек лежит здесь. Всего их 138 штук, но наиболее важные: nmap, shortport, stdnse. Мы будем практически постоянно их использовать.

После импортов идет, в целом, понятная информация о скрипте: автор, описание, лицензия, категории. Данная информация, в основном, используется при запросе справочной информации через –script-help.

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

Код:
nmap –script vuln

Который запускает все скрипты содержащие категорию “vuln”. В рассматриваемом выше скрипте, указана категория “malware”. Чтобы вышеуказанный скрипт запустился с другими скриптами из категории “malware”, запуск Nmap должен выглядеть примерно так:

Код:
nmap –script malware …

При этом, важно чтобы скрипт лежал в “/usr/share/nmap/scripts/”. Полный список категорий можно посмотреть здесь.

Комментарии (в Lua это два тире –) используются для демонстрации примера вывода информации о скрипте. В документации указано, что каждый хороший скрипт содержит в себе пример вывода. Т.е. какого-то жесткого требования для корректной работы скрипта, нет. Можно и проигнорировать.

The Rule​

Надо помнить, что nmap это сканер портов на хосте. Соответственно, все скрипты должны подключаться к открытым портам и взаимодействовать с ними. Все результаты должны ассоциироваться с конкретными портами.

Исходя из вышесказанного, под “правилами” подразумевается разрешение работать с конкретным хостом и конкретным портом. В статье будем работать именно с правилами основанными на портах. По факту, мы должны указать Nmap, надо ли работать скрипту с данным портом. В скрипте выше, это делается при помощи функции port_or_service библиотеки shortport. Эта функция возвращает true в том случае, если порт доступен или на нем доступен сервис “auth”. Именно “или”, о чем нам прямо говорит “_or_” в названии функции.

shortport предоставляет набор наиболее востребованных функций для определения правил, но альтернативой может быть собственная функция определяющая правило (код подсмотрел):

Код:
local nmap = require("nmap")

portrule = function(host, port)
    local auth_port = { number = 113, port = "tcp"}
    local identd = nmap.get_port_state(host, auth_port)

    return identd ~= nil
            and identd.state == "open"
            and port.protocol == "tcp"
            and port.state == "open"
end


В любом случае, по сути, правило это “true” или “false”. Даже если portrule содержит в себе функцию, она будет выполнена и на выходе останется только чистая логика. Соответственно, исходя из состояния переменной “portrule”, Nmap запустит функцию “action” или не запустит.

Что касается остальных тезисов, открывших этот подраздел… Nmap работает с портом. Чтобы мы не пытались сделать, вся суть сводится к отправке каких-то полезных данных на конкретный порт и получению ответа. Даже если мы будем искать SQLi через Nmap, мы отправим http-запрос на порт ассоциированный с сервисом обрабатывающим HTTP. Полученный на выходе результат, мы отдаем обратно Nmap и он выводит его, привязав его к порту.

hostrule​

Не упомянуть о правилах связанных с хостом, было бы неправильно. Наравне с portrule, переменная hostrule может разрешить или запретить работать с конкретным хостом. Если мы не указываем эту переменную, nmap считает её true и, соответственно, хост разрешенным к работе. Так как я не затрагиваю в статье это правило, просто приведу несколько примеров из дефолтных скриптов nmap:

Код:
---
-- This script will run for any non-private IP address.

hostrule = function( host )
  return not ipOps.isPrivate( host.ip )
end

hostrule = function(host)
  return host.registry.datetime_skew and #host.registry.datetime_skew > 0
end

Вот интересный пример из обхода файервола. Интересен он тем, что проверяет права с которыми запущен nmap. Если мы не привилегированный пользователь (root или sudo), мы идем нафиг:

Код:
hostrule = function(host)
  helper = stdnse.get_script_args(SCRIPT_NAME .. ".helper")

  if not nmap.is_privileged() then
    nmap.registry[SCRIPT_NAME] = nmap.registry[SCRIPT_NAME] or {}
    if not nmap.registry[SCRIPT_NAME].rootfail then
      stdnse.verbose1("lacks privileges." )
      nmap.registry[SCRIPT_NAME].rootfail = true
    end
    return false
  end

  if not host.interface then
    return false
  end

  if helper and not helpers[helper] then
    stdnse.debug1("%s helper not supported at the moment.", helper)
    return false
  end

  return true
end

The Action​

Как понятно из названия, это основное действие, которое запустит Nmap, если позволяет правило. По факту, мы присваиваем глобальной переменной “action” функцию, которая принимает два параметра: host и port. На выходе мы должны отдать то, что хотим показать пользователю. Если показывать нечего, то и функция должна вернуть пустоту.

Для примера напишу простой скрипт, который будет просто выводить бессмысленную надпись если на порту работает HTTP-сервер:

Код:
--The Head
local shortport = require "shortport"

discription = [[
    Test NSE
]]

---
-- @usage
-- nmap <target> --script=test
--
-- @output
-- 80/tcp open http
-- | Halabula:
-- |_ result: values...
---

author = "petrihn1998"
categories = {"safe"}

--The Rule
portrule = shortport.http

--The Action
action = function(host, port)
    local test_output = '101'
    return test_output
end

Для определения, надо ли выполнять скрипт, будет использоваться функция http. Что она делает, в целом понятно из названия — определяет, отвечает ли нам веб-сервер. Если есть, то просто выводим “101”, вернув эту строку в функции action. Выполню два запуска с разными портами:

1733854232818.png

1733854204992.png


Поздравляю! Первый скрипт написан и прекрасно работает. Но что если на выходе должна быть не одна строка, а набор данных? Поправим скрипт, заодно познакомимся с библиотекой http. Импортируем её и перепишем экшен:

Код:
...
local http = require "http"
...
action = function(host, port)
    local path = '/'
    local response = http.get(host, port, path)

    return response.rawheader
end

1733854180974.png


Мы просто выполнили обычный GET-запрос при помощи библиотеки http. Обратно вернули полученные заголовки. rawheader - это просто пронумерованный список заголовков в том порядке и виде, в котором его вернул сервер. Альтернативой rawheader, выступает header. Разница в том, что header это ассоциативный массив. Соответственно, если мы ищем какой-то специфичный заголовок, удобнее использовать ассоциативный массив и функцию чека наподобие такой:

Код:
function tableHasKey(table,key)
    return table[key] ~= nil
end

Судя по всему, раньше action должна была возвращать именно строку. Чтобы вернуть результат в виде таблицы (массива значений) использовалась функция format_output библиотеки stdnse. Эта функция помечена, как deprecated и можно просто возвращать таблицу. Nmap научился прекрасно справляться с разными форматами вывода. Но, если вы используете format_output, то ничего страшного не произойдет, все прекрасно отработает. Значит, хоть на каком-то уровне есть обратная совместимость. Если вам попался какой-то древний скрипт, высока вероятность, что он запустится.

Код:
...
local stdnse = require "stdnse"
...
action = function(host, port)
    local path = '/'
    local response = http.get(host, port, path)

    return stdnse.format_output(true, response.rawheader)
end

1733854152999.png


Рекомендую покопаться в описании к библиотеке. Вот пример, если мы хотим следить за статусом http-ответа:

Код:
if response.status ~= 200 then
        return response.status
    end

Как видно из кода выше, никаких неожиданностей в http-запросе нет. Не сложнее, чем работать с response в Python или fetch в Javascript. Но что, если нам нужно сделать что-то специфическое? Например, указать собственный заголовок. Для этого, у http.get() есть четвертый параметр “options”. В нем можно просто передать таблицу с параметрами. Например, можно ограничить таймаут, отключить редиректы или указать хидер:

Код:
action = function(host, port)
    local path = '/'
    local options = {header={X-Forwarded-For='127.0.0.1'}, timeout=5000}
    local response = http.get(host, port, path, options)

    return response

end

1733854127252.png


Как видно, ничего сложного. Кстати, обратили внимание, что в последнем примере я вернул полностью объект “response” и nmap его прекрасно распечатал?

Прежде чем идти дальше, предлагаю подробнее взглянуть на переменные host и port, так как они представляют собой не просто значения, а структурированные данные. Для вывода информации у NSE есть специальные возможности, но раз nmap спокойно распечатывает всю информацию, почему бы не поступить просто и тупо не вернуть переменные в виде результата action?

1733854108646.png


Это информация о хосте, которую отдает nmap в функцию. А это уже port:

1733854090472.png


Для сравнения, вывод если использовать не ip, а hostname:

1733854058308.png


1733854028433.png


Поиск реального IP​

Те, кто следит за моими статьями, уже видели реализацию через расширение для браузера. Фишка этой задачи в том, что она достаточно удобна для обучения. При всей тривиальности, она позволит понять нам как работает Nmap в целом.

Снова скажем спасибо c0d3x за отличную инструкцию и начнем реализовывать её часть. Часть по той причине, что у меня нет адекватного способа автоматически получить все необходимые данные. Нам нужно вытащить историю IP-адресов, отсеять всякие клоудфлары, получить подсети и просканировать их. Рационально, в полностью автоматиеском режиме, мы можем выполнить только последнюю часть.

Как вариант, можно было бы заморочиться и прикрутить работу с сервисами через API. Но тогда, нам пришлось бы вступить в конфликт с многопоточностью nmap. У nmap свой механизм управления параллельными потоками. Если он может наращивать их, он их наращивает. В какой-то момент, если начинаются проблемы с получением ответов, nmap сокращает эти потоки. И тут мы пытаемся впихнуть свой функционал, который предполагает предварительное получение данных и продолжительную последующую работу с полученными подсетями. Да, в библиотеке stdnse есть метод для создания новых потоков (new_thread), но все же его стоит применять не в таких масштабах.

Nmap прекрасно работает с подсетями, поэтому составит свои списки IP-адресов, после его каждый IP проверит на соответствие portrule и передаст на чек в action нашего NSE-скрипта.

1733854001758.png


Поэтому, принцип работы будет такой:

  1. Пользователь добывает данные по подсетям и сохраняет в файл
  2. Запускает nmap, указав наш скрип и аргументы скрипта: хост и искомый текст
  3. Скрипт чекает хост, если успех возвращает информацию о результате запроса

При этом подходе, обработка каждого отдельного IP вписывается в общий механизм работы Nmap и не нарушает никакой логики.

Для начала вспомним, как запускать Nmap с NSE-скриптами и передавать в него аргументы. Для этого есть два варианта: прописать аргументы строкой или передать их в файле. Пример передачи параметров через строку:

Код:
nmap portswigger.net -p80 --script=get-real-ip --script-args="host=portswigger.net, search_text=Best-in-class software and learning for"

Пример файла с аргументами:

Код:
host=portswigger.net
search_text=Best-in-class software and learning for

Запуск nmap с указанием файла с аргументами скрипта:
Код:
nmap portswigger.net -p80 --script=get-real-ip --script-args-file /home/user/ip.info.txt

Начнем с импортов. Базово скрипт очень похож на то, что писали выше, когда выполняли http.get(). Но потребуется еще две библиотеки.

Код:
local shortport = require "shortport"
local nmap = require "nmap"
local http = require "http"
local string = require "string"

Nmap поможет получить переданные скрипту аргументы. Не важно каким способом это сделано, строкой или через файл. В библиотеке string, как понятно из названия, все необходимое для работы со строками. В нашем случае, потребуется метод match для поиска вхождения подстроки в полученном ответе.

Код:
action = function (host, port)
    local hostname = nmap.registry.args.host
    local search_text = nmap.registry.args.search_text

    if type(hostname) ~= "string" then return "Invalid host" end
    if type(search_text) ~= "string" then return "Invalid search text" end

    local path = "/"
    local options = {header = {Host = hostname}, timeout=5000}
    local response = http.get(host, port, path, options)
 
    if response.status ~= 200 then
        return response["status-line"]
    end

    if string.match(response.rawbody, search_text) then
        return "Correct IP and port. Searched text found"
    else
        return "Potencial correct IP and port. Searched text NOT found"
    end
end

В данном случае, мы не заморачиваемся по поводу SSL и прочих нюансов. Нам даже не придется делать отдельных запросов для http и https. Мы даже можем не указывать конкретные порты, а использовать “-p-”, тогда сможем найти сервер на каком бы порте не висел web-сервер. Главное, чтобы nmap смог понять, что мы имеем дело с вебом.

Попробуем запустить и посмотреть на результат:

1733853914640.png


Работает. Ура) Можно спокойно подгрузить список подсетей, дать имя хоста и искомый текст, после чего получить расклад по всем айпи входящим в диапазоны.

Не обязательно реализовывать работу через http. Можно пойти другим путем, используя библиотеку comm и её функции opencon() и exchange(). Эти функции позволяют открыть соединение и отправить данные.

Information disclosure vulnerabilities​

Напишем скрипт, который ищет чувствительные файлы на сервере. Обожаю эту уязвимость, которая встречалась, встречается и будет встречаться. Забытые архивы, конфиги, дубликаты php-файлов с неинтерпретируемыми расширениями php.old, .ph_ и т.п.

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

Код:
local stdnse = require "stdnse"
local io = require "io"
local http = require "http"
local shortport = require "shortport"
local string = require "string"
local table = require "table"

description = [[
    Finde forgotten and backup files.
]]

author = "petrihn1988@xss.pro"

license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

categories = {"discovery"}

portrule = shortport.http

action = function(host, port)
    local output = stdnse.output_table()
    local wordlist,hostname,size = stdnse.get_script_args("w","host","bytes")
    local f = io.open(wordlist, "r")

    if (not(f)) then
        return "Wrong wordlist file"
    end

    found = {}

    while true do
        local line = f:read("*l")
        if (not(line)) then break end

        line = string.gsub(line, '%c', '')

        local path = "/" .. line
        stdnse.debug1("Path %s ", path)
    
        if (not(size)) then size = 1024 end

        local options = {header = {Range="bytes=0-" .. tostring(size)}}

        if hostname ~= nil then
            options.header.host = hostname
        end

        local response = http.get(host, port, path, options)

        if response.status == 206 then
            table.insert(found, string.format("Found file: %s Status: %d Content-Type: %s Length: %d", line, response.status), response.header['content-type'], #response.rawbody)
        end

    end
    f:close()
 
    stdnse.debug1("Found files count %d ", #found)

    if #found == 0 then
        return "Sorry... Forgotten files not found"
    end
    output['Found forgotten files'] = found
 
    return output
end

Первое новшество, это то как я получаю аргументы скрипта. Если раньше это было:

Код:
local hostname = nmap.registry.args.host

В этот раз использовал:

Код:
local wordlist = stdnse.get_script_args("w")

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

Код:
local wordlist,piska,popa,haker = stdnse.get_script_args("w","param2","param3", "1337")

Работа с файлами очень похожа на работу с файлами в Python или куче других языков программирования.

local f = io.open(wordlist, "r")
...
local line = f:read("*l")
...
f:close()

Открыли файл для чтения, далее в цикле читаем его построчно. Есть альтернатива, можно вместо “*l” указать “*a” и прочитать сразу весь файл. Кстати, можно использовать конкретное количество строк, а также полные названия литералов “*line” и “*all”. Подробнее можно почитать в справке по io Lua.

Что касается двоеточия, это способ вызывать методы класса. Когда мы открыли файл, в f получили объект файла. Раз f это объект, значит с ним нужно работать как с объектом. Я не затрагивал работу с классами, так как не вижу смысла в рамках статьи погружаться в ООП Lua. Мы задеваем его по минимуму. Просто для примера приведу код, который создает класс Parallelogram:

Код:
Parallelogram = {a = 0, b = 0, angel = 0}

function Parallelogram:new(obj)
    obj.parent = self
    return obj
end

p = Parallelogram:new{10,20, 45}

Вернемся к нашему коду. Еще одно “новшество”, вместо конструкции “~= nil”, использовал другой тип проверки на отсутствие значения:

Код:
if (not(line)) then break end

Функция gsub библиотеки string, заменяет значение в строке. Нам это нужно для того, чтобы избавиться от “\x0D”. При чтении из файла, мы получаем строки вместе со специальными символами. В искомых параметрах указано ‘%c’, что означает любой спецсимвол. Подобные текстовые шаблоны можно посмотреть здесь

Код:
line = string.gsub(line, '%c', '')

Для демонстрации оставил в коде вывод информации в дебагер

Код:
stdnse.debug1("Path %s ", path)

Чтобы информация начала выводиться, nmap нужно запустить с параметром “-d”:

1733853827022.png


Осталось выполнить запрос и обработать ответ:

Код:
if (not(size)) then size = 1024 end

        local options = {header = {Range="bytes=0-" .. tostring(size)}}

        if hostname ~= nil then
            options.header.host = hostname
        end

        local response = http.get(host, port, path, options)

        if response.status == 206 then
            table.insert(found, string.format("Found file: %s Status: %d Content-Type: %s Length: %d", line, response.status), response.header['content-type'], #response.rawbody)
        end


Мы получаем 1024 байта. Этого более чем достаточно, чтобы понять, то ли получили или не то. Если нам отдали какой-то файл, мы получим статус 206 (часть контента). Любой другой статус нам не интересен. Что это за архив размером менее килобайта?

Вывод информации выполнен через string.format. Вывожу информацию не только о коде ответа, но и тип контента. Если мы ищем архив, нам подойдет достаточно ограниченный набор вариантов. Соответственно, на этапе проверки результатов можем выбрать только подходящие нам. Тоже с длинной ответа.

Ищем Command Injection​

Поработаем немного с лабами, чтобы как-то разнообразить наши примеры и охватить, как можно большее количество нужных технологий. В данном случае, будем работать с этой лабой.Все, что нам нужно, это перехватить POST-запрос и добавить к переменной команду через “|” для объединения команд. Сервер выполнит “stockreport.sh” и нашу команду. Но есть нюанс… если Burp позволяет нам перехватить запрос, то у nmap нет такого функционала. Соответственно, нужно найти все формы отправляющие POST-запросы, распарсить их и выполнить запросы доступными nmap методами. Если точнее, то через тот же http.get(). После остается только чекнуть ответ и вывести инфу об уязвимости, если она есть.

Нас интересует эта форма:

1733853803107.png


Что приятно, форма четко описана. Есть метод, кнопка представляет собой кнопку с типом “submit”, элементы ввода тоже в порядке. Но как добраться до формы в боевых условиях? Познакомимся с пауком. Для этого потребуется подключить библиотеку "httpspider". Напишем простой сборщик форм. Наш паук должен обойти все доступные ссылки в рамках хоста и получить формы.

Код:
local http = require "http"
local httpspider = require "httpspider"
local shortport = require "shortport"
local stdnse = require "stdnse"
local table = require "table"

description = [[
    Command Injection test
]]

author = "petrihn1986"

license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"discovery"}

portrule = shortport.port_or_service({80, 443}, {"http","https"})

action = function(host, port)
    local crawler = httpspider.Crawler:new(host, port, "/", {scriptname = SCRIPT_NAME})
    crawler:set_timeout(5000)
    local result = stdnse.output_table()

    result['Founded forms'] = {}
    while(true) do
        local status, response = crawler:crawl()

        if (not (status)) then
            if (response.err) then
                return stdnse.format_output(false, response.reason)
            else
                break
            end
        end

        table.insert(result['Founded forms'], response.url)
        if response.response and response.response.body and response.response.status == 200 then
        -- Parse forms
        end

    end
    return result
end

Это базовая структура нашего скрипта. В данном случае, для определения portrule использую port_or_service. Нас интересует, либо порты 80 и 443, либо сервис связанный с htps или https.
Код:
local crawler = httpspider.Crawler:new(host, port, "/", {scriptname = SCRIPT_NAME})
crawler:set_timeout(5000)

Сначала нужно создать объект паука и задать свойства. Стандартно для nmap, передаем хост, порт и путь. Из нового, константа SCRIPT_NAME. Хотя, учитывая особенности Lua, это скорее предопределенная переменная, а не константа.

Когда паук готов, запускаем бесконечный цикл. Каждую итерацию, выполняем запрос методом crawl(). Далее проверяем ответ. Если все плохо, выходим. Если все хорошо, у нас есть ответ, у ответа есть тело и статус ответа 200, значит у нас есть все необходимое для поиска форм. Сделаем тестовый запуск и посмотрим, какие пути соберет паук

1733853758351.png


Супер! Можно переходить к парсингу форм. Что приятно, процесс получения форм очень даже простой:

Код:
if response.response and response.response.body and response.response.status == 200 then
            local all_forms = http.grab_forms(response.response.body)
            for _,form_obj in pairs(all_forms) do
                local form = http.parse_form(form_obj)
                local form_path = response.url.path
                local form_to_insert = {form = form, path = form_path}
                table.insert(result['Founded forms'], form_to_insert)
            end
        end

При помощи grab_forms() получаем все формы на полученной странице. После обходим каждую форму в отдельности и парсим отдельные элементы формы На выходе у нас очень крутой объект:

1733853735573.png


Когда я впервые увидел этот объект, я безумно обрадовался. Осталось только сформировать из этих данных запрос, выполнить его и проанализировать ответ. Но разве может все пойти идеально? Конечно нет! Tсли внимательно присмотреться, становится видна проблема…

1733853722774.png


Парсер форм тупо не увидел селекта и доступных значений. Попытка выполнить запрос без указания переменной, приведет к следующей ошибке:

1733853707284.png


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

Код:
local selects_html = form_html:match("<select.*select>")

На выходе получаем ожидаемый текст:

HTML:
<select name="storeId">
<option value="1" >London</option>
<option value="2" >Paris</option>
<option value="3" >Milan</option>
</select>

Нам нужно вытащить имя и значения опций. Нужно написать подходящие шаблоны для матча. Вообще, шаблонам можно посвятить отдельную строку. Это некий аналог регулярных выражений в Lua, который имеет свою логику. Статьи отдельной не получится, так как я далеко не разобрался с шаблонами. Исключительно путем безумных стараний пришел вот к такому способу вычленить name:

local select_name = selects_html:match('name=[\'"](%w+)[\'"]')

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

Код:
while(true) do
        local option_id = selects_html:match('value=[\'"](%w+)[\'"]', pos)
        if (not(option_id)) then break end
        table.insert(values, option_id)
        _, pos = selects_html:find('value=[\'"](%w+)[\'"]', pos)
        if (not(pos)) then break end
    end

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

Полный код функции парсинга выглядит следующим образом:

Код:
function parse_html_form(form_html)
    local form = http.parse_form(form_html)
    local patt = http.tag_pattern("select", true)
    local selects_html = form_html:match("<select.*select>")             
    local select_name = selects_html:match('name=[\'"](%w+)[\'"]')
    local _,pos = 0,0
    local values = {}

    while(true) do
        local option_id = selects_html:match('value=[\'"](%w+)[\'"]', pos)
        if (not(option_id)) then break end
        table.insert(values, option_id)
        _, pos = selects_html:find('value=[\'"](%w+)[\'"]', pos)
        if (not(pos)) then break end
    end
 
    table.insert(form["fields"], {name=select_name, type='select', values=values, value=values[1]})
    return form
end

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

Код:
function check_form_ci(form, host, port, path)
    local vulnerable_fields = {}

    for _, test_field in pairs(form["fields"]) do
        local data = {}
        for _,field in pairs(form["fields"]) do
            if field["name"] == test_field["name"] then
                data[field["name"]] = field["value"] .. '|id'
            else
                data[field["name"]] = field["value"]
            end
        end
        local response = http.post(host, port, form["action"], nil, nil, data)
        if response and response.body then
            if response.body:match('uid=%d+.*gid=%d+.*groups=%d+') then
                table.insert(vulnerable_fields, 'Vulnerability field ' .. test_field["name"] .. ' Path: ' .. path)
            end
        end
    end

    return vulnerable_fields
end

Циклом проходим по всем полям. Каждую итерацию формируем запрос, после чекаем результат на интересующую нас строку. Как тестовую команду выбрал “id”, так как её вывод шаблонный и предсказуемый. Можно было бы взять, например, “ls”. Но тогда пришлось бы делать предзапрос, который был бы эталоном длины ответа и сравнивать с ним результаты отправки пэйлоада. В этом случае, даже если сервер возвращал бы нам сообщение об ошибке (спалил нас), у нас все равно была бы полезная для пользователя информация и это уже было бы продвижение в проникновении.

Осталось внести небольшие изменения в главный цикл и все готово:

Код:
if response.response and response.response.body and response.response.status == 200 then
            local all_forms = http.grab_forms(response.response.body)
            for _,form_html in pairs(all_forms) do
                local form = parse_html_form(form_html)
                local result_check = check_form_ci(form, host, port, response.url.path)
                table.insert(result['Founded'], result_check)             
            end
        end

1733853552630.png


Да, вывод дублируется, так как каждая карточка товара для nmap есть отдельная форма. Это можно исправить, добавив проверку на существование вхождения.

Про библиотеки​

Сами библиотеки лежат в "/usr/share/nmap/nselib”. Так как они написаны на Lua, их код открыт. Это к тому, что всегда можно посмотреть как оно работает. Зачастую, может быть непонятно описание той или иной функции, принимаемых аргументов или то, как будет выглядеть результат.

1733853494858.png


Если вы внимательно посмотрите на использованные нами библиотеки и список выше, вы заметите одну странную деталь - некоторых библиотек нет в списке выше. Если точнее: string, io, table. Все дело в том, что это стандартные библиотеки Lua. Учитывая, что он встроен в nmap, нам нужно прямо указать на необходимость использовать их.

Заключение​

Давно уже хотел написать несколько своих скриптов под Nmap. Оказалось, что эта задача не такая уж и сложная. Язык Lua имеет свои особенности, которые еще предстоит понять. Но в целом, чтобы начать писать свои NSE не нужно чего-то сверхъестественного. Главное запомнить структуру скрипта: The Head, The Rules, The Actions. Тем более, статья должна бы сэкономить вам от нескольких дней до недели времени.

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

Надеюсь статья была полезна. Я принципиально оставил всю хронологию собственного погружения в Lua и NSE. Следующую статью планирую по написанию своих скриптов для Metasploit.
 

Вложения

  • nse-introduction.zip
    3.2 КБ · Просмотры: 10
Последнее редактирование:


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