Skip to content

Commit

Permalink
Fixes large-string expansion in JSObfu.
Browse files Browse the repository at this point in the history
  • Loading branch information
joevennix committed Apr 9, 2014
1 parent c72c96f commit 14fed8c
Show file tree
Hide file tree
Showing 3 changed files with 181 additions and 74 deletions.
135 changes: 85 additions & 50 deletions lib/rex/exploitation/jsobfu.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,68 @@ module Exploitation
#
class JSObfu

# these keywords should never be used as a random var name
# source: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Reserved_Words
RESERVED_KEYWORDS = %w(
break case catch continue debugger default delete do else finally
for function if in instanceof new return switch this throw try
typeof var void while with class enum export extends import super
implements interface let package private protected public static yield
)
# A single Javascript scope, used as a key-value store
# to maintain uniqueness of members in generated closures.
# For speed this class is implemented as a subclass of Hash.
class Scope < Hash

# these keywords should never be used as a random var name
# source: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Reserved_Words
RESERVED_KEYWORDS = %w(
break case catch continue debugger default delete do else finally
for function if in instanceof new return switch this throw try
typeof var void while with class enum export extends import super
implements interface let package private protected public static yield
)

# these vars should not be shadowed as they may be used in other obfuscated code
BUILTIN_VARS = %w(
String window unescape
)

# @param [Rex::Exploitation::JSObfu::Scope] parent an optional parent scope,
# sometimes necessary to prevent needless var shadowing
def initialize(parent=nil)
@parent = parent
@rand_gen = Rex::RandomIdentifierGenerator.new(
:max_length => 15,
:first_char_set => Rex::Text::Alpha+"_$",
:char_set => Rex::Text::AlphaNumeric+"_$"
)
end

# @return [String] a unique random var name that is not a reserved keyword
def random_var_name
loop do
text = random_string
unless has_key?(text) or
RESERVED_KEYWORDS.include?(text) or
BUILTIN_VARS.include?(text)

self[text] = nil
return text
end
end
end

# @return [Boolean] var is in scope
def has_key?(key)
super or (@parent and @parent.has_key?(key))
end

# @return [String] a random string
def random_string
@rand_gen.generate
end

end

#
# The maximum length of a string that will be passed through
# #transform_string without being chopped up into separate
# expressions and concatenated
#
MAX_STRING_CHUNK = 10000

#
# Abstract Syntax Tree generated by RKelly::Parser#parse
Expand All @@ -69,12 +123,8 @@ def initialize(code)
@code = code
@funcs = {}
@vars = {}
@scope = Scope.new
@debug = false
@rand_gen = Rex::RandomIdentifierGenerator.new(
:max_length => 15,
:first_char_set => Rex::Text::Alpha+"_$",
:char_set => Rex::Text::AlphaNumeric+"_$"
)
end

#
Expand Down Expand Up @@ -122,23 +172,8 @@ def obfuscate
obfuscate_r(@ast)
end

# @return [String] a unique random var name that is not a reserved keyword
def random_var_name
loop do
text = random_string
unless @vars.has_value?(text) or RESERVED_KEYWORDS.include?(text)
return text
end
end
end

protected

# @return [String] a random string
def random_string
@rand_gen.generate
end

#
# Recursive method to obfuscate the given +ast+.
#
Expand Down Expand Up @@ -182,12 +217,12 @@ def obfuscate_r(ast)
# Variables
when ::RKelly::Nodes::VarDeclNode
if @vars[node.name].nil?
@vars[node.name] = random_var_name
@vars[node.name] = @scope.random_var_name
end
node.name = @vars[node.name]
when ::RKelly::Nodes::ParameterNode
if @vars[node.value].nil?
@vars[node.value] = random_var_name
@vars[node.value] = @scope.random_var_name
end
node.value = @vars[node.value]
when ::RKelly::Nodes::ResolveNode
Expand All @@ -209,7 +244,7 @@ def obfuscate_r(ast)
# Functions can also act as objects, so store them in the vars
# and the functions list so we can replace them in both places
if @funcs[node.value].nil? and not @funcs.values.include?(node.value)
@funcs[node.value] = random_var_name
@funcs[node.value] = @scope.random_var_name
if @vars[node.value].nil?
@vars[node.value] = @funcs[node.value]
end
Expand Down Expand Up @@ -311,14 +346,16 @@ def transform_string(str)
str = str[1,str.length - 2]
return quote*2 if str.length == 0

if str.length > MAX_STRING_CHUNK
return safe_split(str, quote).map { |args| transform_string(args[1]) }.join('+')
end

transformed = nil
case rand(2)
when 0
transformed = transform_string_split_concat(str, quote)
when 1
transformed = transform_string_fromCharCode(str)
#when 2
# # Currently no-op
# transformed = transform_string_unescape(str)
end

#$stderr.puts "Obfuscating str: #{str.ljust 30} #{transformed}"
Expand Down Expand Up @@ -353,12 +390,12 @@ def safe_split(str, quote)
break unless str[len]
break if len > max_len
# randomize the length of each part
break if (rand(4) == 0)
break if (rand(max_len) == 0)
end

part = str.slice!(0, len)

var = Rex::Text.rand_text_alpha(4)
var = @scope.random_var_name
parts.push( [ var, "#{quote}#{part}#{quote}" ] )
end

Expand Down Expand Up @@ -409,12 +446,6 @@ def transform_string_split_concat(str, quote)
final
end


# TODO
#def transform_string_unescape(str)
# str
#end

#
# Return a call to String.fromCharCode() with each char of the input as arguments
#
Expand All @@ -423,9 +454,17 @@ def transform_string_split_concat(str, quote)
# output: String.fromCharCode(0x41, 10)
#
def transform_string_fromCharCode(str)
buf = "String.fromCharCode("
bytes = str.unpack("C*")
"String.fromCharCode(#{string_to_bytes(str)})"
end

