Hacking
March 17, 2022

Как взломать iPhone обычной GIF'кой

Подробнейшая лекция, в которой мы рассказываем, как работает эксплоит zero-click для iMessage

Всем салют, дорогие друзья!
В изра­иль­ской NSO Group соз­дали экс­пло­ит для iMessage, на­делав­ший мно­го шума. С помощью именно это­го экс­пло­ита тро­ян Pegasus был внед­рен в телефо­ны пуб­личных деяте­лей и полити­ков. Apple уже по­дала иск на NSO. Одна­ко оста­вим в сто­роне полити­ку — в этой статье мы сос­редото­чим­ся на самом экс­пло­ите, тем более что он прос­то взрыв­ной! Заража­ет девай­сы без учас­тия юзе­ра, укры­вает­ся внут­ри GIF и вклю­чает в себя кро­шеч­ный вир­туаль­ный компь­ютер.


NSO

На­чало тому, о чем мы будем говорить, было положе­но в августе 2016 года, ког­да изра­иль­ская ком­пания NSO Group, спе­циали­зиру­ющаяся на киберо­ружии, раз­работа­ла и выпус­тила шпи­онское ПО Pegasus, пред­назна­чен­ное для зараже­ния мобиль­ных устрой­ств под управле­нием Android и iOS. «Пегас» был спо­собен читать тек­сто­вые сооб­щения, отсле­живать звон­ки и мес­тополо­жение, собирать пароли, получать информа­цию с мик­рофона и камеры, а так­же дос­туп к лич­ной информа­ции поль­зовате­ля.

Тот ста­рый «Пегас» 2016 года исполь­зовал экс­пло­ит «одно­го нажатия» (one-click). То есть, ког­да поль­зователь‑жер­тва получал на свой смар­тфон «заряжен­ное» сооб­щение, что­бы акти­виро­вать под­ложен­ный сюр­приз, ему нуж­но было что‑то сде­лать, нап­ример клик­нуть по ссыл­ке. Зараже­ния было лег­ко избе­жать — дос­таточ­но было не нажимать на что попало.

При­меры фишин­говых СМС

В июле 2021 года уда­лось изу­чить экс­пло­ит «нулево­го нажатия» для iMessage, обна­ружен­ный на смар­тфо­не акти­вис­та из Саудов­ской Ара­вии. Экс­пло­ит работал вооб­ще без учас­тия поль­зовате­ля и сра­баты­вал сам — хакеру дос­таточ­но лишь отпра­вить полез­ную наг­рузку в мес­сен­дже­ре.


ОПАСНЫЙ ПРИЕМ

Вход­ная точ­ка «Пегаса» в iPhone — при­ложе­ние iMessage. Это зна­чит, что ата­кующе­му дос­таточ­но знать телефон­ный номер или Apple ID жер­твы.

В iMessage есть натив­ная под­дер­жка GIF-ани­мации. Прис­ланная в чат гиф­ка вос­про­изво­дит­ся в цик­ле бес­конеч­но. Как толь­ко iMessage получа­ет сооб­щение, еще до его вывода на экран вызыва­ется метод в про­цес­се IMTranscoderAgent. Он, в свою оче­редь, выпол­няет­ся за пре­дела­ми песоч­ницы BlastDoor. При этом в парамет­ре метода переда­ется любое изоб­ражение с рас­ширени­ем gif. Вот таким обра­зом:

[IMGIFUtils copyGifFromPath:toDestinationPath:error]

Это при­мер кода на язы­ке Objective-C. Пос­мотри на селек­тор. Здесь, веро­ятно, было намере­ние прос­то ско­пиро­вать файл GIF перед редак­тирова­нием поля счет­чика цик­лов, но семан­тика это­го метода иная. Внут­ри он исполь­зует API CoreGraphics, что­бы отоб­разить исходное изоб­ражение в новый GIF-файл по задан­ному пути. Одна­ко то, что файл име­ет рас­ширение gif, вов­се не озна­чает, что он на самом деле гиф­ка.

Биб­лиоте­ка ImageIO при­меня­ется, что­бы опоз­нать фор­мат фай­ла и про­ана­лизи­ровать его, но при этом пол­ностью игно­риру­ет его рас­ширение. При исполь­зовании это­го трю­ка с «под­дель­ными гиф­ками» более 20 гра­фичес­ких кодеков ста­новят­ся потен­циаль­ными жер­тва­ми для ата­ки нулево­го нажатия в iMessage. Некото­рые из них очень слож­ны и сос­тоят из сотен тысяч строк кода. Огромное прос­транс­тво для хакер­ской сме­кал­ки!

