от John Graham-Cumming

Миналия петък Tavis Ormandy от Project Zero на Google се свърза с Cloudflare, за да съобщи за проблем със сигурността с нашите периферни сървъри. Той виждаше повредени уеб страници, върнати от някои HTTP заявки, изпълнявани през Cloudflare.

Оказа се, че при някои необичайни обстоятелства, които ще опиша подробно по-долу, нашите периферни сървъри са работили след края на буфер и са връщали памет, която съдържа лична информация като HTTP бисквитки, токени за удостоверяване, HTTP POST тела и други чувствителни данни . И някои от тези данни бяха кеширани от търсачките.

За избягване на съмнение, личните SSL ключове на клиенти на Cloudflare не са изтекли. Cloudflare винаги е прекъсвал SSL връзки чрез изолиран екземпляр на NGINX, който не е бил засегнат от тази грешка.

Бързо идентифицирахме проблема и изключихме три незначителни функции на Cloudflare („скриване на имейли“, „Изключвания от страна на сървъра“ и „Автоматични пренаписи на HTTPS“), които използваха една и съща верига за анализатор на HTML, която причиняваше изтичането. В този момент вече не беше възможно паметта да бъде върната в HTTP отговор.

Поради сериозността на такъв бъг, многофункционален екип от софтуерно инженерство, информационна сигурност и операции, сформиран в Сан Франциско и Лондон, за да разбере напълно основната причина, да разбере ефекта от изтичането на памет и да работи с Google и други търсачките за премахване на всички кеширани HTTP отговори.

Наличието на глобален екип означава, че на интервали от 12 часа работата се предава между офисите, което позволява на персонала да работи по проблема 24 часа на ден. Екипът работи непрекъснато, за да гарантира, че този бъг и последствията от него са напълно отстранени. Едно от предимствата на това да си услуга е, че грешките могат да преминат от докладвани до коригирани за минути до часове, вместо за месеци. Стандартното за индустрията време, позволено за внедряване на корекция за грешка като тази, обикновено е три месеца; бяхме напълно готови в световен мащаб за по-малко от 7 часа с първоначално смекчаване за 47 минути.

Грешката беше сериозна, тъй като изтеклата памет можеше да съдържа лична информация и защото беше кеширана от търсачките. Също така не сме открили никакви доказателства за злонамерени подвизи на бъга или други съобщения за неговото съществуване.

Най-големият период на въздействие беше от 13 февруари до 18 февруари с около 1 на всеки 3 300 000 HTTP заявки през Cloudflare, потенциално водещи до изтичане на памет (това е около 0,00003% от заявките).

Благодарни сме, че беше намерено от един от най-добрите световни екипи за изследване на сигурността и ни беше докладвано.

Тази публикация в блога е доста дълга, но, както е нашата традиция, ние предпочитаме да бъдем открити и технически подробни относно проблемите, които възникват с нашата услуга.

Разбор и модифициране на HTML в движение

Много от услугите на Cloudflare разчитат на анализиране и модифициране на HTML страници, докато преминават през нашите крайни сървъри. Например, можем да „вмъкнем“ маркера на Google Analytics, безопасно да пренапишем http:// връзки към „https://“, да изключим части от страница от лоши ботове, да объркаме имейл адреси, да активираме „AMP“ и много повече, като променим HTML на страница.

За да променим страницата, трябва да прочетем и анализираме HTML, за да намерим елементи, които трябва да бъдат променени. От най-ранните дни на Cloudflare ние използвахме анализатор, написан с помощта на Ragel. Един .rl файл съдържа HTML анализатор, използван за всички HTML модификации в движение, които Cloudflare извършва.

Преди около година решихме, че базираният на Ragel анализатор е станал твърде сложен за поддръжка и започнахме да пишем нов анализатор, наречен cf-html, който да го замени. Този парсер за стрийминг работи правилно с HTML5 и е много, много по-бърз и лесен за поддръжка.

