Сгенерировано в DALL-E 3
Сгенерировано в DALL-E 3

Несмотря на то, что название статьи, скорее, кликбейтное, речь в статье всё же пойдет про способы обхода существующих механизмов защиты от парсина, которые используют современные сайты.

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

История стара как мир — владельцы сайтов, например, с бесплатным пробным периодом своего продукта хотят обезопасить себя от людей, которые будут этим злоупотреблять и вместо покупки продукта будут стараться обмануть систему и набрать как можно больше различных триалов. Или, например, чтобы не дать возможности регистрировать аккаунты, которые впоследствие будут использоваться для спам-рассылок/накрутки/и т.д. Для решения этой проблемы (в некотором роде пиратства) люди в разные времена придумывали различные хитрости, чтобы ограничить возможность хотя бы просто запиратить, скажем, дискету с программой. Для этого, например, можно было проткнуть дискету иголкой, после чего узнать номер(-а) секторов (в CHS/LBA нотации), которые от прокола попортились, и зашить это в логику кода. Однако, разумеется, маломальски образованный программист может подменить прерывание и таким образом имитировать отказ секторов. Наверное, я бы сравнил это с гонкой брони и снаряда, потому что обезопасить что-то практически нереально (либо этим будет невозможно пользоваться, или затраты на это будут несоразмерно велики потерям). Ровно по этой причине до сих пор существуют различные репаки, кейгены и прочее.

Лично моё мнение по этому вопросу следующее: если это какой-то продукт, то я его куплю, если это что-то общедоступное (например, сайт) — я, разумеется, хочу иметь возможность такой сайт парсить и применять к нему некоторую автоматизацию. И я могу понять когда его владельцы не хотят чтобы его положили и применяют защиту от DoS-атак, но защиту от роботов я, лично, считаю излишней. Более того, далеко не у всех есть возможность распарсить сайт даже без защиты от роботов. И, кажется, в 2025 году уже можно привыкнуть к тому, что всё что только можно стараются автоматизировать.

Вводной части достаточно, давайте перейдем к делу.

Как по мне, самый первый и банальный способ узнать, был ли человек на сайте — попробовать сохранить что-то у него, что он не сможет тривиальным образом почистить (как куки), либо сохранить что-то о нём у себя.

