@hclsoftware/secagent
Version:
IAST agent
267 lines (249 loc) • 13.7 kB
JavaScript
//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