Първо използвахме този нов анализатор за функцията Автоматично пренаписване на HTTP и бавно мигрирахме функционалност, която използва стария анализатор на Ragel към cf-html.

Както cf-html, така и старият анализатор на Ragel са внедрени като NGINX модули, компилирани в нашите компилации на NGINX. Тези филтърни модули на NGINX анализират буфери (блокове памет), съдържащи HTML отговори, правят необходимите модификации и предават буферите към следващия филтър.

За избягване на съмнение: грешката не е в самия Ragel. Той е в използването на Ragel от Cloudflare. Това е наша грешка, а не по вина на Ragel.

Оказа се, че основната грешка, която е причинила изтичането на памет, е присъствала в нашия базиран на Ragel парсер от много години, но не е изтекла памет поради начина, по който са използвани вътрешните буфери на NGINX. Въвеждането на cf-html леко промени буферирането, което позволи изтичането, въпреки че нямаше проблеми в самия cf-html.

След като разбрахме, че грешката е причинена от активирането на cf-html (но преди да разберем защо), ние деактивирахме трите функции, които причиниха използването му. Всяка функция, доставяна от Cloudflare, има съответен „флаг на функция“, който наричаме „глобално убийство“. Активирахме глобалното унищожаване на имейл обфускацията 47 минути след получаване на подробности за проблема и глобалното унищожаване на автоматичното пренаписване на HTTPS 3 часа и 5 минути по-късно. Функцията за прикриване на имейли беше променена на 13 февруари и беше основната причина за изтичането на памет, като по този начин нейното деактивиране бързо спря почти всички течове на памет.

В рамките на няколко секунди тези функции бяха деактивирани в целия свят. Потвърдихме, че не виждаме изтичане на памет чрез тестови URI адреси и накарахме Google да провери отново дали са видели същото.

След това открихме, че трета функция, Server-Side Excludes, също е уязвима и няма превключвател за глобално унищожаване (беше толкова стара, че предшестваше внедряването на глобални убивания). Внедрихме глобално премахване на изключвания от страна на сървъра и разположихме кръпка в нашия флот по целия свят. От осъзнаването, че изключванията от страната на сървъра са проблем до внедряването на корекция отне приблизително три часа. Изключенията от страна на сървъра обаче се използват рядко и се активират само за злонамерени IP адреси.

Основната причина за грешката

Кодът на Ragel се преобразува в генериран C код, който след това се компилира. C кодът използва, по класическия C начин, указатели към HTML документа, който се анализира, а самият Ragel дава на потребителя голям контрол върху движението на тези указатели. Основният бъг възниква поради грешка в указателя.

/* generated code */ if ( ++p == pe ) goto _test_eof;

Основната причина за грешката беше, че достигането до края на буфера беше проверено с помощта на оператора за равенство и указателят успя да премине края на буфера. Това е известно като препълване на буфера. Ако проверката беше извършена с ›= вместо ==, прескачането на края на буфера щеше да бъде уловено. Проверката за равенство се генерира автоматично от Ragel и не беше част от кода, който написахме. Това показва, че не използваме Ragel правилно.

Кодът на Ragel, който написахме, съдържаше грешка, която накара показалеца да прескочи края на буфера и да премине през способността на проверка за равенство, за да забележи препълването на буфера.

Ето част от кода на Ragel, използван за използване на атрибут в HTML <script> таг. Първият ред казва, че трябва да се опита да намери нула или още unquoted_attr_char, последвано от (това е операторът за конкатенация :››) празно пространство, наклонена черта или след това › означаващо края на етикета.

script_consume_attr := ((unquoted_attr_char)* :>> (space|'/'|'>')) >{ ddctx("script consume_attr"); } @{ fhold; fgoto script_tag_parse; } $lerr{ dd("script consume_attr failed"); fgoto script_consume_attr; };