#
# Return a comma-separated list of byte values with random encodings (decimal/hex/octal)
#
def string_to_bytes(str)
len = 0
bytes = str.unpack("C*")
encoded_bytes = []

while str.length > 0
if str[0,1] == "\\"
str.slice!(0,1)
Expand Down Expand Up @@ -472,16 +511,12 @@ def transform_string_fromCharCode(str)
else
char = str.slice!(0,1).unpack("C").first
end
buf << "#{rand_base(char)},"
encoded_bytes << rand_base(char)
end
# Strip off the last comma
buf = buf[0,buf.length-1] + ")"
transformed = buf

transformed
encoded_bytes.join(',')
end


end
end
end
Expand Down
50 changes: 50 additions & 0 deletions spec/lib/rex/exploitation/jsobfu_scope_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
require 'spec_helper'
require 'rex/exploitation/jsobfu'

describe Rex::Exploitation::JSObfu::Scope do

subject(:scope) do
described_class.new()
end

describe '#random_var_name' do
subject(:random_var_name) { scope.random_var_name }

it { should be_a String }
it { should_not be_empty }

it 'is composed of _, $, alphanumeric chars' do
20.times { expect(scope.random_var_name).to match(/\A[a-zA-Z0-9$_]+\Z/) }
end

it 'does not start with a number' do
20.times { expect(scope.random_var_name).not_to match(/\A[0-9]/) }
end

context 'when a reserved word is generated' do
let(:reserved) { described_class::RESERVED_KEYWORDS.first }
let(:random) { 'abcdef' }
let(:generated) { [reserved, reserved, reserved, random] }

before do
scope.stub(:random_string) { generated.shift }
end

it { should eq random }
end

context 'when a non-unique random var is generated' do
let(:preexisting) { 'preexist' }
let(:random) { 'abcdef' }
let(:generated) { [preexisting, preexisting, preexisting, random] }

before do
scope.stub(:random_string) { generated.shift }
scope[preexisting] = 1
end

it { should eq random }
end
end

end
70 changes: 46 additions & 24 deletions spec/lib/rex/exploitation/jsobfu_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,66 @@
described_class.new("")
end

describe '#random_var_name' do
subject(:random_var_name) { jsobfu.random_var_name }
# surround the string in quotes
def quote(str, q='"'); "#{q}#{str}#{q}" end

it { should be_a String }
it { should_not be_empty }
describe '#transform_string' do
context 'when given a string of length > MAX_STRING_CHUNK' do
let(:js_string) { quote "ABC"*Rex::Exploitation::JSObfu::MAX_STRING_CHUNK }

it 'is composed of _, $, alphanumeric chars' do
20.times { expect(jsobfu.random_var_name).to match(/\A[a-zA-Z0-9$_]+\Z/) }
it 'calls itself recursively' do
expect(jsobfu).to receive(:transform_string).at_least(2).times.and_call_original
jsobfu.send(:transform_string, js_string.dup)
end
end

it 'does not start with a number' do
20.times { expect(jsobfu.random_var_name).not_to match(/\A[0-9]/) }
context 'when given a string of length < MAX_STRING_CHUNK' do
let(:js_string) { quote "A"*(Rex::Exploitation::JSObfu::MAX_STRING_CHUNK/2).to_i }

it 'does not call itself recursively' do
expect(jsobfu).to receive(:transform_string).once.and_call_original
jsobfu.send(:transform_string, js_string.dup)
end
end
end

context 'when a reserved word is generated' do
let(:reserved) { described_class::RESERVED_KEYWORDS.first }
let(:random) { 'abcdef' }
let(:generated) { [reserved, reserved, reserved, random] }
describe '#safe_split' do
let(:js_string) { Rex::Text.to_hex("ABCDEFG"*100, "\\x") }
let(:quote) { '"' }
let(:parts) { 50.times.map { jsobfu.send(:safe_split, js_string.dup, quote).map{ |a| a[1] } } }

before do
jsobfu.stub(:random_string) { generated.shift }
describe 'quoting' do
context 'when given a double-quote' do
let(:quote) { '"' }
it 'surrounds all the split strings with the same quote' do
expect(parts.flatten.all? { |part| part.start_with?(quote) }).to be_true
end
end

it { should be random }
context 'when given a single-quote' do
let(:quote) { "'" }
it 'surrounds all the split strings with the same quote' do
expect(parts.flatten.all? { |part| part.start_with?(quote) }).to be_true
end
end
end

context 'when a non-unique random var is generated' do
let(:preexisting) { 'preexist' }
let(:random) { 'abcdef' }
let(:vars) { { 'jQuery' => preexisting } }
let(:generated) { [preexisting, preexisting, preexisting, random] }
describe 'splitting' do
context 'when given a hex-escaped series of bytes' do
let(:js_string) { Rex::Text.to_hex("ABCDEFG"*100, "\\x") }

before do
jsobfu.stub(:random_string) { generated.shift }
jsobfu.instance_variable_set("@vars", vars)
it 'never splits in the middle of a hex escape' do
expect(parts.flatten.all? { |part| part.start_with?('"\\') }).to be_true
end
end

it { should be random }
context 'when given a unicode-escaped series of bytes' do
let(:js_string) { Rex::Text.to_unescape("ABCDEFG"*100).gsub!('%', '\\') }

it 'never splits in the middle of a unicode escape' do
expect(parts.flatten.all? { |part| part.start_with?('"\\') }).to be_true
end
end
end
end

Expand Down

0 comments on commit 14fed8c

Please sign in to comment.