@hclsoftware/secagent
Version:
IAST agent
328 lines (301 loc) • 16.5 kB
JavaScript
//IASTIGNORE
/*
* ****************************************************
* Licensed Materials - Property of HCL.
* (c) Copyright HCL Technologies Ltd. 2017, 2025.
* Note to U.S. Government Users *Restricted Rights.
* ****************************************************
*/
'use strict'
/*
* ****************************************************
* 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 BASH_OS_COMMANDING_COMMENTS = {
"\\" : "\\\\",
"`" : "`",
"$" : "\\$",
"(" : "\\(",
")" : "\\)",
"|" : "\\|",
"&" : "&",
";" : ";",
"#" : "#",
"!" : "!",
"<" : "<",
">" : ">",
"\n" : "\\n"
}
const SHELL_COMMAND_CHARS = {
"`" : "`",
"$(" : "\\$\\(",
"\\" : "\\\\",
}
const SHELL_COMMAND_BLACKLIST = {
"`" : "`",
"$" : "\\$",
"(" : "\\(",
"\\" : "\\\\",
}
const DOUBLE_QUOTE_ESCAPE_AS_LIST = {
"\"" : "\"",
"\\" : "\\\\"
}
const BRACKETS_ESCAPE_AS_LIST = {
"]" : "]",
"\\" : "\\\\"
}
const BRACES_ESCAPE_AS_LIST = {
"}" : "}",
"\\" : "\\\\"
}
const WHITESPACE_CHARS_AS_LIST = {
" " : " ",
"\t": "\\t",
"\r": "\\r"
}
const ctxOpeners = {
"'" : ContextType.SINGLE_QUOTE,
"\"": ContextType.DOUBLE_QUOTE,
"(": ContextType.PARENTHESES,
"[": ContextType.BRACKETS,
"{": ContextType.BRACES,
"`": ContextType.BACK_TICK,
"$(": ContextType.DOLLAR_SINGLE_PARENTHESES
}
// THE BASH OS COMMANDING ALGORITHM :
//
// 1.single-Quote-other within context does not count
// a.if['] must be replaced and ["\\", "`", "$", "(", ")", "|", "&", ";", "#", "!", "<", ">", "\n"] then vuln
//
// 2. double-Quote-other within context does not count excluding[``,$()]****
// a.if[ `` or $() \]then vuln
// b.if[" OR \] and ["\\", "`", "$", "(", ")", "|", "&", ";", "#", "!", "<", ">", "\n"] then vuln
//
// 3.if["["]->
// a.if[ `` or $() \]then vuln
// b.if[']'OR \] and ["\\", "`", "$", "(", ")", "|", "&", ";", "#", "!", "<", ">", "\n"] then vuln
//
// 4.if["{"]->[}OR \] and ["\\", "`", "$", "(", ")", "|", "&", ";", "#", "!", "<", ">", "\n"] then vuln
//
// 5.no-Context
// ["\\", "`", "$", "(", ")", "|", "&", ";", "#", "!", "<", ">", "\n"] then vuln
//
// 6.if["("] -> Same as 5
//
// 7.ctx-> `Jakrata` then
// Same as 5
//
// 8.ctx->$(Jakrata)then
// Same as 5
//
// 9. if [${] - no currently support
// 10. if [$'] - no currently support
//
//
// ****in case we are in double context we do not count parenthesis UNLESS we are in nested Backtick context
// within doubleQuote context(ex: \"`(INPUT)`\") - then we DO have to follow the parenthesis.
// "backTicInDoubleQuoteCtx"is the flag for this case and if we close Backtick context
// in case of nested backTick context within doubleQuote(ex: \"`$((INPUT))`\") "backTicInDoubleQuoteCtx"
// would be true only when we close the most external backtick(`` in the ex)
class BashOsCommandingSinkTask 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 bashOsCommandingContext = new BashOsCommandingContext(parameterUnderTest, this.command)
this.performContextsAnalysis(bashOsCommandingContext, 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){
// in case of ["\"", "[]"] context - first test if ["`", "$(", "\"] are 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, SHELL_COMMAND_BLACKLIST, false, '\\' ))
return new SinkTaskInfo(exploit, [currentContext], exploitList, this.vulnerability)
}
}
if(currentContext === ContextType.DOUBLE_QUOTE.contextTypeName){
//test if double quote is replaced
let processingResult = parameterUnderTest.getMutatedProcessedParam("\"")
let processedLegitimateExploitForCodePath = processingResult.afterProcessing
if(processedLegitimateExploitForCodePath != null){
if(SinkTask.containsUnescapedChars(processedLegitimateExploitForCodePath, DOUBLE_QUOTE_ESCAPE_AS_LIST, false, '\\'))
containUnescapeContext = true
}
} else {
// test if bracket ([) is replaced
let processingResult = parameterUnderTest.getMutatedProcessedParam("]")
let processedLegitimateExploitForCodePath = processingResult.afterProcessing
if(processedLegitimateExploitForCodePath != null){
if(SinkTask.containsUnescapedChars(processedLegitimateExploitForCodePath, BRACKETS_ESCAPE_AS_LIST, false, '\\'))
containUnescapeContext = true
}
}
} else if (currentContext === ContextType.BRACES.contextTypeName) { // if context is {}
//test if braces are replaced {}
let processingResult = parameterUnderTest.getMutatedProcessedParam("}")
let processedLegitimateExploitForCodePath = processingResult.afterProcessing
if(processedLegitimateExploitForCodePath != null){
if(SinkTask.containsUnescapedChars(processedLegitimateExploitForCodePath, BRACES_ESCAPE_AS_LIST, false, '\\'))
containUnescapeContext = true
}
}
// TODO add $'___' context support ($' allows singleQuote escaping) AND ${___} context
if(currentContext === ContextType.UNQUOTED.contextTypeName || currentContext === ContextType.PARENTHESES.contextTypeName || containUnescapeContext){
exploitList = this.getPayloadsForContextStack(new ContextInfo([currentContext]))
for(const exploit of exploitList){
let processingResult = parameterUnderTest.getMutatedProcessedParam(exploit)
let processedLegitimateExploitForCodePath = processingResult.afterProcessing
if(processedLegitimateExploitForCodePath != null){
if(this.containsBlacklistedChars(processedLegitimateExploitForCodePath,false)){
return new SinkTaskInfo(exploit, [currentContext], exploitList, this.vulnerability)
}
}
}
}
}
}
containsBlacklistedChars(possiblySanitizedPayload, checkWhitespaceChars){
return BashOsCommandingSinkTask.containsBlacklistedChars(possiblySanitizedPayload, checkWhitespaceChars)
}
static containsBlacklistedChars(possiblySanitizaedPayload, checkWhitespaceChars){
// List of escapable chars was modified from: https://stackoverflow.com/questions/15783701/which-characters-need-to-be-escaped-when-using-bash
// Further info regarding context sensitive escapable chars can be found here: https://stackoverflow.com/questions/6697753/difference-between-single-and-double-quotes-in-bash
let blackListedChars = BASH_OS_COMMANDING_COMMENTS;
// Check whitespace chars
if (checkWhitespaceChars) {
blackListedChars.concat(WHITESPACE_CHARS_AS_LIST);
}
return SinkTask.containsUnescapedChars(possiblySanitizaedPayload, blackListedChars, false, '\\');
}
getCommentChars() {
return Object.keys(BASH_OS_COMMANDING_COMMENTS)
}
}
class BashOsCommandingContext extends VulnerabilityContext {
constructor(parameter, osCommandingQuery) {
super(osCommandingQuery, parameter);
}
estimateContextStackBased(leftEdge, rightEdge) {
let leftCtxStack =[]
let rightCtxStack =[]
var currentCtx = null
let inSingleQuoteCtx = false; let inDoubleQuoteCtx = false;
let backTicInDoubleQuoteCtxCounter = 0 ; 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 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 Backtick context
// within doubleQuote context (ex: \"`(INPUT)`\") - then we DO have to follow the parenthesis.
// "backTicInDoubleQuoteCtx" is the flag for this case and if we close Backtick context
// we need to set the boolean flag to its actual value
// in case of nested backTick context within doubleQuote (ex: \"`$((INPUT))`\") "backTicInDoubleQuoteCtx"
// would be true only when we close the most external backtick(`` in the ex)
if(inDoubleQuoteCtx){
if(currentCtx === ContextType["BACK_TICK"]) backTicInDoubleQuoteCtxCounter--
else if(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 backTic context UNLESS backTicInDoubleQuoteCtx is TRUE
// in case we are in double context we do not count parenthesis UNLESS we are in nested Backtick context
// within doubleQuote context (ex: \"`(INPUT)`\") - then we DO have to follow the parenthesis.
// "backTicInDoubleQuoteCtx" is the flag for this case and if we close Backtick context
// we need to set the boolean flag to its actual value
// in case of nested backTick context within doubleQuote (ex: \"`$((INPUT))`\") "backTicInDoubleQuoteCtx"
// would be true only when we close the most external backtick(`` in the ex)
else if(inDoubleQuoteCtx){
if((backTicInDoubleQuoteCtxCounter || dollarParenthesisInDoubleQuoteCtxCounter) && Object.keys(ctxOpeners).origArrayIncludes(c) || c === "`" || c === "$("){
currentCtx = ctxOpeners[c]
leftCtxStack.push(currentCtx.contextTypeName)
}
if(c === "$(") dollarParenthesisInDoubleQuoteCtxCounter++
else if(c === "`") backTicInDoubleQuoteCtxCounter++
}
inSingleQuoteCtx = currentCtx === ContextType["SINGLE_QUOTE"]
inDoubleQuoteCtx = leftCtxStack.origArrayIncludes(ContextType["DOUBLE_QUOTE"].contextTypeName)
}
// currentCtx is null IFF all the contexts before the user parameter have been closed
// This indicates we're in an UNQUOTED context
// Furthermore, in most cases (except when in a SINGLE_QUOTE context) , if currentCtx is BACK_TICK we can run commands directly
// We can definitively say a BACK_TICK context is unquoted IFF it's not within a SINGLE_QUOTE context
if(currentCtx == null || currentCtx === ContextType["BACK_TICK"] || currentCtx === ContextType["DOLLAR_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["BACK_TICK"] || currentCtx === ContextType["DOLLAR_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["BACK_TICK"] || currentCtx === ContextType["DOLLAR_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 = BashOsCommandingSinkTask
module.exports.BashOsCommandingContext = BashOsCommandingContext