С iOS 14.8.1 (26 октября 2021 года) Apple огра­ничи­ла фор­маты в ImageIO, дос­тупные из про­цес­са IMTranscoderAgent. Так­же инже­неры ком­пании пол­ностью уда­лили код для дос­тупа к GIF из IMTranscoderAgent с вер­сии iOS 15.0 (20 сен­тября 2021 года), вмес­те с тем пол­ностью перене­ся декоди­рова­ние GIF внутрь BlastDoor.

PDF ВНУТРИ GIF

В NSO исполь­зовали дыру «под­дель­ный GIF», что­бы через нее заюзать уяз­вимость в пар­сере CoreGraphics PDF.

Дол­гие годы фор­мат PDF был излюблен­ной целью для атак — он дос­тупен вез­де и обла­дает дос­таточ­ной слож­ностью. При­ятный бонус для хакеров — под­дер­жка JavaScript внут­ри PDF. CoreGraphics PDF не интер­пре­тиру­ет JavaScript, тем не менее NSO уда­лось най­ти в нед­рах пар­сера кое‑что не менее мощ­ное.


ЭКСТРЕМАЛЬНОЕ СЖАТИЕ

В кон­це девянос­тых мало у кого был ста­биль­ный и быс­трый интернет, боль­шинс­тво юзе­ров доз­ванива­лись к про­вай­деру по dial-up и работа­ли на смеш­ных сей­час ско­рос­тях. Да и дис­ки не отли­чались боль­шими емкостя­ми, поэто­му сжа­тие дан­ных было важ­ной тех­нологи­ей. PNG, JPEG и GIF нам зна­комы и по сей день, но были и дру­гие.

Фор­мат JBIG2 пред­назна­чал­ся для сжа­тия монох­ромных изоб­ражений (где пик­сели могут быть толь­ко чер­ными или белыми). Он при­менял­ся в офис­ных ска­нерах высокой ценовой катего­рии, таких как Xerox WorkCenter.

Xerox WorkCenter

Ес­ли лет десять‑двад­цать тому назад тебе доводи­лось исполь­зовать фун­кцию «ска­ниро­вания в PDF» на подоб­ном устрой­стве, в получав­шихся у тебя PDF, ско­рее все­го, был поток JBIG2.

При­меча­тель­но, что эти фай­лы даже при дос­той­ном раз­решении ска­ниро­вания занима­ли все­го нес­коль­ко килобай­тов. JBIG2 исполь­зует два метода для дос­тижения такого мощ­ного сжа­тия. Сей­час мы их обсу­дим. Толь­ко не думай, что мы тут отвлек­лись на какую‑то побоч­ную ерун­ду, — все это име­ет непос­редс­твен­ное отно­шение к экс­плу­ата­ции дыры в iMessage!

Техника 1: сегментация и замещение

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

JBIG2 пыта­ется сег­менти­ровать каж­дую стра­ницу на гли­фы, а затем исполь­зует прос­тое сопос­тавле­ние с образцом, что­бы выделить гли­фы, которые выг­лядят оди­нако­во.

Со­пос­тавле­ние с образцом поз­воля­ет най­ти все фор­мы, в дан­ном слу­чае все бук­вы e

При этом JBIG2 ничего не зна­ет о самих гли­фах и не пыта­ется рас­позна­вать их и сопос­тавлять с алфа­витом (OCR). Кодиров­щик JBIG2 прос­то ищет свя­зан­ные области пик­селей и груп­пиру­ет похожие.

При этом алго­ритм сжа­тия заменя­ет все дос­таточ­но похожие на вид области копи­ей толь­ко одной из них.

За­мена всех вхож­дений одной копи­ей гли­фа поз­воля­ет дос­тичь высоких коэф­фици­ентов сжа­тия

В таком слу­чае текст по‑преж­нему прек­расно чита­ется, одна­ко объ­ем хра­нимой информа­ции ста­новит­ся мень­ше. Вмес­то того что­бы хра­нить дан­ные о пик­селях всей стра­ницы, для их отоб­ражения нуж­на толь­ко сжа­тая вер­сия «ссы­лоч­ного гли­фа» для каж­дого сим­вола и отно­ситель­ные коор­динаты мест, где дол­жны быть раз­мещены его копии. При рас­паков­ке алго­ритм рас­став­ляет гли­фы по мес­там, как бы рисуя ими на хол­сте.