Ако даден атрибут е добре оформен, анализаторът на Ragel се премества към кода вътре в блока @{ }. Ако атрибутът не успее да анализира (което е началото на грешката, която обсъждаме днес), тогава се използва блокът $lerr{ }.

Например, при определени обстоятелства (подробно описани по-долу), ако уеб страницата завършва с повреден HTML таг като този:

<script type=

блокът $lerr{ } ще се използва и буферът ще бъде препълнен. В този случай $lerr прави dd(“script consume_attr failed”); (това е оператор за регистриране на грешки, който е nop в производството) и след това прави fgoto script_consume_attr; (състоянието преминава към script_consume_attr за анализиране на следващия атрибут).
От нашата статистика изглежда, че такива повредени тагове в края на HTML се срещат на около 0,06% от уебсайтовете.

Ако имате набито око, може би сте забелязали, че преходът @{ } също направи fgoto, но точно преди това направи fhold, а блокът $lerr{ } не. Това е липсващият fhold, който доведе до изтичане на памет.

Вътрешно генерираният C код има указател с име p, който сочи към знака, който се изследва в HTML документа. fhold е еквивалентен на p-- и е от съществено значение, защото когато възникне условието за грешка, p ще сочи към знака, който е причинил неуспех на script_consume_attr.

И това е двойно важно, защото ако това състояние на грешка възникне в края на буфера, съдържащ HTML документа, тогава p ще бъде след края на документа (p ще бъде pe + 1 вътрешно) и последваща проверка дали краят на буфера е достигнат ще се провали и p ще работи извън буфера.

Добавянето на fhold към манипулатора на грешки коригира проблема.

Защо сега

Това обяснява как показалецът може да премине през края на буфера, но не и защо проблемът внезапно се е проявил. В края на краищата този код беше в производство и беше стабилен от години.

Връщайки се към дефиницията script_consume_attr по-горе:

script_consume_attr := ((unquoted_attr_char)* :>> (space|'/'|'>')) >{ ddctx("script consume_attr"); } @{ fhold; fgoto script_tag_parse; } $lerr{ dd("script consume_attr failed"); fgoto script_consume_attr; };

Какво се случва, когато анализаторът изчерпи символите за анализиране, докато консумира атрибут, се различава дали буферът, който в момента се анализира, е последният буфер или не. Ако това не е последният буфер, тогава няма нужда да използвате $lerr, тъй като анализаторът не знае дали е възникнала грешка или не, тъй като останалата част от атрибута може да е в следващия буфер.

Но ако това е последният буфер, тогава $lerr се изпълнява. Ето как кодът в крайна сметка прескача края на файла и преминава през паметта.

Входната точка за функцията за анализиране е ngx_http_email_parse_email (името е историческо, прави много повече от анализиране на имейл).