Начнем с сохранения чего-то на стороне пользователя. Речь, разумеется, идет о том, чтобы уметь после очистки стандартных мест (таких как куки, история просмотра, всякие storage'ы и тд) восстанавливать, скажем, какой-нибудь идентификатор, который мы выдаём пользователю, когда он впервые пришел на сайт. Примерно этим и занимались люди, когда создавали Evercookie, который, очевидно, по состоянию на 2025 год не работает. Насколько известно мне, последняя серьезная починка была связана с разметкой специальным CSS-тегом посещенных пользователем ссылок (работало примерно так: рендерился небольшой HTML-контейнер с ссылками, по каким-то из них совершался "переход", после чего нужные ссылки были окрашены в другой цвет. Для восстановления идентификатора достаточно было посмотреть на те, что были посещены). Однако существует некоторое логическое продолжение этой истории, но только с favico'нками сайта, тк они кэшируются в одном месте — кэш для инкогнито и для обычного профиля браузера один и тот же, и, если не грохнуть этот кэш путем удаления файла в папке с профилем браузера, он будет преследовать вас долго. Но такое легко автоматизировать и не стоит нашего внимания. Далее мы будем считать, что мы каждый раз запускаем браузер со свежим профилем и никакой информации сохранить на компьютере пользователя нельзя (я буквально могу сделать чистый образ для VBox и копировать его для каждого нового сеанса).

Более интеллектуальный способ заключается в сохранении некоторой информации о пользователе и его оборудовании у себя. Интеллектуальный он в том плане, что, скорее, не завязан на действиях пользователя, будь то обновления версии браузера или ОС, очистке кэша или смены профиля. Это называется модным словом fingerprinting.

Идея заключается в том, что в рамках одного ПК, на самом деле, мало что меняется со временем: набор шрифтов, видеокарты, драйвера, объем памяти, процессоры, мониторы. Более того, шанс того, что у каких-то двух различных пользователей это все совпадет, невелик. Именно этим и пользуются современные решения, которые идентифицируют пользователей. Давайте рассмотрим некоторые примеры:

Canvas Fingerprinting

Идея заключается в следующем: от смены версии браузера и ОС, вероятно, то, как будет отрисован текст и некоторые геометрические фигуры на экране пользователя, будет сохраняться. Очевидно, разные видеокарты и браузеры (и даже ОС) рендерят, например, шрифты немного по-разному. Поэтому если мы созданим небольшой html canvas и отрисуем на нем что-нибудь, а потом возьмем от его содержимого некоторый хэш, то он, вероятно, не поменяется за все время использования сайта пользователем. Попробовать, например, можно тут: https://browserleaks.com/canvas. И, хочу заметить, что в режиме инкогнито хэш будет совпадать. Аналогично можно поступить и с WebGL.
Приятная новость для тех, кто использует такой хэш: если я наивно накопирую VBox машин, fingerprint будет везде одинаковый.

Fonts Fingerprinting

Аналогично предыдущему подходу, шанс того, что обычный пользователь установит новых шрифтов (например, при установке MS Office на свой Mac), — мал, ведь, скорее всего, он уже установил всё, что нужно, и набор шрифтов со временем меняться не будет.
Тоже можно попробовать тут: https://browserleaks.com/fonts
Аналогично имеет все плюсы предыдущего способа.

На самом деле, можно придумать массу различных fingerprint'ов, нас интересует не столько их существование, сколько то, как такое можно обходить.

Хочется сначала поговорить про некоторые сложности, которые могут возникнуть, и с чем приходится считаться разработчикам таких систем. Например, использование одной и той же версии ОС и одного и того же браузера на идентичных устройствах (например, ноутбуки одной модели), только отпечатка Canvas'а может быть недостаточно: мы не сможем различить таких пользователей. Поэтому используется сразу несколько отпечатков. Но и при установке нового шрифта не очень хочется считать пользователя иным — ведь тогда и обычные пользователи, даже не имея желания обмануть нашу систему, будут её "обманывать".

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

Давайте попробуем обмануть разработчиков такого рода систем.

Браузерный JS — хороший язык для того, чтобы узнать что-то о пользователе. Ведь любое переопределение функции видно, и может быть "спалено" некоторым кодом. Например,

console.log.toString()
// 'function log() { [native code] }'
console.log = function () {}
console.log.toString()
// 'function () {}'

Давайте разберемся, что же нам надо подменить, чтобы все выглядело так, будто мы ничего и не подменяли вовсе.
Во-первых, toString — это все же не свойство console.log, а свойство прототипа функции. Поэтому, например, вот тут мы получим true:

console.error.toString == console.log.toString

Во-вторых, если мы хотим подменять сам объект (в данном случае функцию), у нас могут возникнуть проблемы следующего вида:

Object.getOwnPropertyNames(console.log)
// ['length', 'name']
console.log = function () {}
Object.getOwnPropertyNames(console.log)
// ['length', 'name', 'arguments', 'caller', 'prototype']

Чтобы победить проблему выше, можно использовать arrow functions:

Object.getOwnPropertyNames(console.log)
// ['length', 'name']
console.log = () => {}
Object.getOwnPropertyNames(console.log)
// ['length', 'name']

Но у такой функции пустое имя, в отличие от оригинальной console.log

console.error(console.log.name) // 'log'
console.log = () => {}
console.error(console.log.name) // ''

Такое можно легко починить:

console.error(console.log.name) // 'log'
{
    let log = () => {} // log will be visible only at that scope
    console.log = log
}
console.error(console.log.name) // 'log'

Давайте попробуем обмнануть сравнение прототипов. Для этого мы заведем новую функцию toString, которая как-то будет отличать наш объект от остальных и сохранять поведение на новых функциях такое же, если бы это была старая функция. Чтобы отличать свои объекты от чужих, мы будем создавать новое свойство у объекта (с рандомным названием, чтобы просто так нельзя было его найти).

let HIDE_ME = '__hide_me'; // better use random naming

function renewHandler(obj, key, newProp) {
  // new property to save previous object
  // to use in handlers
  newProp[HIDE_ME] = obj[key];
  obj[key] = newProp;
}

// only for functions
let prevToString = (function() {}).__proto__.toString;
renewHandler((function() {}).__proto__, 'toString', function toString() {
  "use strict"
  if (HIDE_ME in this) {
    return prevToString.call(this[HIDE_ME])
  }
  return prevToString.call(this)
})

console.error(console.log.toString == console.error.toString)
// true
console.error(console.log.toString())
// 'function log() { [native code] }'
console.error(console.log.toString.toString())
// 'function toString() { [native code] }'
{
    const log = () => {};
    renewHandler(console, 'log', log)
}
console.error(console.log.toString == console.error.toString)
// true
console.error(console.log.toString())
// 'function log() { [native code] }'
console.error(console.log.toString.toString())
// 'function toString() { [native code] }'

Вдумчивый читатель, наверное, уже знает следующую проблему: Object.getOwnProperties и аналогичные функции будут выдавать наши свойства наружу:

let HIDE_ME = '__hide_me';

function renewHandler(obj, key, newProp) {
  // new property to save previous object
  // to use in handlers
  newProp[HIDE_ME] = obj[key]
  obj[key] = newProp;
}

let prevToString = Function.prototype.toString;
renewHandler(Function.prototype, 'toString', function toString() {
  "use strict"
  if (HIDE_ME in this) {
    return prevToString.call(this[HIDE_ME])
  }
  return prevToString.call(this)
})

console.error(Object.getOwnPropertyNames(prevToString))
// ['length', 'name']
console.error(Object.getOwnPropertyNames(Function.prototype.toString))
// ['length', 'name', 'prototype', '__hide_me']

По аналогии с toString давайте обманем все такие методы.

let HIDE_ME = '__hide_me';

function renewHandler(obj, key, newProp) {
  // new property to save previous object
  // to use in handlers
  newProp = newProp(obj[key])
  newProp[HIDE_ME] = obj[key]
  obj[key] = newProp
}

renewHandler(Function.prototype, 'toString', (prev) => {
  return function toString() {
    "use strict"
    if (HIDE_ME in this) {
      return prev.call(this[HIDE_ME])
    }
    return prev.call(this)
  }
})

function fakeHandler(prevHandler, obj, ...args) {
  try {
    if (HIDE_ME in obj) {
        return prevHandler(obj[HIDE_ME], ...args)
    }
  } catch { /* ignore */ }
  return prevHandler(obj, ...args)
}

function makeClosureWithNameAndBody(body, name, prev) {
  formattedBody = "return (prev) => {\nlet " + name + " = " + body + "\nreturn " + name + "\n}"
  return Function(formattedBody)(prev)
}

let newBody = "(objT, ...args) => fakeHandler(prev, objT, ...args)"
let props = ["getOwnPropertyDescriptor", "getOwnPropertyDescriptors", "getOwnPropertyNames", "entries", "keys", "values"]

for (let idx in props) {
  renewHandler(Object, props[idx], makeClosureWithNameAndBody(newBody, props[idx]))
}

console.error(Object.getOwnPropertyNames(Function.prototype.toString))
// ok: ['length', 'name']
console.error(Object.entries(Function.prototype.toString))
// ok: []
console.error(Object.keys(Function.prototype.toString))
// ok: []
console.error(Object.values(Function.prototype.toString))
// ok: []

Отлично! По аналогии надо на прототипе объекта заменить hasOwnProperty и аналогичные функции (а так же toLocaleString).

let HIDE_ME = '__hide_me';

function renewHandler(obj, key, newProp) {
  // new property to save previous object
  // to use in handlers
  newProp = newProp(obj[key])
  newProp[HIDE_ME] = obj[key]
  obj[key] = newProp
}

function fakeThisHandler(prevHandler, ...args) {
  try {
    if (HIDE_ME in this) {
        return prevHandler.call(this[HIDE_ME], args)
    }
  } catch { /* ignore */ }
  return prevHandler.call(this, args)
}

function fakeHandler(prevHandler, obj, ...args) {
  try {
    if (HIDE_ME in obj) {
        return prevHandler(obj[HIDE_ME], ...args)
    }
  } catch { /* ignore */ }
  return prevHandler(obj, ...args)
}

function makeClosureWithNameAndBody(body, name, prev) {
  formattedBody = "return (prev) => {\n " + body.replace("{name}", name) + "\nreturn " + name + "\n}"
  return Function(formattedBody)(prev)
}

let newBody = "let {name} = (objT, ...args) => fakeHandler(prev, objT, ...args)"
let newBodyThis = "function {name} (...args) { return fakeThisHandler.call(this, prev, ...args) }"

let propsToChange = [
  [Object, newBody, ["getOwnPropertyDescriptor", "getOwnPropertyDescriptors", "getOwnPropertyNames", "entries", "keys", "values"]],
  [Object.prototype, newBodyThis, ["hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable", "toString", "toLocaleString"]],
  [Function.prototype, newBodyThis, ["toString", "toLocaleString"]]
]

propsToChange.forEach((val) => {
  val[2].forEach((prop) => {
    renewHandler(val[0], prop, makeClosureWithNameAndBody(val[1], prop))
  })
})

console.error(console.log.toString.hasOwnProperty("prototype"))
// ok: false

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

function f() {
    "use strict"
}

console.log(f.prototype) // {}
console.log(console.log.toString.prototype) // undefined
Object.defineProperty(f, "prototype", {value: undefined}) // easy fix
console.log(f.prototype) // undefined

Мы даже можем обмануть вызов создания объекта на такой функции (вместе с подхачиванием стека исключения):

function f() {
  if (new.target) {
    err =  new TypeError("f is not a constructor");
    st = err.stack.split('\n')
    st.splice(1, 1); // remove entry with f() call
    err.stack = st.join("\n")
    throw err;
  }
}

try {
    let z = new f();
} catch (e) {
    console.log(e.stack);
    /*
    TypeError: f is not a constructor
        at <anonymous>:12:13
    */
}

try {
    let x = () => {}
    let z = new x();
} catch (e) {
    console.log(e.stack);
    /*
    TypeError: x is not a constructor
        at <anonymous>:23:13
    */
}

Но есть то, что мы обмануть средствами JS не в силах:

function f() {}
console.log("prototype" in f) // true :(

Ведь нам нужны функции со свободным this потому что мы переопределяем функции прототипов, а они могут быть вызваны на разных объектах. Мы, конечно, можем переопределить еще bind, call и apply, но функции, обычно, зовутся как console.log.toString(), а не как Function.prototype.toString.call(console.log). Да и подменить прототип у всех объектов мы не сможем.

Но все же мы можем это сделать, немного пропатчив хромиум.

Для этого мы немного патчим V8, изменив генерацию байткода для оператора in. Логика такая: мы смотрим, есть ли у нас свойство с каким-то заданным названием (опять же: оно должно быть выбрано максимально рандомно, чтобы вас не смогли задетектить), и, если оно есть, мы сначала запускаем in в нем, и возвращаем false если мы его нашли. Иначе фолбечимся на старую логику.

void BytecodeGenerator::VisitCompareOperation(CompareOperation* expr) {
  Expression* sub_expr;
  Literal* literal;
  TestTypeOfFlags::LiteralFlag flag;
  if (IsLiteralCompareTypeof(expr, &sub_expr, &flag, ast_string_constants())) {
    // Emit a fast literal comparison for expressions of the form:
    // typeof(x) === 'string'.
    VisitForTypeOfValue(sub_expr);
    builder()->SetExpressionPosition(expr);
    if (flag == TestTypeOfFlags::LiteralFlag::kOther) {
      builder()->LoadFalse();
    } else {
      builder()->CompareTypeOf(flag);
    }
  } else if (expr->IsLiteralStrictCompareBoolean(&sub_expr, &literal)) {
    DCHECK(expr->op() == Token::kEqStrict);
    VisitForAccumulatorValue(sub_expr);
    builder()->SetExpressionPosition(expr);
    BuildLiteralStrictCompareBoolean(literal);
  } else if (expr->IsLiteralCompareUndefined(&sub_expr)) {
    VisitForAccumulatorValue(sub_expr);
    builder()->SetExpressionPosition(expr);
    BuildLiteralCompareNil(expr->op(), BytecodeArrayBuilder::kUndefinedValue);
  } else if (expr->IsLiteralCompareNull(&sub_expr)) {
    VisitForAccumulatorValue(sub_expr);
    builder()->SetExpressionPosition(expr);
    BuildLiteralCompareNil(expr->op(), BytecodeArrayBuilder::kNullValue);
  } else if (expr->IsLiteralCompareEqualVariable(&sub_expr, &literal) &&
             IsLocalVariableWithInternalizedStringHint(sub_expr)) {
    builder()->LoadLiteral(literal->AsRawString());
    builder()->CompareReference(
        GetRegisterForLocalVariable(sub_expr->AsVariableProxy()->var()));
  } else {
    if (expr->op() == Token::kIn && expr->left()->IsPrivateName()) {
      Variable* var = expr->left()->AsVariableProxy()->var();
      if (IsPrivateMethodOrAccessorVariableMode(var->mode())) {
        BuildPrivateMethodIn(var, expr->right());
        return;
      }
      // For private fields, the code below does the right thing.
    }

    Register lhs = VisitForRegisterValue(expr->left());
    auto object = VisitForRegisterValue(expr->right());
    FeedbackSlot slot;
    BytecodeLabel found;
    BytecodeLabel end;
    if (expr->op() == Token::kIn) {
      // if there truly exists "__do_not_show",
      auto foo_literal = zone()->New<Literal>(ast_string_constants()->__do_not_show_string(), expr->position());
      Register foo = VisitForRegisterValue(foo_literal);
      BytecodeLabel foo_not_exists;
      builder()->LoadAccumulatorWithRegister(object);
      builder()->SetExpressionPosition(expr);
      builder()->CompareOperation(expr->op(), foo, feedback_index(feedback_spec()->AddKeyedHasICSlot()));
      builder()->JumpIfFalse(ToBooleanMode::kAlreadyBoolean, &foo_not_exists);
      {
        // check if search pattern contains in object.__do_not_show
        builder()->LoadNamedProperty(
              object, ast_string_constants()->__do_not_show_string(),
              feedback_index(feedback_spec()->AddLoadICSlot()));

        builder()->CompareOperation(expr->op(), lhs, feedback_index(feedback_spec()->AddKeyedHasICSlot()));
        builder()->JumpIfTrue(ToBooleanMode::kAlreadyBoolean, &found);
      }

      builder()->Bind(&foo_not_exists);

      slot = feedback_spec()->AddKeyedHasICSlot();
    } else if (expr->op() == Token::kInstanceOf) {
      slot = feedback_spec()->AddInstanceOfSlot();
    } else {
      slot = feedback_spec()->AddCompareICSlot();
    }
    builder()->LoadAccumulatorWithRegister(object);
    builder()->SetExpressionPosition(expr);
    builder()->CompareOperation(expr->op(), lhs, feedback_index(slot));
    builder()->Jump(&end);

    builder()->Bind(&found);
    builder()->LoadFalse();
    builder()->Bind(&end);
  }
  // Always returns a boolean value.
  execution_result()->SetResultIsBoolean();
}

(Помимо этого, нужно потрогать еще файлики с константами и запустить питоновский скрипт для перегенерации заголовочных файлов, чтобы всяческие String Pool'ы были наполнены правильными значениями). В итоге мы получаем ожидаемое поведение:

let z = function () {}
console.log("prototype" in z) // true
z.__do_not_show = {"prototype": 1}
console.log("prototype" in z) // false

Итого:

let HIDE_ME = '__hide_me';
let DO_NOT_SHOW = '__do_not_show';

function renewHandler(obj, key, newProp) {
  // new property to save previous object
  // to use in handlers
  newProp = newProp(obj[key])
  newProp[HIDE_ME] = obj[key]
  obj[key] = newProp
}

function fakeThisHandler(prevHandler, ...args) {
  try {
    if (HIDE_ME in this) {
        return prevHandler.call(this[HIDE_ME], args)
    }
  } catch { /* ignore */ }
  return prevHandler.call(this, args)
}

let fakeHandler = (prevHandler, obj, ...args) => {
  try {
    if (HIDE_ME in obj) {
        return prevHandler(obj[HIDE_ME], ...args)
    }
  } catch { /* ignore */ }
  return prevHandler(obj, ...args)
}

function makeClosureWithNameAndBody(body, name, prev) {
  formattedBody = "return (prev) => {\n " + body.replaceAll("{name}", name) + "\nreturn " + name + "\n}"
  return Function(formattedBody)(prev)
}

let newBody = "let {name} = (objT, ...args) => fakeHandler(prev, objT, ...args)"
let newBodyThis = `
function {name} (...args) {
  if (new.target) {
    err = new TypeError("{name} is not a constructor");
    st = err.stack.split("\\n")
    st.splice(1, 1); // remove entry with function call
    err.stack = st.join("\\n")
    throw err;
  }
  return fakeThisHandler.call(this, prev, ...args)
}
Object.defineProperty({name}, "prototype", {value: undefined})
{name}.${DO_NOT_SHOW} = {"prototype": null}
`;

let propsToChange = [
  [Object, newBody, [
    "getOwnPropertyDescriptor", "getOwnPropertyDescriptors",
    "getOwnPropertyNames", "entries", "keys", "values",
    "getOwnPropertySymbols", "groupBy"
  ]],
  [Object.prototype, newBodyThis, ["hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable", "toString", "toLocaleString"]],
  [Function.prototype, newBodyThis, ["toString", "toLocaleString"]]
]

propsToChange.forEach((val) => {
  val[2].forEach((prop) => {
    renewHandler(val[0], prop, makeClosureWithNameAndBody(val[1], prop))
  })
})

console.error(console.log.toString.hasOwnProperty("prototype"))
// ok: false
console.error(console.log.toString.prototype)
// ok: undefined
console.error("prototype" in console.log.toString)
// ok: false
try {
    let x = new console.log.toString();
} catch (e) {
    console.error(e.stack);
    /* ok: 
    TypeError: toString is not a constructor
        at <anonymous>:74:13
    */
}

Теперь у нас есть почти все для того, чтобы мы смогли обмануть любой код, который считает отпечатки браузера. По мере обмана вычисления отпечатков различных компонент мы всё еще будем сталкиваться с проблемами, однако они будут гораздо проще, да и напишу я об этом в следующей части.

Приглашаю в свой (пока что пустой) tg канал: http://t.me/mrlolthe1st_ch

Пишите комментарии, как и что еще можно задектить или как решить проблемы, поднятые в статье проще (если это возможно).

Этичного хакинга!

Комментарии (2)


  1. CrazyHackGUT
    23.08.2025 18:17

    Первая мысль, когда я увидел патчинг v8 в Хромиуме: "а не проще было изначально пропатчить нужные объекты в рантайме"?


    1. mrlolthe1st Автор
      23.08.2025 18:17

      Это гораздо сложнее. Особенно, если уметь динамически от запуска к запуску что-то менять. Да, можно вытянуть из envrionment нужные значения, но тогда патч хромиума разрастается и становится огромным, мэйнтейнить это для новых версий становится неудобно и времязатратно. Поэтому был выбран путь минимальных правок в код хромиума.