Та­кой под­ход таит в себе сущес­твен­ный недос­таток: кри­вой кодиров­щик может слу­чай­но спу­тать похожие на вид сим­волы. А это при­водит к печаль­ным пос­ледс­тви­ям. Для нас в дан­ном слу­чае эти проб­лемы не важ­ны — раз­ве что ими мож­но объ­яснить поч­ти пол­ное вымира­ние JBIG2.

Техника 2: уточняющее кодирование

Итак, резуль­тат сжа­тия на осно­ве под­ста­нов­ки отоб­ража­ется с потеря­ми. То есть пос­ле сжа­тия и рас­паков­ки вывод на вид не будет в точ­ности соот­ветс­тво­вать вво­ду. Поэто­му JBIG2 под­держи­вает и сжа­тие без потерь, в которое вхо­дит про­межу­точ­ный этап «сжа­тия с мень­шими потеря­ми».

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

Ис­поль­зование опе­рато­ра XOR на рас­трах для вычис­ления мас­ки раз­ности изоб­ражения

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

Вмес­то того что­бы пол­ностью кодиро­вать всю раз­ность за один раз, это мож­но сде­лать поэтап­но, при этом на каж­дой ите­рации исполь­зует­ся логичес­кий опе­ратор (один из AND, OR, XOR или XNOR) для уста­нов­ки, сбро­са или перек­лючения битов.

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


ПОТОК JBIG2

Боль­шая часть декоде­ра CoreGraphics PDF — это проп­риетар­ный код Apple, но реали­зация JBIG2 взя­та из про­екта Xpdf, исходный код которо­го находит­ся в сво­бод­ном дос­тупе.

Фор­мат JBIG2 пред­став­ляет собой набор сег­ментов, который мож­но рас­смат­ривать как серию команд рисова­ния — они выпол­няют­ся пос­ледова­тель­но за один про­ход. Ана­лиза­тор CoreGraphics JBIG2 под­держи­вает 19 раз­личных типов сег­ментов, которые вклю­чают такие опе­рации, как опре­деле­ние новой стра­ницы, декоди­рова­ние таб­лицы Хаф­фма­на и визу­али­зация рас­тро­вого изоб­ражения с задан­ными коор­дината­ми.

Сег­менты пред­став­лены клас­сом JBIG2Segment и его под­клас­сами JBIG2Bitmap и JBIG2SymbolDict. JBIG2Bitmap пред­став­ляет собой пря­моуголь­ный мас­сив пик­селей. Его поле дан­ных ука­зыва­ет на зад­ний буфер, содер­жащий повер­хность для рен­дерин­га. JBIG2SymbolDict груп­пиру­ет бит­мапы. А целевая стра­ница пред­став­лена как JBIG2Bitmap и сос­тоит из отдель­ных гли­фов. На сег­менты (JBIG2Segment) мож­но ссы­лать­ся по номеру, а век­торный тип GList хра­нит ука­зате­ли на все сег­менты. Что­бы най­ти сег­мент по его номеру, GList ска­ниру­ется пос­ледова­тель­но.

Кста­ти, если ты сей­час про­читал сло­во «джи‑лист» неп­равиль­но и раз­веселил­ся, как дет­садовец, то пос­тарай­ся скон­цен­три­ровать­ся, нас ждут куда более инте­рес­ные откры­тия.

УЯЗВИМОСТЬ

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

Guint numSyms; // (1)

numSyms = 0;

for (i = 0; i < nRefSegs; ++i) {

if ((seg = findSegment(refSegs[i]))) {

if (seg->getType() == jbig2SegSymbolDict) {

numSyms += ((JBIG2SymbolDict *)seg)->getSize(); // (2)

} else if (seg->getType() == jbig2SegCodeTable) {

codeTables->append(seg);

}

} else {

error(errSyntaxError, getPos(),

"Invalid segment reference in JBIG2 text region");

delete codeTables;

return;

}

}

...

// Get the symbol bitmaps

syms = (JBIG2Bitmap **)gmallocn(numSyms, sizeof(JBIG2Bitmap *)); // (3)

kk = 0;

for (i = 0; i < nRefSegs; ++i) {

if ((seg = findSegment(refSegs[i]))) {

if (seg->getType() == jbig2SegSymbolDict) {

symbolDict = (JBIG2SymbolDict *)seg;

for (k = 0; k < symbolDict->getSize(); ++k) {

syms[kk++] = symbolDict->getBitmap(k); // (4)

}

}

}

}