ngx_int_t ngx_http_email_parse_email(ngx_http_request_t *r, ngx_http_email_ctx_t *ctx) { u_char *p = ctx->pos; u_char *pe = ctx->buf->last; u_char *eof = ctx->buf->last_buf ? pe : NULL;

Можете да видите, че p сочи към първия знак в буфера, pe към знака след края на буфера и eof е зададено на pe, ако това е последният буфер във веригата (указан от last_buf boolean), в противен случай е НУЛА.

Когато старият и новият парсер присъстват по време на обработката на заявка, буфер като този ще бъде предаден на функцията по-горе:

(gdb) p *in->buf $8 = { pos = 0x558a2f58be30 "<script type=\"", last = 0x558a2f58be3e "", [...] last_buf = 1, [...] }

Тук има данни и last_buf е 1. Когато новият анализатор не присъства, крайният буфер, който съдържа данни, изглежда така:

(gdb) p *in->buf $6 = { pos = 0x558a238e94f7 "<script type=\"", last = 0x558a238e9504 "", [...] last_buf = 0, [...] }

Последен празен буфер (pos и last както NULL, така и last_buf = 1) ще последва този буфер, но ngx_http_email_parse_email не се извиква, ако буферът е празен.

Така че, в случай, когато присъства само старият анализатор, крайният буфер, който съдържа данни, има last_buf зададено на 0. Това означава, че eof ще бъде NULL. Сега, когато се опитвате да обработите script_consume_attr с незавършен етикет в края на буфера, $lerr няма да бъде изпълнен, защото анализаторът смята (поради last_buf), че може да има повече данни.

Ситуацията е различна, когато присъстват и двата анализатора. last_buf е 1, eof е зададено на pe и кодът $lerr се изпълнява. Ето генерирания код за него:

/* #line 877 "ngx_http_email_filter_parser.rl" */ { dd("script consume_attr failed"); {goto st1266;} } goto st0; [...] st1266: if ( ++p == pe ) goto _test_eof1266;

Анализаторът изчерпва знаците, докато се опитва да изпълни script_consume_attr и p ще бъде pe, когато това се случи. Тъй като няма fhold (това би направило p--), когато кодът скача до st1266 p се увеличава и вече е над pe.

След това няма да скочи до _test_eof1266 (където би била извършена EOF проверка) и ще продължи след края на буфера, опитвайки се да анализира HTML документа.

И така, грешката беше латентна от години, докато вътрешният фън шуй на буферите, предавани между филтърните модули на NGINX, се промени с въвеждането на cf-html.

Отивам на лов за буболечки

Изследванията на IBM през 1960-те и 1970-те години показаха, че бъговете са склонни да се групират в това, което стана известно като „податливи на грешки модули“. Тъй като идентифицирахме неприятно превишаване на указателя в кода, генериран от Ragel, беше разумно да отидем на лов за други грешки.

Част от екипа на infosec започна да „размива“ генерирания код, за да търси други възможни превишавания на указателя. Друг екип създаде тестови случаи от неправилно формирани уеб страници, намерени в дивата природа. Екип от софтуерни инженери започна ръчна проверка на генерирания код, търсейки проблеми.

В този момент беше решено да се добавят изрични проверки на указателя към всеки достъп до указател в генерирания код, за да се предотвратят бъдещи проблеми и да се регистрират всички грешки, наблюдавани в природата. Генерираните грешки бяха подавани към нашата глобална инфраструктура за регистриране на грешки за анализ и тенденции.

#define SAFE_CHAR ({\ if (!__builtin_expect(p < pe, 1)) {\ ngx_log_error(NGX_LOG_CRIT, r->connection->log, 0, "email filter tried to access char past EOF");\ RESET();\ output_flat_saved(r, ctx);\ BUF_STATE(output);\ return NGX_ERROR;\ }\ *p;\ })

И започнахме да виждаме редове в журнала като този:

2017/02/19 13:47:34 [crit] 27558#0: *2 email filter tried to access char past EOF while sending response to client, client: 127.0.0.1, server: localhost, request: "GET /malformed-test.html HTTP/1.1”

Всеки регистрационен ред показва HTTP заявка, която може да е изтекла частна памет. Като записваме колко често се появява проблемът, ние се надявахме да получим оценка за броя пъти, когато HTTP заявката е изтекла памет, докато грешката е налице.

За да изтече паметта, трябва да е вярно следното:

Окончателният буфер, съдържащ данни, трябваше да завърши с неправилно формиран скрипт или img таг Буферът трябваше да е с дължина по-малка от 4k (в противен случай NGINX щеше да се срине) Клиентът трябваше или да има активирано обфускиране на имейли (защото използва както стария, така и новия анализатор докато преминаваме),

… или автоматични пренаписвания на HTTPS/изключване от страна на сървъра (които използват новия анализатор) в комбинация с друга функция на Cloudflare, която използва стария анализатор. … и изключванията от страна на сървъра се изпълняват само ако клиентският IP има лоша репутация (т.е. не работи за повечето посетители).

Първоначално публикувано в blog.cloudflare.com на 23 февруари 2017 г.