shift-interpreter
Version:
Shift-interpreter is an experimental JavaScript meta-interpreter useful for reverse engineering and analysis. One notable difference from other projects is that shift-interpreter retains state over an entire script but can be fed expressions and statement
443 lines (398 loc) • 14.5 kB
text/typescript
import chalk from 'chalk';
import DEBUG from 'debug';
import { EventEmitter } from 'events';
import {
ArrayBinding,
BindingIdentifier,
BindingWithDefault,
EmptyStatement,
Expression,
Node,
ObjectBinding,
Script,
Statement,
VariableDeclaration,
VariableDeclarationStatement,
} from 'shift-ast';
import * as codegen from 'shift-printer';
import shiftScope, { Scope, ScopeLookup, Variable } from 'shift-scope';
import { BasicContext } from './context';
import { InterpreterRuntimeError } from './errors';
import { InstructionBuffer, Instruction } from './instruction-buffer';
import { NodeHandler } from './node-handler';
import { BlockType, FuncType, Identifier, InstructionNode } from './types';
import { isStatement } from './util';
const debug = DEBUG('shift-interpreter');
interface Options {
skipUnsupported?: boolean;
handler?: { new (interpreter: Interpreter): NodeHandler };
}
export enum InterpreterEventName {
COMPLETE = 'complete',
}
export abstract class InterpreterEvent {
static type = InterpreterEventName;
}
export class InterpreterCompleteEvent extends InterpreterEvent {
result: any;
constructor(result: any) {
super();
this.result = result;
}
}
export class Interpreter {
contexts: BasicContext[] = [];
globalScope: Scope = shiftScope(new Script({ directives: [], statements: [] }));
lookupTable: ScopeLookup = new ScopeLookup(this.globalScope);
scopeMap: WeakMap<Variable, Scope> = new WeakMap();
scopeOwnerMap: WeakMap<Node, Scope> = new WeakMap();
variableMap = new Map<Variable, any>();
options: Options;
loadedScript: Script = new Script({ directives: [], statements: [] });
handler: NodeHandler;
contextProxies = new WeakMap<typeof Proxy, any>();
pointer = new InstructionBuffer();
lastStatement: Statement = new EmptyStatement();
lastInstruction: Instruction = new Instruction(new EmptyStatement(), -1);
_isReturning: boolean = false;
_isBreaking: boolean = false;
_isContinuing: boolean = false;
errorLocation?: { lastInstruction: Instruction; lastStatement: Statement };
constructor(options: Options = {}) {
this.options = options;
if (this.options.handler) {
this.handler = new this.options.handler(this);
} else {
this.handler = new NodeHandler(this);
}
}
print(node?: Node) {
return codegen.prettyPrint(node || this.lastInstruction.node);
}
logNode(node: Node) {
codegen.log(node);
}
codegen(node: Node) {
codegen.printTruncated(node);
}
skipOrThrow(type: string) {
if (this.options.skipUnsupported) return;
throw new InterpreterRuntimeError(`Unsupported node ${type}`);
}
load(script: Script, context: BasicContext = {}) {
debug('loading script');
this.globalScope = shiftScope(script);
this.lookupTable = new ScopeLookup(this.globalScope);
this.buildScopeMap();
this.loadedScript = script;
this.pushContext(context);
}
private buildScopeMap() {
const lookupTable = this.lookupTable;
this.scopeMap = new WeakMap();
const recurse = (scope: Scope) => {
this.scopeOwnerMap.set(scope.astNode, scope);
scope.variableList.forEach((variable: Variable) => {
this.scopeMap.set(variable, scope);
});
scope.children.forEach(recurse);
};
recurse(lookupTable.scope);
}
pushContext(context: any) {
this.contexts.push(context);
}
popContext() {
return this.contexts.pop();
}
getCurrentContext() {
if (this.contexts.length === 0) {
debug('interpreter created with no context, creating empty context.');
this.pushContext({});
}
const context = this.contexts[this.contexts.length - 1];
if (context === undefined) return this.contexts[0];
return context;
}
getContexts() {
return this.contexts;
}
getContext(index: number) {
const context = this.contexts[index];
if (context === undefined) return this.contexts[0];
return context;
}
isReturning(state?: boolean) {
if (state !== undefined) this._isReturning = state;
return this._isReturning;
}
isBreaking(state?: boolean) {
if (state !== undefined) this._isBreaking = state;
return this._isBreaking;
}
isContinuing(state?: boolean) {
if (state !== undefined) this._isContinuing = state;
return this._isContinuing;
}
run(passedNode?: InstructionNode): Promise<any> {
let nodeToEvaluate: InstructionNode | undefined = undefined;
if (passedNode) {
if (passedNode.type === 'Script') {
this.load(passedNode);
}
nodeToEvaluate = passedNode;
} else if (this.loadedScript) {
nodeToEvaluate = this.loadedScript;
}
if (!this.loadedScript) {
// If we don't have a currentScript (haven't run load()) but were passed a node
// the node must be a Statement or Expression (or bad object) and we shouldn't run
// it without the user knowing what they are doing.
if (passedNode)
throw new InterpreterRuntimeError(
`Can not evaluate ${passedNode.type} node without loading a program (Script node) first.`,
);
else throw new InterpreterRuntimeError('No program to evaluate');
}
if (!nodeToEvaluate) {
throw new InterpreterRuntimeError('No program to evaluate');
}
debug('starting execution');
let programResult: any = null;
try {
programResult = this.evaluate(nodeToEvaluate);
debug(`completed execution with result: %o`, programResult);
return programResult;
} catch (e) {
this.errorLocation = {
lastStatement: this.lastStatement,
lastInstruction: this.lastInstruction,
};
this.handleError(e);
throw e;
}
}
private handleError(e: any) {
debug.extend('error')(`Error during execution`);
if (this.errorLocation) {
const statementSrc = codegen.printSummary(this.errorLocation.lastStatement);
const nodeSrc = codegen.printSummary(this.errorLocation.lastInstruction.node);
console.log(statementSrc.replace(nodeSrc, `👉👉👉${chalk.red(nodeSrc)}`));
} else {
console.log('No error location recorded.');
}
}
runToFirstError(passedNode?: Script | Statement | Expression) {
try {
return this.run(passedNode);
} catch (e) {}
}
evaluateInstruction(instruction: Instruction) {
this.lastInstruction = instruction;
const node = instruction.node;
if (isStatement(node)) this.lastStatement = node;
let result = this.handler[node.type](node);
return (instruction.result = result);
}
evaluate(node: InstructionNode | null): any {
if (node === null) {
return undefined;
} else {
// yeah this looks weird, it's a remnant of when this was all async. You used
// to be able to stop, start, and break the program but the implementation with native JS promises
// caused problems. I'm keeping the instruction buffer because hey, maybe it'll come back.
this.pointer.add(node);
const instruction = this.pointer.nextInstruction();
if (!instruction) {
debug(`no instruction to evaluate, returning`);
return;
}
debug(`evaluating instruction from %o -> %o`, this.lastInstruction.node.type, node.type);
return this.evaluateInstruction(instruction);
}
}
hoistFunctions(block: BlockType) {
const functions = block.statements.filter(s => s.type === 'FunctionDeclaration');
if (functions.length) debug(`hoisting %o functions in %o`, functions.length, block.type);
for (let fnDecl of functions) {
this.evaluate(fnDecl);
}
}
hoistVars(block: BlockType) {
const vars = block.statements
.filter(
<(T: Statement) => T is VariableDeclarationStatement>(stmt => stmt.type === 'VariableDeclarationStatement'),
)
.filter((decl: VariableDeclarationStatement) => decl.declaration.kind === 'var');
if (vars.length) debug(`hoisting %o var statements in %o`, vars.length, block.type);
for (let varDecl of vars) {
for (let declarator of varDecl.declaration.declarators) this.bindVariable(declarator.binding, undefined);
}
}
declareVariables(decl: VariableDeclaration) {
for (let declarator of decl.declarators) {
this.evaluate(declarator);
}
}
createFunction(node: FuncType) {
const _debug = debug.extend('createFunction');
let name: string | undefined = undefined;
if (node.name) {
switch (node.name.type) {
case 'BindingIdentifier':
name = node.name.name;
break;
case 'ComputedPropertyName':
name = this.evaluate(node.name.expression);
break;
case 'StaticPropertyName':
name = node.name.value;
}
}
_debug(`creating intermediary %o: %o`, node.type, name);
const interpreter = this;
const fnDebug = debug.extend('function');
let fn: (this: any, ...args: any) => any;
// anonymous functions have an empty string as the name
if (!name) name = '';
// creating a function like this, i.e. { someName: function(){} )
// allows us to create a named function by inferring the name from the property value.
fn = {
[name]: function(this: any, ...args: any): any {
fnDebug(`calling intermediary %o: %o`, node.type, name);
interpreter.pushContext(this);
const scope = interpreter.scopeOwnerMap.get(node);
if (scope) {
const argsRef = scope.variables.get('arguments');
if (argsRef) interpreter.setRuntimeValue(argsRef, arguments);
}
if (node.type === 'Getter') {
// nothing
} else if (node.type === 'Setter') {
fnDebug(`setter: binding passed parameter`);
interpreter.bindVariable(node.param, args[0]);
} else {
node.params.items.forEach(
(el: ArrayBinding | BindingIdentifier | BindingWithDefault | ObjectBinding, i: number) => {
fnDebug(`binding function argument %o`, i + 1);
return interpreter.bindVariable(el, args[i]);
},
);
}
fnDebug('evaluating function body');
const result = interpreter.evaluate(node.body);
fnDebug('completed evaluating function body');
interpreter.popContext();
if (new.target) {
if (interpreter.isReturning()) {
interpreter.isReturning(false);
if (typeof result === 'object') return result;
}
return this;
} else {
if (interpreter.isReturning()) {
interpreter.isReturning(false);
}
return result;
}
},
}[name];
return Object.assign(fn, { _interp: true });
}
bindVariable(binding: BindingIdentifier | ArrayBinding | ObjectBinding | BindingWithDefault, init: any) {
const _debug = debug.extend('bindVariable');
switch (binding.type) {
case 'BindingIdentifier':
{
const variables = this.lookupTable.variableMap.get(binding);
if (variables.length > 1) throw new Error('reproduce this and handle it better');
const variable = variables[0];
_debug(`binding %o to %o`, binding.name, init);
this.setRuntimeValue(variable, init);
}
break;
case 'ArrayBinding':
{
for (let i = 0; i < binding.elements.length; i++) {
const el = binding.elements[i];
const indexElement = init[i];
if (el) this.bindVariable(el, indexElement);
}
if (binding.rest) this.skipOrThrow('ArrayBinding->Rest/Spread');
}
break;
case 'ObjectBinding':
{
for (let i = 0; i < binding.properties.length; i++) {
const prop = binding.properties[i];
if (prop.type === 'BindingPropertyIdentifier') {
const name = prop.binding.name;
if (init[name] === undefined && prop.init) {
this.bindVariable(prop.binding, this.evaluate(prop.init));
} else {
this.bindVariable(prop.binding, init[name]);
}
} else {
const name =
prop.name.type === 'ComputedPropertyName' ? this.evaluate(prop.name.expression) : prop.name.value;
this.bindVariable(prop.binding, init[name]);
}
}
if (binding.rest) this.skipOrThrow('ObjectBinding->Rest/Spread');
}
break;
case 'BindingWithDefault':
if (init === undefined) {
_debug(`evaluating default for undefined argument`);
const defaults = this.evaluate(binding.init);
_debug(`binding default`);
this.bindVariable(binding.binding, defaults);
} else {
this.bindVariable(binding.binding, init);
}
break;
}
}
updateVariableValue(node: Identifier, value: any) {
const variables = this.lookupTable.variableMap.get(node);
if (variables.length > 1) throw new Error('reproduce this and handle it better');
const variable = variables[0];
const decl = variable.declarations[0];
if (decl && decl.type.name === 'Const') throw new TypeError('Assignment to constant variable.');
this.setRuntimeValue(variable, value);
return value;
}
setRuntimeValue(variable: Variable, value: any) {
this.variableMap.set(variable, value);
}
getRuntimeValue(node: Identifier): any {
const _debug = debug.extend('getVariableValue');
_debug(`retrieving value for ${node.name}`);
const variables = this.lookupTable.variableMap.get(node);
if (!variables) {
throw new Error(`${node.type} variable not found. Make sure you are passing a valid Identifier node.`);
}
if (variables.length > 1) {
_debug(`>1 variable returned, ${variables}`);
throw new Error('reproduce this and handle it better');
}
const variable = variables[0];
if (this.variableMap.has(variable)) {
const value = this.variableMap.get(variable);
return value;
} else {
const contexts = this.getContexts();
for (let i = contexts.length - 1; i > -1; i--) {
const context = this.getContext(i);
if (!context) {
throw new Error('No context to evaluate in.');
}
if (variable.name in context) {
const value = context[variable.name];
return value;
}
}
throw new ReferenceError(`${node.name} is not defined`);
}
}
}