Здесь перемен­ная numSyms объ­явле­на как 32-бит­ное целое (смот­ри помет­ку 1). Если мы будем раз за разом добав­лять спе­циаль­но под­готов­ленные ссы­лоч­ные сег­менты (2), в кон­це кон­цов это при­ведет к перепол­нению numSyms до кон­тро­лиру­емо­го неболь­шого зна­чения. Это зна­чение исполь­зует­ся для выделе­ния кучи (3), из чего сле­дует, что syms будет ука­зывать на буфер недос­таточ­ного раз­мера. Во внут­реннем цик­ле (4) зна­чения ука­зате­ля JBIG2Bitmap записы­вают­ся в буфер syms мень­шего раз­мера.

Без допол­нитель­ных ухищ­рений этот цикл записал бы более 32 Гбайт дан­ных в неп­ригод­ный по раз­мерам буфер syms, а это при­ведет к сбою. Что­бы такого не про­исхо­дило, куча обра­баты­вает­ся так, что­бы пер­вые нес­коль­ко записей из кон­ца буфера syms пов­режда­ли зад­ний буфер GList. Спи­сок GList хра­нит все извес­тные сег­менты и исполь­зует­ся фун­кци­ей findSegments для сопос­тавле­ния номеров сег­ментов, передан­ных в refSegs, с ука­зате­лями JBIG2Segment. Перепол­нение при­водит к переза­писи ука­зате­лей JBIG2Segment в GList ука­зате­лями JBIG2Bitmap (4).

Так как JBIG2Bitmap нас­леду­ется от JBIG2Segment, вир­туаль­ный вызов seg->getType() выпол­няет­ся успешно даже на устрой­ствах, где вклю­чена про­вер­ка под­линнос­ти ука­зате­ля (она исполь­зует­ся для выпол­нения сла­бой про­вер­ки типа вир­туаль­ных вызовов). Но воз­вра­щаемый тип теперь не будет равен jbig2SegSymbolDict, в резуль­тате чего даль­нейшая запись не про­исхо­дит (4) и сте­пень пов­режде­ния памяти огра­ничи­вает­ся.

Уп­рощен­ное пред­став­ление струк­туры памяти при перепол­нении кучи

БЕСПРЕДЕЛЬНЫЙ И НЕОГРАНИЧЕННЫЙ ДОСТУП

Пос­ле того как сег­менты в спис­ке GList пов­режде­ны, хакер перехо­дит к пор­че объ­екта JBIG2Bitmap, пред­став­ляюще­го собой текущую стра­ницу (мес­то, где коман­ды рисова­ния выпол­няют визу­али­зацию). Про­ще говоря, JBIG2Bitmap — это обо­лоч­ки зад­него буфера, хра­нящие ширину и высоту буфера (в битах), а так­же зна­чение, которое опре­деля­ет, сколь­ко бай­тов хра­нит­ся для каж­дой стро­ки.

Струк­тура памяти объ­екта JBIG2Bitmap, показы­вающая поля segnum, w, h и line, которые были пов­режде­ны во вре­мя перепол­нения буфера

Ес­ли тща­тель­но струк­туриро­вать refSegs, они могут оста­новить перепол­нение пос­ле записи еще трех ука­зате­лей JBIG2Bitmap пос­ле кон­ца буфера сег­ментов GList. Такой под­ход поз­воля­ет переза­писать ука­затель на vtable и пер­вые четыре поля JBIG2Bitmap, пред­став­ляющих текущую стра­ницу.

Вви­ду устрой­ства адресно­го прос­транс­тва iOS эти ука­зате­ли, ско­рее все­го, будут находить­ся во вто­рых 4 Гбайт вир­туаль­ной памяти с адре­сами от 0x100000000 до 0x1ffffffff. На девай­сах, исполь­зующих iOS, при­меня­ется пря­мой порядок бай­тов (little-endian). Это озна­чает, что поля w и line будут переза­писа­ны на 0x1 — наибо­лее зна­чимую полови­ну ука­зате­ля JBIG2Bitmap, а поля segNum и h заменят­ся наиме­нее зна­чимой полови­ной это­го же ука­зате­ля — слу­чай­ным зна­чени­ем, завися­щим от раз­мещения кучи и ASLR (ран­домиза­ция раз­мещения адресно­го прос­транс­тва), где‑то меж­ду 0x100000 и 0xffffffff.

В ито­ге выходит, что целевая стра­ница JBIG2Bitmap получа­ет слиш­ком боль­шое зна­чение h. Пос­коль­ку это зна­чение исполь­зует­ся для про­вер­ки гра­ниц и дол­жно отра­жать выделен­ный раз­мер буфера стра­ницы, получа­ется эффект раз­верты­вания рабочей области для вывода изоб­ражения. Это озна­чает, что пос­леду­ющие коман­ды сег­мента JBIG2 могут читать и записы­вать память за пре­дела­ми исходных гра­ниц буфера под­дер­жки стра­ницы.

