Решил написать эту заметку, потому как надоело отвечать 100500 раз одно и то же на ВиО.
Многие начинающие веб-программисты рано или поздно сталкиваются с задачей внедрения в свой сайт человеко-понятных линков (ЧПУ). До внедрения ЧПУ все ссылки имеют вид /myscript.php или даже /myfolder/myfolder2/myscript3.php, что тяжело для запоминания и ещё хуже для SEO. После внедрения ЧПУ линки принимают вид /statiya-o-php или даже на кириллице /статья-о-пхп.
Кстати о SEO. Человекопонятные линки на САМОМ деле придумали не для удобного запоминания, а в основном для повышения индексируемости сайта, потому что совпадение поискового запроса и части URL даёт хорошее преимущество в рейтинге поиска.
Эволюция начинающего PHP-программиста может быть выражена следующей последовательностью шагов:
Замечу, что между пунктами 2 и 3 большинство программистов делают очевидную ошибку. Я не ошибусь, если назову это значением около 95% программистов. Даже большинство известных фреймворков содержат эту ошибку. И заключается она в следующем.
Вместо того, чтобы реализовывать принципиально новый способ обработки линков, ошибочно делается концепция «заплаток и редиректов» на базе.htaccess, которая заключается в том, чтобы с помощью mod_rewrite создавать множество правил редиректа. Эти строки сравнивают URL с каким-либо регулярным выражением и при совпадении расталкивают выуженные из URL значения по GET-переменным, в дальнейшем вызывая всё тот же index.php.
#Неправильный метод ЧПУ RewriteEngine On RewriteRule ^\/users\/(.+)$ index.php?module=users&id=$1 #....Ещё куча подобных правил...
У данной концепции множество недостатков. Один из них — трудность создания правил, большой процент человеческих ошибок при добавлении правил, которые сложно выявить, но они приводят к ошибке сервера 500.
Другой недостаток в том, что часто правится по сути конфига сервера, что само по себе нонсенс. И если в Apache конфиг можно «пропатчить» с помощью.htaccess, то в популярном nginx такой возможности нет, там всё находится в общем файле конфигурации в системной зоне.
И ещё один недостаток, вероятно, наиболее важный, что при таком подходе невозможно динамически конфигурировать роутер, то есть «на лету», алгоритмически менять и расширять правила выбора нужного скрипта.
Предлагаемый ниже способ избавлен от всех этих недостатков. Он уже используется в большом количестве современных фреймворков.
Суть заключается в том, что начальный запрос всегда хранится в переменной $_SERVER[‘REQUEST_URI’], то есть его можно считать внутри index.php и разобрать как строку средствами PHP со всеми обработками ошибок, динамическими редиректами и проч и проч.
При этом в файле конфигурации можно создать только одно статичное правило, которое будет все запросы к несуществующим файлам или папкам редиректить на index.php.
RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f #Если файл не существует RewriteCond %{REQUEST_FILENAME} !-d #И если папка не существует RewriteRule ^.*$ index.php
Причём это правило можно разместить как в.htaccess, так и в основном файле конфигурации Apache.
Для nginx соответствующее правило будет выглядеть вот так:
Location / { if (!-e $request_filename) { rewrite ^/(.*)$ /index.php last; } }
Всё просто.
Теперь рассмотрим кусок кода PHP в index.php, который анализирует ссылки и принимает решение — какой скрипт запускать.
/часть1/часть2/часть3
Первое, что приходит в голову — разбить её с помощью explode(‘/’, $uri) и сделать сложный роутер на основе switch/case, анализирующий каждый кусок запроса. Не делайте этого! Это сложно и в итоге приводит код в ужасный непонимабельный и неконфигурабельный вид!
Я предлагаю более лаконичный способ. Лучше не описывать словами, а сразу показать код.
"page404.php", // Страница 404 "/" => "mainpage.php", // Главная страница "/news" => "newspage.php", // Новости - страница без параметров "/stories(/+)?" => "storypage.php", // С числовым параметром // Больше правил); // Код роутера class uSitemap { public $title = ""; public $params = null; public $classname = ""; public $data = null; public $request_uri = ""; public $url_info = array(); public $found = false; function __construct() { $this->mapClassName(); } function mapClassName() { $this->classname = ""; $this->title = ""; $this->params = null; $map = &$GLOBALS["sitemap"]; $this->request_uri = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH); $this->url_info = parse_url($this->request_uri); $uri = urldecode($this->url_info["path"]); $data = false; foreach ($map as $term => $dd) { $match = array(); $i = preg_match("@^".$term."$@Uu", $uri, $match); if ($i > 0) { // Get class name and main title part $m = explode(",", $dd); $data = array("classname" => isset($m)?strtolower(trim($m)):"", "title" => isset($m)?trim($m):"", "params" => $match,); break; } } if ($data === false) { // 404 if (isset($map["_404"])) { // Default 404 page $dd = $map["_404"]; $m = explode(",", $dd); $this->classname = strtolower(trim($m)); $this->title = trim($m); $this->params = array(); } $this->found = false; } else { // Found! $this->classname = $data["classname"]; $this->title = $data["title"]; $this->params = $data["params"]; $this->found = true; } return $this->classname; } } $sm = new uSitemap(); $routed_file = $sm->classname; // Получаем имя файла для подключения через require() require("app/".$routed_file); // Подключаем файл // P.S. Внутри подключённого файла Вы можете использовать параметры запроса, // которые хранятся в свойстве $sm->params
Несмотря на то, что код довольно длинный, он прост логически. Мне не хочется его объяснять, я считаю любой код на PHP самообъясняющим, если он правильно написан. Учитесь читать код.
Процесс создания системы регистрации – это довольно большой объем работы. Вам нужно написать код, который бы перепроверял валидность email-адресов, высылал email-письма с подтверждением, предлагал возможность восстановить пароль, хранил бы пароли в безопасном месте, проверял формы ввода и многое другое. Даже когда вы все это сделаете, пользователи будут регистрироваться неохотно, так как даже самая минимальная регистрация требует их активности.
В сегодняшнем руководстве мы займемся разработкой простой системы регистрации, с использованием которой вам не понадобятся никакие пароли! В результаты мы получим, систему, которую можно будет без труда изменить или встроить в существующий PHP-сайт. Если вам интересно, продолжайте чтение.
PHP
Теперь мы готовы к тому, чтобы заняться кодом PHP. Основной функционал системы регистрации предоставляется классом User, который вы можете видеть ниже. Класс использует (), представляющую собой минималистскую библиотеку для работы с базами данных. Класс User отвечает за доступ к базам данных, генерирование token-ов для логина и их валидации. Он представляет нам простой интерфейс, который можно без труда включить в систему регистрации на ваших сайтах, основанных на PHP.
User.class.php
// Private ORM instance
private $orm;
/**
* Find a user by a token string. Only valid tokens are taken into
* consideration. A token is valid for 10 minutes after it has been generated.
* @param string $token The token to search for
* @return User
*/
Public static function findByToken($token){
// find it in the database and make sure the timestamp is correct
->where("token", $token)
->where_raw("token_validity > NOW()")
->find_one();
If(!$result){
return false;
}
Return new User($result);
}
/**
* Either login or register a user.
* @return User
*/
Public static function loginOrRegister($email){
// If such a user already exists, return it
If(User::exists($email)){
return new User($email);
}
// Otherwise, create it and return it
Return User::create($email);
}
/**
* Create a new user and save it to the database
* @param string $email The user"s email address
* @return User
*/
Private static function create($email){
// Write a new user to the database and return it
$result = ORM::for_table("reg_users")->create();
$result->email = $email;
$result->save();
Return new User($result);
}
/**
* Check whether such a user exists in the database and return a boolean.
* @param string $email The user"s email address
* @return boolean
*/
Public static function exists($email){
// Does the user exist in the database?
$result = ORM::for_table("reg_users")
->where("email", $email)
->count();
Return $result == 1;
}
/**
* Create a new user object
* @param $param ORM instance, id, email or null
* @return User
*/
Public function __construct($param = null){
If($param instanceof ORM){
// An ORM instance was passed
$this->orm = $param;
}
else if(is_string($param)){
// An email was passed
$this->
->where("email", $param)
->find_one();
}
else{
If(is_numeric($param)){
// A user id was passed as a parameter
$id = $param;
}
else if(isset($_SESSION["loginid"])){
// No user ID was passed, look into the sesion
$id = $_SESSION["loginid"];
}
$this->orm = ORM::for_table("reg_users")
->where("id", $id)
->find_one();
}
/**
* Generates a new SHA1 login token, writes it to the database and returns it.
* @return string
*/
Public function generateToken(){
// generate a token for the logged in user. Save it to the database.
$token = sha1($this->email.time().rand(0, 1000000));
// Save the token to the database,
// and mark it as valid for the next 10 minutes only
$this->orm->set("token", $token);
$this->orm->set_expr("token_validity", "ADDTIME(NOW(),"0:10")");
$this->orm->save();
Return $token;
}
/**
* Login this user
* @return void
*/
Public function login(){
// Mark the user as logged in
$_SESSION["loginid"] = $this->orm->id;
// Update the last_login db field
$this->orm->set_expr("last_login", "NOW()");
$this->orm->save();
}
/**
* Destroy the session and logout the user.
* @return void
*/
Public function logout(){
$_SESSION = array();
unset($_SESSION);
}
/**
* Check whether the user is logged in.
* @return boolean
*/
Public function loggedIn(){
return isset($this->orm->id) && $_SESSION["loginid"] == $this->orm->id;
}
/**
* Check whether the user is an administrator
* @return boolean
*/
Public function isAdmin(){
return $this->rank() == "administrator";
}
/**
* Find the type of user. It can be either admin or regular.
* @return string
*/
Public function rank(){
if($this->orm->rank == 1){
return "administrator";
}
Return "regular";
}
/**
* Magic method for accessing the elements of the private
* $orm instance as properties of the user object
* @param string $key The accessed property"s name
* @return mixed
*/
Public function __get($key){
if(isset($this->orm->$key)){
return $this->orm->$key;
}
Return null;
}
}
Token-ы генерируются при помощи алгоритма , и сохраняются в базу данных. Мы используем из MySQL для установки значения в колонку token_validity, равного 10 минутам. При валидации token, мы сообщаем движку, что нам нужен token, поле token_validity пока еще не истекло. Таким образом мы ограничиваем время, в течение которого token будет валиден.
Обратите внимание на то, что мы используем волшебный метод __get () в конце документа, чтобы получить доступ к свойствам объекта user. Это позволяет нам осуществить доступ к данным, которые хранятся в базе данных в виде свойств: $user->email, $user->token. Для примера давайте посмотрим, как мы можем использовать этот класс в следующем фрагменте кода:
Functions.php
Function send_email($from, $to, $subject, $message){
// Helper function for sending email
$headers = "MIME-Version: 1.0" . "\r\n";
$headers .= "Content-type: text/plain; charset=utf-8" . "\r\n";
$headers .= "From: ".$from . "\r\n";
Return mail($to, $subject, $message, $headers);
}
function get_page_url(){
// Find out the URL of a PHP file
$url = "http".(empty($_SERVER["HTTPS"])?"":"s")."://".$_SERVER["SERVER_NAME"];
If(isset($_SERVER["REQUEST_URI"]) && $_SERVER["REQUEST_URI"] != ""){
$url.= $_SERVER["REQUEST_URI"];
}
else{
$url.= $_SERVER["PATH_INFO"];
}
Return $url;
}
function rate_limit($ip, $limit_hour = 20, $limit_10_min = 10){
// The number of login attempts for the last hour by this IP address
$count_hour = ORM::for_table("reg_login_attempt")
->
->where_raw("ts > SUBTIME(NOW(),"1:00")")
->count();
// The number of login attempts for the last 10 minutes by this IP address
$count_10_min = ORM::for_table("reg_login_attempt")
->where("ip", sprintf("%u", ip2long($ip)))
->where_raw("ts > SUBTIME(NOW(),"0:10")")
->count();
If($count_hour > $limit_hour || $count_10_min > $limit_10_min){
throw new Exception("Too many login attempts!");
}
}
function rate_limit_tick($ip, $email){
// Create a new record in the login attempt table
$login_attempt = ORM::for_table("reg_login_attempt")->create();
$login_attempt->email = $email;
$login_attempt->ip = sprintf("%u", ip2long($ip));
$login_attempt->save();
}
function redirect($url){
header("Location: $url");
exit;
}
Функции rate_limit и rate_limit_tick позволяют нам ограничивать число попыток авторизации на определенный промежуток времени. Попытки авторизации записываются в базу данных reg_login_attempt. Эти функции запускаются при проведении подтверждения формы авторизации, как можно видеть в следующем фрагменте кода.
Нижеприведенный код был взят из index.php, и он отвечает за подтверждение формы авторизации. Он возвращает JSON-ответ, который управляется кодом jQuery, который мы видели в assets/js/script.js.
index.php
If(!empty($_POST) && isset($_SERVER["HTTP_X_REQUESTED_WITH"])){
// Output a JSON header
Header("Content-type: application/json");
// Is the email address valid?
If(!isset($_POST["email"]) || !filter_var($_POST["email"], FILTER_VALIDATE_EMAIL)){
throw new Exception("Please enter a valid email.");
}
// This will throw an exception if the person is above
// the allowed login attempt limits (see functions.php for more):
rate_limit($_SERVER["REMOTE_ADDR"]);
// Record this login attempt
rate_limit_tick($_SERVER["REMOTE_ADDR"], $_POST["email"]);
// Send the message to the user
$message = "";
$email = $_POST["email"];
$subject = "Your Login Link";
If(!User::exists($email)){
$subject = "Thank You For Registering!";
$message = "Thank you for registering at our site!\n\n";
}
// Attempt to login or register the person
$user = User::loginOrRegister($_POST["email"]);
$message.= "You can login from this URL:\n";
$message.= get_page_url()."?tkn=".$user->generateToken()."\n\n";
$message.= "The link is going expire automatically after 10 minutes.";
$result = send_email($fromEmail, $_POST["email"], $subject, $message);
If(!$result){
throw new Exception("There was an error sending your email. Please try again.");
}
Die(json_encode(array(
"message" => "Thank you! We\"ve sent a link to your inbox. Check your spam folder as well."
)));
}
}
catch(Exception $e){
Die(json_encode(array(
"error"=>1,
"message" => $e->getMessage()
)));
}
При успешной авторизации или регистрации, вышеприведенный код отсылает email человеку с ссылкой для авторизации. Token (лексема) становится доступной в качестве $_GET-переменной "tkn" ввиду сгенерированного URL.
index.php
If(isset($_GET["tkn"])){
// Is this a valid login token?
$user = User::findByToken($_GET["tkn"]);
// Yes! Login the user and redirect to the protected page.
$user->login();
redirect("protected.php");
}
// Invalid token. Redirect back to the login form.
redirect("index.php");
}
Запуск $user->login() создаст необходимые переменные для сессии, что позволит пользователю оставаться авторизованным при последующих входах.
Выход из системы реализуется примерно таким же образом:
Index.php
If(isset($_GET["logout"])){
$user = new User();
If($user->loggedIn()){
$user->logout();
}
Redirect("index.php");
}
В конце кода мы снова перенаправляем пользователя на index.php, поэтому параметр?logout=1 в URL исключается.
Нашему файлу index.php также потребуется защита – мы не хотим, чтобы уже авторизованные пользователи видели форму. Для этого мы используем метод $user->loggedIn():
Index.php
$user = new User();
if($user->loggedIn()){
redirect("protected.php");
}
Наконец, давайте посмотрим, как можно защитить страницу вашего сайта, и сделать ее доступной только после авторизации:
protected.php
// To protect any php page on your site, include main.php
// and create a new User object. It"s that simple!
require_once "includes/main.php";
$user = new User();
if(!$user->loggedIn()){
redirect("index.php");
}
После этой проверки вы можете быть уверены в том, что пользователь успешно авторизовался. У вас также будет доступ к данным, которые хранятся в базе данных в качестве свойств объекта $user. Чтобы вывести email пользователя и их ранг, воспользуйтесь следующим кодом:
Echo "Your email: ".$user->email;
echo "Your rank: ".$user->rank();
Здесь rank() – это метод, так как колонка rank в базе данных обычно содержит числа (0 для обычных пользователей и 1 для администраторов), и нам нужно преобразовать это все в названия рангов, что реализуется при помощи данного метода. Чтобы преобразовать обычного пользователя в администратора, просто отредактируйте запись о пользователе в phpmyadmin (либо в любой другой программе по работе с базами данных). Будучи администратором, пользователь не будет наделен какими-то особыми возможностями. Вы сами в праве выбирать, каким правами наделять администраторов.
Готово!
На этом наша простенькая система регистрации готова! Вы можете использовать ее на уже существующем PHP-сайте, либо модернизировать ее, придерживаясь собственных требований.
Сегoдня мы рассмотрим эксплуатацию критической 1day-уязвимости в популярной CMS Joomla, которая прогремела на просторах интернета в конце октября. Речь пойдет об уязвимостях с номерами CVE-2016-8869 , CVE-2016-8870 и CVE-2016-9081 . Все три происходят из одного кусочка кода, который пять долгих лет томился в недрах фреймворка в ожидании своего часа, чтобы затем вырваться на свободу и принести с собой хаос, взломанные сайты и слезы ни в чем не повинных пользователей этой Joomla. Лишь самые доблестные и смелые разработчики, чьи глаза красны от света мониторов, а клавиатуры завалены хлебными крошками, смогли бросить вызов разбушевавшейся нечисти и возложить ее голову на алтарь фиксов.
6 октября 2016 года Дэмис Пальма (Demis Palma) создал топик на Stack Exchange , в котором поинтересовался: а почему, собственно, в Joomla версии 3.6 существуют два метода регистрации пользователей с одинаковым названием register() ? Первый находится в контроллере UsersControllerRegistration , а второй - в UsersControllerUser . Дэмис хотел узнать, используется ли где-то метод UsersControllerUser::register() , или это лишь эволюционный анахронизм, оставшийся от старой логики. Его беспокоил тот факт, что, даже если этот метод не используется никаким представлением, он может быть вызван при помощи сформированного запроса. На что получил ответ от девелопера под ником itoctopus, подтвердившего: проблема действительно существует. И направил отчет разработчикам Joomla.
Далее события развивались самым стремительным образом. 18 октября разработчики Joomla принимают репорт Дэмиса, который к тому времени набросал PoC, позволяющий регистрировать пользователя. Он опубликовал заметку на своем сайте , где в общих чертах рассказал о найденной проблеме и мыслях по этому поводу. В этот же день выходит новая версия Joomla 3.6.3, которая все еще содержит уязвимый код.
После этого Давиде Тампеллини (Davide Tampellini) раскручивает баг до состояния регистрации не простого пользователя, а администратора. И уже 21 октября команде безопасности Joomla прилетает новый кейс. В нем речь уже идет о повышении привилегий . В этот же день на сайте Joomla появляется анонс о том, что во вторник, 25 октября, будет выпущена очередная версия с порядковым номером 3.6.3, которая исправляет критическую уязвимость в ядре системы.
25 октября Joomla Security Strike Team находит последнюю проблему, которую создает обнаруженный Дэмисом кусок кода. Затем в главную ветку официального репозитория Joomla пушится коммит от 21 октября с неприметным названием Prepare 3.6.4 Stable Release , который фиксит злосчастный баг.
После этого камин-аута к междусобойчику разработчиков подключаются многочисленные заинтересованные личности - начинают раскручивать уязвимость и готовить сплоиты.
27 октября исследователь Гарри Робертс (Harry Roberts) выкладывает в репозиторий Xiphos Research готовый эксплоит , который может загружать PHP-файл на сервер с уязвимой CMS.
Что ж, с предысторией покончено, переходим к самому интересному - разбору уязвимости. В качестве подопытной версии я установил Joomla 3.6.3, поэтому все номера строк будут актуальны именно для этой версии. А все пути до файлов, которые ты увидишь далее, будут указываться относительно корня установленной CMS.
Благодаря находке Дэмиса Пальмы мы знаем, что есть два метода, которые выполняют регистрацию пользователя в системе. Первый используется CMS и находится в файле /components/com_users/controllers/registration.php:108 . Второй (тот, что нам и нужно будет вызвать), обитает в /components/com_users/controllers/user.php:293 . Посмотрим на него поближе.
286: /** 287: * Method to register a user. 288: * 289: * @return boolean 290: * 291: * @since 1.6 292: */ 293: public function register() 294: { 295: JSession::checkToken("post") or jexit(JText::_("JINVALID_TOKEN")); ... 300: // Get the form data. 301: $data = $this->input->post->get("user", array(), "array"); ... 315: $return = $model->validate($form, $data); 316: 317: // Check for errors. 318: if ($return === false) 319: { ... 345: // Finish the registration. 346: $return = $model->register($data);
Здесь я оставил только интересные строки. Полную версию уязвимого метода можно посмотреть в репозитории Joomla.
Разберемся, что происходит при обычной регистрации пользователя: какие данные отправляются и как они обрабатываются. Если регистрация пользователей включена в настройках, то форму можно найти по адресу http://joomla.local/index.php/component/users/?view=registration .
Легитимный запрос на регистрацию пользователя выглядит как на следующем скриншоте.
За работу с пользователями отвечает компонент com_users . Обрати внимание на параметр task в запросе. Он имеет формат $controller.$method . Посмотрим на структуру файлов.
Имена скриптов в папке controllers соответствуют названиям вызываемых контроллеров. Так как в нашем запросе сейчас $controller = "registration" , то вызовется файл registration.php и его метод register() .
Внимание, вопрос: как передать обработку регистрации в уязвимое место в коде? Ты наверняка уже догадался. Имена уязвимого и настоящего методов совпадают (register), поэтому нам достаточно поменять название вызываемого контроллера. А где у нас находится уязвимый контроллер? Правильно, в файле user.php . Получается $controller = "user" . Собираем все вместе и получаем task = user.register . Теперь запрос на регистрацию обрабатывается нужным нам методом.
Второе, что нам нужно сделать, - это отправить данные в правильном формате. Тут все просто. Легитимный register() ждет от нас массив под названием jform , в котором мы передаем данные для регистрации - имя, логин, пароль, почту (см. скриншот с запросом).
Наш подопечный получает эти данные из массива с именем user .
Поэтому меняем в запросе имена всех параметров с jfrom на user .
Третий наш шаг - это нахождение валидного токена CSRF, так как без него никакой регистрации не будет.
Он выглядит как хеш MD5, а взять его можно, например, из формы авторизации на сайте /index.php/component/users/?view=login .
Теперь можно создавать пользователей через нужный метод. Если все получилось, то поздравляю - ты только что проэксплуатировал уязвимость CVE-2016-8870 «отсутствующая проверка разрешений на регистрацию новых пользователей».
Вот как она выглядит в «рабочем» методе register() из контроллера UsersControllerRegistration:
А так в уязвимом:
Ага, никак.
Чтобы понять вторую, гораздо более серьезную проблему, отправим сформированный нами запрос и проследим, как он выполняется на различных участках кода. Вот кусок, который отвечает за проверку отправленных пользователем данных в рабочем методе:
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», увеличит личную накопительную скидку и позволит накапливать профессиональный рейтинг Xakep Score!
Для начала мы усовершенствуем страничку регистрации, добавив возможность загружать аватар. Исходное изображение должно быть формата jpg, gif или png. Так же оно должно быть не более 2 Мб. Не беспокойтесь, после его сжатия скриптом, размер аватара будет около 3 кб и формат jpg. Откройте страницу reg. php и допишите в теге < form > строчку enctype="multipart/form-data" ,как в примере: