UNPKG

@hclsoftware/secagent

Version:

IAST agent

267 lines (249 loc) 13.7 kB
//IASTIGNORE /* * **************************************************** * Licensed Materials - Property of HCL. * (c) Copyright HCL Technologies Ltd. 2017, 2025. * Note to U.S. Government Users *Restricted Rights. * **************************************************** */ const {SinkTask} = require("./SinkTask") const TaskType = require("./TaskType") const {VulnerabilityContext} = require("./VulnerabilityContext") const ContextType = require("./ContextType") const ContextInfo = require("./ContextInfo") const {ParameterUnderTest} = require("./ParameterUnderTest") const SinkTaskInfo = require("./SinkInfo/SinkTaskInfo") const COMMENT_CHARS = { "|" : "\\|", ";" : ";", "&" : "&", "(" : "\\(", ")" : "\\)", ">" : ">", "$" : "\\$", "!" : "!", "#" : "#", "`" : "`", "<" : "<", "@" : "@", "^" : "\\^", "%" : "%", "-" : "-", "." : "\\.", ":" : ":" } const SHELL_COMMAND_CHARS = { "`" : "`", "$(": "\\$\\(" } const POWER_SHELL_ESCAPABLE_CHARS = { "$" : "\\$", "(" : "\\(", "`" : "`" } const ctxCloser ={ "'" : ["'"], "\"": ["\""], ")": ["\\)"], "]": ["]"], "}": ["}"], } const ctxOpeners = { "'" : ContextType.SINGLE_QUOTE, "\"": ContextType.DOUBLE_QUOTE, "(": ContextType.PARENTHESES, "[": ContextType.BRACKETS, "{": ContextType.BRACES, "$(": ContextType.DOLLAR_SINGLE_PARENTHESES, "@(": ContextType.AT_SINGLE_PARENTHESES } // THE POWERSHELL COMMANDING ALGORITHM : // primitives to escape: // ["|",";","&","(",")",">","$","!","#","`","<","@","^","%","-",".",":"] // // SINGLE_QUOTE: // a.['] must be replaced AND un escaping one of primitives above // DOUBLE_QUOTE: // a.$()-if un escaped then vurn // b.["] must be escaped AND un escaping one of primitives above // BRACKETS[]: // as double quote: // a.$()-if un escaped then vurn // b.["]"] must be escaped AND un escaping one of primitives above // BRACES {}: // a. ["}"] must be escaped AND un escaping one of primitives above // PARENTHESES (): // a. [")"] must be escaped AND un escaping one of primitives above // UNQUOTED: // a.un escaping one of primitives above // DOLLAR_SINGLE_PARENTHESES $(): // a.as un-quoted // AT_SINGLE_PARENTHESES @(): // a.as un-quoted class PowerShellOsCommandingSinkTask extends SinkTask { constructor(source, v, stack, parameters, obj) { super(source, v, stack, parameters); this.taskType = TaskType.OS_COMMANDING_SINK this.command = obj == null ? null : obj.toString() } performAction() { // We have a list of verifications and sanitizers that have been run on a string. In order to validate that they actually work, we need // to send through multiple tainted strings and see whether they were validated or not. // return value of source - original param let parameterUnderTest = new ParameterUnderTest(this.taintedObjectFlow.entity.value, this.taintedObjectFlow.taskList) let pSOsCommandingContext = new PSOsCommandingContext(parameterUnderTest, this.command) this.performContextsAnalysis(pSOsCommandingContext, parameterUnderTest); return "" } runContextAwareAnalysis(contextType, parameterUnderTest) { let exploitList let containUnescapeContext = false for (let currentContext of contextType.getContextTypesStackBased()) { if (currentContext === ContextType.SINGLE_QUOTE.contextTypeName) { let processingResult = parameterUnderTest.getMutatedProcessedParam("'") let processedLegitimateExploitForCodePath = processingResult.afterProcessing if (processedLegitimateExploitForCodePath != null) { if (processedLegitimateExploitForCodePath.origStringIncludes("'")) containUnescapeContext = true } } else if (currentContext === ContextType.DOUBLE_QUOTE.contextTypeName || currentContext === ContextType.BRACKETS.contextTypeName || currentContext === ContextType.BRACES.contextTypeName || currentContext === ContextType.PARENTHESES.contextTypeName) { if (currentContext === ContextType.DOUBLE_QUOTE.contextTypeName || currentContext === ContextType.BRACKETS.contextTypeName) { // in case of ["\"", "[]"] context - first test if ["$("] is replaced - context does not matter // otherwise continue testing with all other characters AND their context exploitList = Object.keys(SHELL_COMMAND_CHARS) for (let exploit of exploitList) { let processingResult = parameterUnderTest.getMutatedProcessedParam(exploit) let processedLegitimateExploitForCodePath = processingResult.afterProcessing if (processedLegitimateExploitForCodePath != null) { if (SinkTask.containsUnescapedChars(processedLegitimateExploitForCodePath, POWER_SHELL_ESCAPABLE_CHARS, false, '`')){ return new SinkTaskInfo(exploit, [currentContext], exploitList, this.vulnerability) } } } } let processingResult = parameterUnderTest.getMutatedProcessedParam(ContextType[currentContext].getCtxCloser) let processedLegitimateExploitForCodePath = processingResult.afterProcessing if (processedLegitimateExploitForCodePath != null) { if (SinkTask.containsUnescapedChars(processedLegitimateExploitForCodePath, ctxCloser[ContextType[currentContext].getCtxCloser], false, '`')) containUnescapeContext = true } } if (currentContext === ContextType.UNQUOTED.contextTypeName || containUnescapeContext) { let exploitList = Object.keys(COMMENT_CHARS) for (let exploit of exploitList) { let processingResult = parameterUnderTest.getMutatedProcessedParam(exploit) let processedLegitimateExploitForCodePath = processingResult.afterProcessing if (processedLegitimateExploitForCodePath != null) { if (this.containsBlacklistedChars(processedLegitimateExploitForCodePath)) { return new SinkTaskInfo(exploit, [currentContext], exploitList, this.vulnerability) } } } } } return null; } containsBlacklistedChars(possiblySanitizedPayload){ return PowerShellOsCommandingSinkTask.containsBlacklistedChars(possiblySanitizedPayload) } static containsBlacklistedChars(possiblySanitizedPayload){ return SinkTask.containsUnescapedChars(possiblySanitizedPayload, COMMENT_CHARS, false, '`'); } getCommentChars() { return Object.keys(COMMENT_CHARS); } } class PSOsCommandingContext extends VulnerabilityContext { constructor(parameter, pSOsCommandingContext) { super(pSOsCommandingContext, parameter); } estimateContextStackBased(leftEdge, rightEdge) { let leftCtxStack =[] let rightCtxStack =[] var currentCtx = null; let inSingleQuoteCtx = false; let inDoubleQuoteCtx = false; let dollarParenthesisInDoubleQuoteCtx = false; let dollarParenthesisInDoubleQuoteCtxCounter = 0 for(let i = 0; i < leftEdge.length; i++) { let c if(i > 0) { if(leftEdge.charAt(i-1) === '`') continue; } c = leftEdge.charAt(i) // check if we are in $() context if(i + 1 < leftEdge.length && leftEdge.charAt(i) === '$' && leftEdge.charAt(i + 1) === '(') { i++ c = "$(" } // check if we are in @() context if(i + 1 < leftEdge.length && leftEdge.charAt(i) === '@' && leftEdge.charAt(i + 1) === '(') { i++ c = "@(" } //Check if we can close the current context if(currentCtx != null && currentCtx.getCtxCloser != null && currentCtx.getCtxCloser === c) { leftCtxStack.pop() // in case we are in double context we do not count parenthesis UNLESS we are in nested DOLLAR_SINGLE_PARENTHESES context // within doubleQuote context (ex: \"$((INPUT))\") - then we DO have to follow the parenthesis. // "dollarParenthesisInDoubleQuoteCtx" is the flag for this case and if we close DOLLAR_SINGLE_PARENTHESES context // we need to set the boolean flag to its actual value // in case of nested DOLLAR_SINGLE_PARENTHESES context within doubleQuote (ex: \"$($((INPUT)))\") "backTicInDoubleQuoteCtx" // would be true only when we close the most external backtick( $( ) in the ex) if(inDoubleQuoteCtx && currentCtx === ContextType["DOLLAR_SINGLE_PARENTHESES"]){ dollarParenthesisInDoubleQuoteCtxCounter-- } currentCtx = !leftCtxStack.length ? null : ContextType[leftCtxStack[leftCtxStack.length -1 ]] } // Check if we are opening a new context // Can only open a new context if we're not in a single quoted context else if (!inSingleQuoteCtx && !inDoubleQuoteCtx && Object.keys(ctxOpeners).origArrayIncludes(c)){ currentCtx = ctxOpeners[c] leftCtxStack.push(currentCtx.contextTypeName) } // can open only DOLLAR_SINGLE_PARENTHESES context UNLESS dollarParenthesisInDoubleQuoteCtx is TRUE // in case we are in double context we do not count parenthesis UNLESS we are in nested DOLLAR_SINGLE_PARENTHESES context // within doubleQuote context (ex: \"$((INPUT))\") - then we DO have to follow the parenthesis. // "dollarParenthesisInDoubleQuoteCtx" is the flag for this case and if we close DOLLAR_SINGLE_PARENTHESES context // we need to set the boolean flag to its actual value // in case of nested DOLLAR_SINGLE_PARENTHESES context within doubleQuote (ex: \"$($((INPUT)))\") "dollarParenthesisInDoubleQuoteCtx" // would be true only when we close the most external dollar parenthesis else if(inDoubleQuoteCtx){ if(dollarParenthesisInDoubleQuoteCtxCounter && Object.keys(ctxOpeners).origArrayIncludes(c) || c === "$("){ currentCtx = ctxOpeners[c] leftCtxStack.push(currentCtx.contextTypeName) } if(c === "$(") dollarParenthesisInDoubleQuoteCtxCounter++ } inSingleQuoteCtx = currentCtx === ContextType["SINGLE_QUOTE"] inDoubleQuoteCtx = leftCtxStack.origArrayIncludes(ContextType["DOUBLE_QUOTE"].contextTypeName) } // Furthermore, in most cases (except when in a DOUBLE_QUOTE context) , if currentCtx is DOLLAR_SINGLE_PARENTHESES we can run commands directly // We can definitively say a DOLLAR_SINGLE_PARENTHESES context is unquoted IFF it's not within a DOUBLE_QUOTE context if(currentCtx == null || currentCtx === ContextType["DOLLAR_SINGLE_PARENTHESES"] || currentCtx === ContextType["AT_SINGLE_PARENTHESES"]){ return new ContextInfo([ContextType["UNQUOTED"].contextTypeName]) } // At this point, leftCtxStack holds all the unterminated contexts from the left of the user parameter // Scanning the rightEdge we're trying to match each open context with a terminator // Each terminator we find is added to the rightCtxStack for(const c of rightEdge){ if(currentCtx == null || currentCtx.getCtxCloser === c){ rightCtxStack.push(currentCtx.contextTypeName) leftCtxStack.pop() currentCtx = !leftCtxStack.length ? null : ContextType[leftCtxStack[leftCtxStack.length-1]] } if(currentCtx == null || currentCtx === ContextType["DOLLAR_SINGLE_PARENTHESES"] || currentCtx === ContextType["AT_SINGLE_PARENTHESES"]) break; } // When we get to this point, we should have depleted all the unterminated contexts in leftCtxStack // If we did, rightCtxStack holds all the contexts we need to break/escape (in order) // One sub-case relevant specifically to shell style injections is the use of a BACK_TICK context // Simply put, if a context stack contains a BACK_TICK context we have to escape contexts only until we reach the BACK_TICK context // For example: // ls ['(TAINTED_PARAM)'] -> in this case, it's enough to break out of the innermost parentheses to have arbitrary code execution //TODO: is the last line of the comment true for subshells to? Maybe it's enough to break into () or {} contexts if(currentCtx === ContextType["DOLLAR_SINGLE_PARENTHESES"] || currentCtx === ContextType["AT_SINGLE_PARENTHESES"]) return rightCtxStack.length !== 0 ? new ContextInfo(rightCtxStack) : new ContextInfo([ContextType["UNQUOTED"].contextTypeName]) else return leftCtxStack.length === 0 ? new ContextInfo(rightCtxStack) : new ContextInfo([ContextType["UNKNOWN"].contextTypeName]) } } module.exports = PowerShellOsCommandingSinkTask module.exports.PSOsCommandingContext = PSOsCommandingContext