Class: Vangrail::Rails::Obfuscation

Inherits:
Vangrail::Rail show all
Defined in:
lib/vangrail/rails/obfuscation.rb

Overview

Runs other rails again over the text an attacker actually meant.

Every pattern rail reads what is written. An attacker who knows that writes it differently: base64 the paragraph and ask the model to decode it, rot13 it, spell it with Cyrillic letters that look like Latin ones, put zero-width joiners between the letters of "ignore", or set a right-to-left override so the rendered page and the byte sequence say different things. The model reads through all of it, because that is what models do, and the regexps see nothing.

The answer is not more patterns. It is to undo the encoding and run the rails that already exist over the result, which is why this takes a rail list rather than defining checks of its own:

Rails::Obfuscation.new(rails: [Rails::InjectedInstructions.new,
                             Rails::Jailbreak.new])

Each transform is applied on its own, and a variant identical to the original is dropped, so ordinary text costs one comparison per transform and nothing else. A hit names both the rail and the encoding it was hiding under, because "blocked" without that is unactionable for whoever has to look at the page. A child that rewrites a variant (a key inside a decoded blob) is spliced back into the page, or into that variant when the variant is the page (the invisible-character strip). The decoded form is not published in place of the document.

Invisible characters are handled here directly rather than by a delegate: they are stripped, and the strip is reported as a rewrite. A zero-width joiner inside a word has no honest use in a handbook, and removing it costs a reader nothing while denying the cheapest bypass there is.

What this does not do is guess. There is no scoring, no entropy threshold, no "this looks encoded" heuristic that would fire on the base64 blobs and hashes a cluster handbook is full of. A blob either decodes to text a rail objects to, or it does not.

Constant Summary collapse

INVISIBLE =

Zero-width and bidi control characters. The first four are the invisible separators; the bidi set is the trojan-source family, where the rendered order and the stored order disagree. The last one is the replacement character, which is what a scrub leaves where an invalid byte was. Garbage bytes inside a keyword are the same move as a zero-width joiner with a cruder tool: they break a pattern without changing what a model reads, and scrubbing restores validity rather than the phrase. Legitimate text does not carry them.

/[\u200B-\u200D\u2060\uFEFF\u180E\u202A-\u202E\u2066-\u2069\uFFFD\u00AD\u0008]/
BASE64 =

A base64 run long enough to hold a sentence. Below this the decode is noise, and a handbook is full of short tokens that happen to be in the alphabet.

Bounded by lookaround rather than \b, because + and / are not word characters: a blob ending in one had its last character trimmed off the match, and a base64 string one character short decodes to a sentence with its tail missing. That cost the corpus a case, and the case it cost was an HTML comment, whose pattern needs the closing marker.

/(?<![A-Za-z0-9+\/=])[A-Za-z0-9+\/]{24,}={0,2}(?![A-Za-z0-9+\/=])/

Constants inherited from Vangrail::Rail

Vangrail::Rail::DEFAULT_SIDES, Vangrail::Rail::SIDES

Instance Attribute Summary collapse

Attributes inherited from Vangrail::Rail

#name, #sides

Instance Method Summary collapse

Methods inherited from Vangrail::Rail

#applies_to?, #call, #language_agnostic?, #placeholder?, #posture?, #quantifies?, #to_s, usable

Constructor Details

#initialize(rails:, transforms: %i[invisible confusables confusables_all rot13 base64 nfkc],, name: 'obfuscation', sides: %i[input context])) ⇒ Obfuscation

Returns a new instance of Obfuscation.



67
68
69
70
71
72
# File 'lib/vangrail/rails/obfuscation.rb', line 67

def initialize(rails:, transforms: %i[invisible confusables confusables_all rot13 base64 nfkc],
               name: 'obfuscation', sides: %i[input context])
  super(name: name, sides: sides)
  @rails = Array(rails)
  @transforms = Array(transforms).map(&:to_sym)
end

Instance Attribute Details

#railsObject (readonly)

Returns the value of attribute rails.



65
66
67
# File 'lib/vangrail/rails/obfuscation.rb', line 65

def rails
  @rails
end

#transformsObject (readonly)

Returns the value of attribute transforms.



65
66
67
# File 'lib/vangrail/rails/obfuscation.rb', line 65

def transforms
  @transforms
end

Instance Method Details

#cache_key(text, _context) ⇒ Object



80
81
82
# File 'lib/vangrail/rails/obfuscation.rb', line 80

def cache_key(text, _context)
  text if offline?
end

#decide(text, context) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/vangrail/rails/obfuscation.rb', line 84

def decide(text, context)
  body = text.to_s
  stripped = body.gsub(INVISIBLE, '')

  hit, uncertain = first_objection(body, stripped, context)
  return hit if hit
  return unchecked(uncertain.reason) if uncertain

  return pass if stripped == body

  modify(stripped, categories: ['invisible_characters'],
                   reason: 'removed zero-width or bidi control characters')
end

#offline?Boolean

Only if everything it delegates to is. A wrapper around a model rail inherits the model rail's posture.

Returns:

  • (Boolean)


76
77
78
# File 'lib/vangrail/rails/obfuscation.rb', line 76

def offline?
  rails.all?(&:offline?)
end

#variants(text) ⇒ Object

The decoded forms of a text, labelled. Public because an application that logs a blocked page wants to show what it decoded to.



100
101
102
103
104
105
106
107
108
# File 'lib/vangrail/rails/obfuscation.rb', line 100

def variants(text)
  body = text.to_s
  transforms.filter_map do |name|
    decoded = apply(name, body)
    next if decoded.nil? || decoded == body || decoded.strip.empty?

    [name, decoded]
  end
end