Об­работ­чик кучи так­же раз­меща­ет буфер под­дер­жки текущей стра­ницы чуть ниже непод­ходяще­го по раз­мерам буфера syms, поэто­му, ког­да стра­ница JBIG2Bitmap не огра­ниче­на, она может читать и записы­вать свои собс­твен­ные поля.

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

Бла­года­ря отри­сов­ке четырех­бай­товых рас­тров с пра­виль­ными коор­дината­ми мож­но про­изво­дить запись во все поля стра­ницы JBIG2Bitmap, а тща­тель­но выбирая новые зна­чения для w, h и line, мож­но записы­вать про­изволь­ные сме­щения в зад­ний буфер стра­ницы.

На этом эта­пе уже мож­но было бы писать абсо­лют­ные адре­са памяти, если бы мы зна­ли их сме­щения в зад­нем буфере стра­ницы. Но как вычис­лить эти сме­щения? До сих пор этот экс­пло­ит дей­ство­вал очень похоже на обыч­ный экс­пло­ит тра­дици­онно­го язы­ка сце­нари­ев. В JavaScript это мог­ло бы закон­чить­ся неог­раничен­ным объ­ектом ArrayBuffer с дос­тупом к памяти. В таком слу­чае зло­умыш­ленник имел бы воз­можность запус­кать про­изволь­ный код на JavaScript, который бы исполь­зовал­ся для вычис­ления сме­щений и выпол­нения про­изволь­ных дей­ствий. Как это сде­лать в однопро­ход­ном пар­сере изоб­ражений?


ДРУГОЙ ФОРМАТ СЖАТИЯ — ПОЛНЫЙ ПО ТЬЮРИНГУ!

Как ты пом­нишь, пос­ледова­тель­ность шагов, реали­зующих уточ­нение в JBIG2, очень гиб­кая. Шаги уточ­нения могут ссы­лать­ся как на рас­тро­вое изоб­ражение вывода, так и на любые ранее соз­данные сег­менты, а так­же отоб­ражать вывод либо на текущей стра­нице, либо на сег­менте. Хит­роум­но работая с завися­щей от кон­тек­ста деком­прес­сией уточ­нения, мож­но соз­давать пос­ледова­тель­нос­ти сег­ментов, в которых эффект будут иметь толь­ко опе­рато­ры ком­бинации усо­вер­шенс­тво­ваний.

На прак­тике это зна­чит, что мож­но при­менять логичес­кие опе­рато­ры AND, OR, XOR и XNOR меж­ду областя­ми памяти с про­изволь­ными сме­щени­ями зад­него буфера JBIG2Bitmap текущей стра­ницы. И пос­коль­ку огра­ниче­ний нет, мож­но выпол­нять эти логичес­кие опе­рации с памятью с про­изволь­ными сме­щени­ями за пре­дела­ми гра­ниц.

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

Это нем­ного усложнит задачу, но при желании мож­но вмес­то гли­фов работать с отдель­ными битами. В качес­тве вход­ных дан­ных мож­но подать набор команд сег­мента JBIG2, которые реали­зуют пос­ледова­тель­ность логичес­ких битовых опе­раций, при­меня­емых к стра­нице. А пос­коль­ку буфер стра­ницы не огра­ничен, эти битовые опе­рации могут работать с про­изволь­ной памятью.

С помощью дос­тупных логичес­ких опе­рато­ров AND, OR, XOR и XNOR мож­но выпол­нить любую матема­тичес­кую фун­кцию.


ВЫВОДЫ

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

Это как раз то, что дела­ет экс­пло­ит NSO. В нем на осно­ве 70 тысяч сег­мен­тных команд реали­зова­на архи­тек­тура неболь­шого компь­юте­ра. Здесь есть регис­тры, а так­же пол­ные 64-бит­ный сум­матор и ком­паратор, которые исполь­зуют­ся для поис­ка в памяти и выпол­нения ариф­метичес­ких опе­раций. Все это работа­ет не так быс­тро, как JavaScript, но мож­но добить­ся схо­жих резуль­татов.

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

Хоть деятель­ность NSO и вызыва­ет воп­росы с точ­ки зре­ния эти­ки, но нель­зя не отдать дол­жное изоб­ретатель­нос­ти этой схе­мы!


- Легко! Ведь именно ​этому мы и обучаем в нашей AKADEМИИ. Кстати, уже совсем скоро грядет ее масштабное обновление. Следите за каналом, будет очень горячо 🔥