Webex puts a yellow banner across the top of the window. Offline - No internet connection. Phone services show as disconnected, nothing syncs, and you cannot join the meeting that started ninety seconds ago.

That is not an inconvenience when the thing is your work phone. Webex is where my calls come in, and my VoIP line runs through it, so a client that will not authenticate is a desk phone that will not ring, a meeting I am not in, and a colleague who gets voicemail. It stopped me working. Not degraded, not slow. Stopped.

And none of it was mine to cause or to prevent. A working day went sideways because of quality control that was not applied, at a supplier that gets paid, on a product sold with a support contract against a platform its own requirements page says is supported. I did not misconfigure anything. I installed the vendor’s package, from the vendor’s repository, on a platform the vendor lists, and it could not open a TLS connection. Then I spent an evening of my own time finding out why, which is time the people who signed the package did not spend.

The machine is not offline. The browser next to it is loading pages. Your mail is arriving, your terminal is pulling from a remote, and if you ask the operating system whether it can reach the internet it says yes. Webex itself agrees, in its own log, eleven seconds before it tells you otherwise.

What has actually happened is that the copy of OpenSSL Cisco ship inside Webex cannot find a single certificate authority, because it was compiled to look for them in /workspace/.conan2/p/b/cisco8ee8b59cf93de/p/ssl. That is a directory on a Cisco build container. It has never existed on your computer and it never will. Every TLS connection the application makes fails at certificate verification, the login token cannot be refreshed, a fifteen-second timer runs out, and the interface reaches for the only explanation it has a string for.

So the banner is wrong in a specific and unhelpful way. It names your network. The fault is a path inside their build.

This is version 46.8.0.35631, on Fedora 44, kernel 7.2.4. It is a supported platform. Cisco publish Linux system requirements and ship a signed .rpm, webex-46.8.0.35631-1.x86_641. What follows is how to prove it in about ten minutes, why none of the obvious fixes work, the one that does, and then the part that matters more than any of it: this is not a subtle bug. It is a build that nobody ever ran on a machine that had not built it.

What The Banner Is Actually Telling You

Webex runs its connectivity display off a composite state machine, seven sub-machines each with its own timers. At startup they initialise like this:

ConnectivityStateMachine::ConnectivityBanner - Initializing with state: Connected
ConnectivityStateMachine::Network        - Initializing with state: NoNetwork
ConnectivityStateMachine::Services       - Initializing with state: Connected
ConnectivityStateMachine::Mercury        - Initializing with state: Disconnected
ConnectivityStateMachine::Authentication - Initializing with state: UserNotAuthenticated
ConnectivityStateMachine::Syncing        - Initializing with state: Synced
ConnectivityStateMachine::Survivability  - Initializing with state: SurvivabilityHide

Forty milliseconds later the operating system reports back, and the application writes it down:

NetworkManagerPowerNetworkWatcher.cpp:93 onConnectivityCheckSuccess::
  The host is connected to a network, that appears to be able to reach the full Internet.

That line is in the same file, in the same session, as the banner claiming there is no internet connection. The application knew. It had the answer in hand at 08:25:12.131 and showed the opposite at 08:25:27.132.

Between those two moments, this:

TimeWhat happened
08:25:12.131Operating system confirms full internet reachability
08:25:12.218Proxy detection: none configured, direct connection
08:25:12.241First HTTPS request fails, errorCode: 167772294 Error in SSL handshake
08:25:12.569CloudApps access token refresh fails, same code
08:25:12.571Kms access token refresh fails, same code
08:25:15.684Retry, both fail
08:25:21.745Retry, both fail
08:25:27.132Fifteen-second timer expires, banner switches to NoInternet
08:26:12.091Sixty-second timers expire, services degrade to DisconnectedShortTerm

The Authentication sub-machine never leaves UserNotAuthenticated, so Services derives itself as disconnected, so the banner fires. Every stage of that is correct behaviour given the input. The input is wrong, and the input is a number: 167772294, on every failed request, from the first one to the last.

That number is the whole post. Hold on to it.

Three Paths, None Of Which Exist

Webex does not use the system OpenSSL. It brings its own, along with its own libcurl, and the libcurl links against the bundled one rather than yours:

$ ldd /opt/Webex/bin/libcurl.so | grep -E 'ssl|crypto'
    libssl.so.3    => /opt/Webex/bin/../lib/libssl.so.3
    libcrypto.so.3 => /opt/Webex/bin/../lib/libcrypto.so.3

Fine so far. Bundling a TLS library is a defensible choice and plenty of vendors do it. What matters is what got baked into it, and OpenSSL will tell you if you ask:

$ strings /opt/Webex/lib/libcrypto.so.3 | grep -E 'OPENSSLDIR|ENGINESDIR|MODULESDIR'
OPENSSLDIR: "/workspace/.conan2/p/b/cisco8ee8b59cf93de/p/ssl"
ENGINESDIR: "/workspace/.conan2/p/b/cisco8ee8b59cf93de/p/lib/engines-3"
MODULESDIR: "/workspace/.conan2/p/b/cisco8ee8b59cf93de/p/lib/ossl-modules"

Three of them. Not one slipped setting. The whole install prefix, carried through from the machine that compiled it, in a library that reaches the customer as a signed package on a supported platform.

OPENSSLDIR is set once, at configure time, with --openssldir, and OpenSSL’s own build documentation is blunt about what it is for: “Directory for OpenSSL configuration files, and also the default certificate and key store.”2 Everything downstream of trust hangs off it. The default CA file is cert.pem inside it and the default CA directory is certs inside it3. /workspace is a Conan build cache. Conan is the C++ package manager Cisco build with, and it stores each package under a hash of its build inputs4. The hash cisco8ee8b59cf93de is a fact about a container that was probably deleted minutes after the build finished.

Ask the library what it is, and it is not even stock OpenSSL:

VERSION              CiscoSSL 3.5.5.8.5.4 27 Jan 2026
BUILT_ON             built on: Wed Feb 25 06:29:18 2026 UTC
PLATFORM             platform: conan-Release-Linux-x86_64-gcc-13
DIR                  OPENSSLDIR: "/workspace/.conan2/p/b/cisco8ee8b59cf93de/p/ssl"

A maintained in-house fork, with a version scheme of its own, built in February, shipped in August, and still carrying the working directory it was made in.

Where each copy of OpenSSL on the machine looks for its trust anchorsOne machine, two copies of OpenSSL, and only one of them knows where the certificates areNeither copy is told at runtime. Each carries the answer as a string compiled into it, and that string is set by whoever ran the build.The system copy: OpenSSL 3.5.8, Fedora 44compiled-in OPENSSLDIR/etc/pki/tlsthe directory is therecert.pem → tls-ca-bundle.pemcerts/ → hashed anchorsthe chain is checked against real anchorsHandshake completes. Every other program on the box works.Which is exactly why the user is certain the network is fine.The copy Webex ships: CiscoSSL 3.5.5.8.5.4compiled-in OPENSSLDIR/workspace/.conan2/p/b/cisco8ee8b59cf93de/p/sslno such directory on any customer machinecert.pem → absentcerts/ → absentthe store loads zero trust anchorsHandshake fails: error 0x0A000086, decimal 167772294.The banner the user is shown says the network is down.
Two copies of OpenSSL on one machine. Neither is told at runtime where trust lives. Each carries a string compiled into it, and one of those strings names a directory that only ever existed on somebody else’s build host.

Do Not Take strings For An Answer. Ask The Library

strings finds text in a file. It does not prove the library uses it. So load the shipped library and ask it directly, which takes about twelve lines of Python and no root:

import ctypes
c = ctypes.CDLL("/opt/Webex/lib/libcrypto.so.3")
for f in ("X509_get_default_cert_file", "X509_get_default_cert_dir",
          "X509_get_default_cert_file_env", "X509_get_default_cert_dir_env"):
    getattr(c, f).restype = ctypes.c_char_p
    print(f"{f:34} {getattr(c, f)().decode()}")
X509_get_default_cert_file         /workspace/.conan2/p/b/cisco8ee8b59cf93de/p/ssl/cert.pem
X509_get_default_cert_dir          /workspace/.conan2/p/b/cisco8ee8b59cf93de/p/ssl/certs
X509_get_default_cert_file_env     SSL_CERT_FILE
X509_get_default_cert_dir_env      SSL_CERT_DIR

There it is from the library’s own mouth. When anything in Webex asks this copy of OpenSSL for the default trust store, it is handed a file and a directory that do not exist. That is what SSL_CTX_set_default_verify_paths does, and it is what almost every client does unless it has been told otherwise.

The last two lines are worth noting, because they are the escape hatch: the library will let SSL_CERT_FILE and SSL_CERT_DIR override both5. Remember that as well. It becomes important, and not in the way you would expect.

Reproducing The Exact Error Code

Proof by inspection is not proof. Take the shipped libssl.so.3 and libcrypto.so.3, make a real handshake to a real host with nothing but the library’s own defaults, and watch what comes back.

The interesting run is the one where the defaults point at nothing. SSL_CERT_FILE and SSL_CERT_DIR override exactly the two values a missing OPENSSLDIR leaves dangling, so pointing them at a path that does not exist reproduces the shipped condition precisely:

$ SSL_CERT_FILE=/nonexistent/cert.pem SSL_CERT_DIR=/nonexistent/certs python3 tls.py
set_default_verify_paths -> 1
set_fd -> 1
SNI -> 1
SSL_connect -> -1 SSL_get_error -> 1
verify result 20: unable to get local issuer certificate
  err: 0xa000086 error:0A000086:SSL routines::certificate verify failed

0x0A000086 in decimal is 167772294.

That is the number in every failed line of the Webex log, and it did not come from Webex. It came from Cisco’s own TLS library, running outside their application, failing for exactly one reason: it had no trust anchors to check the chain against. Same library, same error, no application in the way.

Point the same two variables at the real Fedora bundle and the same code path completes:

$ SSL_CERT_FILE=/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem python3 tls.py
SSL_connect -> 1 SSL_get_error -> 0
verify result 0: ok

Nothing about the network changed between those two runs. One file path did.

Every network step succeeds. The step that fails is a local file lookup.Four steps cross the network and succeed. The fifth reads a file and does not.Run against the shipped libssl.so.3 and libcrypto.so.3, outside Webex, with nothing but the library's own defaults.TCP connect :443okClientHello, SNIokServerHello, chainok, chain receivedcheck the chain against trustno anchors loadedwhat the library reports backSSL_connect -> -1verify result 20: unable to get local issuer certificateerr: 0xa000086 error:0A000086:SSL routines::certificate verify failed0x0A000086 is 167772294 in decimal, the number in every failed line of the Webex log.The application never wrote the word "certificate" anywhere. It logged "Error in SSL handshake" and a decimal integer, and the interfaceturned that into "Offline - No internet connection", which is the one thing the machine demonstrably was not.Nothing here is a network fault. The only thing missing was a directory.
Four steps cross the network and succeed, including receiving the server’s full certificate chain. The step that fails opens a local file. The user is shown a message about their internet connection.

Why Nothing Warned You

Two things conspire to make this silent, and only one of them is Cisco’s fault.

What never raises a wordWhose designWhy it stays quiet
Loading a trust store that is not thereOpenSSL’s, deliberatelySSL_CTX_set_default_verify_paths returns 1 whether or not the paths are real. “A missing default location is still treated as a success”3
Having nothing to fall back toCisco’s, and correctCertificates validated, self-signed refused, SSL retry off, so no degraded mode exists to mask a fault

The first is reasonable. A program that ships its own anchors separately should not be forced to care, so the call succeeds, the store is empty, and nothing anywhere in the stack says I have loaded zero certificate authorities. The first thing that notices is a verification failure half a second later. I ran that call against the shipped library with the paths pointed at /nonexistent and it returned 1. It is in the output above.

The second is a policy that comes down from the service, and the log records it:

Wdm.cpp:1162 parseDeviceJson: Adding policy << allowSelfSignedCertificate with value: false
NetworkManager.cpp:1738 onConfigReady: ...httpRequestSSLRetryEnabled: 0
HttpRequestManager.cpp:1883 rawHttpRequest: {"validateCertificates":"true","useClientCertificate":"false"}

Those three flags are the only reason this fault is an outage instead of something far worse, and that is worth sitting with rather than hurrying past.

Work it through. The bundled library loads zero trust anchors, and nothing beneath the policy layer was ever going to notice, because the failure is silent by design all the way down. What turned it into a banner was three flags. Flip any one of them the way plenty of clients ship them and the same build does not fail at all. It connects, to anything holding any certificate, because it has nothing to check one against.

The same broken trust store, one policy flag away from a very different faultThe trust path is equally broken in both columns. Only the policy decides how you find out.Common to both: the compiled-in OPENSSLDIR does not exist, so the store loads zero certificate authoritiesAs Webex ships itvalidateCertificates: trueallowSelfSignedCertificate: falsehttpRequestSSLRetryEnabled: 0Nothing to verify against, so it refuses.Handshake fails. Banner. You lose a day.Loud, and harmless.Any one of them set the other wayvalidateCertificates: falseor self-signed permittedor retry without verificationNothing to verify against, so it proceeds.Handshake completes. Against anything.Silent, and not harmless.What separated the two outcomes was a policy value set by a different team, downstream of the defect, for unrelated reasons.The trust path was never protecting anybody. It was broken throughout, and the only open question was which way it would fail.
The difference between “Webex is offline today” and “Webex trusted whatever answered” is a policy value set by a different team, downstream of the defect, for reasons that have nothing to do with it.

What it surfaces as is the problem. The user gets “Offline - No internet connection”. The log gets “Error in SSL handshake” and a decimal integer. The word certificate does not appear anywhere the user or a first-line support engineer will ever look, and the one diagnostic breadcrumb is a number you have to convert to hex before it means anything.

The Fixes That Did Not Work

Both of the obvious ones fail, and the reasons are different and both worth knowing.

Editing the shipped openssl.cnf

Webex ships a configuration file at /opt/Webex/lib/openssl.cnf, and as shipped it activates one provider:

[provider_sect]
fips = fips_sect

Only FIPS. Not the default provider, which is where the ordinary TLS algorithms live6. Adding it back is a one-line edit and it changes nothing at all, because the bundled OpenSSL never reads that file. It looks for openssl.cnf inside OPENSSLDIR7, and OPENSSLDIR is the path that does not exist. The file sits in the installation directory looking authoritative. Nothing reads it.

It is a second defect hiding behind the first. Even if the path problem were fixed tomorrow by pointing OPENSSLDIR at /opt/Webex/lib, this configuration would then load and would activate only FIPS. And the FIPS module itself is loaded from MODULESDIR, the third dead path from the same tree, so fips.so shipping in /opt/Webex/lib cannot be found either. Three paths, one wrong prefix, and every one of them broken in a way that hides the next.

Setting the environment variables

The library honours SSL_CERT_FILE and SSL_CERT_DIR. I proved that above. The successful run is right there. So the obvious move is to set them in the desktop entry, which is what was tried:

Exec=env OPENSSL_CONF=/opt/Webex/lib/openssl.cnf \
  SSL_CERT_FILE=/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \
  SSL_CERT_DIR=/etc/pki/tls/certs/ /opt/Webex/bin/CiscoCollabHost %U

No change. And the reason is not that Webex ignores the variables. The reason is that the process never received them:

$ tr '\0' '\n' < /proc/20293/environ | grep -E 'SSL|OPENSSL|CURL'
$ tr '\0' '\n' < /proc/20293/environ | wc -l
99

Ninety-nine variables in the running Webex process, not one of them the three that were set. Because there are two desktop entries with the same name on this machine:

FileWhat its Exec line runsWritten by
/usr/share/applications/webex.desktopthe three-variable env line above, in fullthe .rpm, then edited by hand
~/.local/share/applications/webex.desktop/opt/Webex/bin/CiscoCollabHost %U, and no environment at allWebex’s own launcher

And the specification is not ambiguous about which wins: “The base directory defined by $XDG_DATA_HOME is considered more important than any of the base directories defined by $XDG_DATA_DIRS.”8 $XDG_DATA_HOME is ~/.local/share. The user copy shadows the packaged one, every time, on every desktop that follows the spec9.

So Webex installs a second copy of its own launcher into your home directory, and that copy is the one your desktop runs. Change the packaged file all you like. You are editing a document nothing reads.

Two desktop entries with the same name, and the one that wins is the one Webex writes for itselfThe environment was set in the file the desktop never readsTwo entries, one name. The search order is written down, and it does not favour the packaged copy.Edited by hand, and outranked/usr/share/applications/webex.desktopExec=env OPENSSL_CONF=... SSL_CERT_FILE=...never consulted while the other file existsWritten by Webex, and it wins~/.local/share/applications/webex.desktopExec=/opt/Webex/bin/CiscoCollabHost %Uno environment set at alldiscardedlaunchedXDG base directory specification: the base directory defined by $XDG_DATA_HOME is considered moreimportant than any of the base directories defined by $XDG_DATA_DIRS.Proof, from the running process rather than from reasoning:tr '\0' '\n' < /proc/20293/environ | grep -E 'SSL|OPENSSL' → no output, 99 variables, none of them theseSo the fix was real, the file was real, and the process it was meant for was started from somewhere else entirely.
The environment was set in the file the desktop never reads. Webex writes its own entry under the user’s data directory, the specification says that one outranks the packaged one, and the proof is the running process: ninety-nine environment variables and none of the three.

The Fix That Works

If the library insists on a path, give it the path. Create the directory it was compiled to want and fill it with symlinks to the real thing:

#!/bin/bash
# Point the bundled CiscoSSL at the system trust store by building the
# directory it was compiled to look for. Tested: Fedora 44, Webex 46.8.0.35631.
set -euo pipefail

OPENSSLDIR=$(strings /opt/Webex/lib/libcrypto.so.3 \
  | grep -oP '(?<=OPENSSLDIR: ")[^"]+')
[ -n "$OPENSSLDIR" ] || { echo "no OPENSSLDIR found in the shipped library"; exit 1; }

for p in /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \
         /etc/ssl/certs/ca-certificates.crt \
         /etc/pki/tls/certs/ca-bundle.crt \
         /etc/ssl/cert.pem; do
  [ -f "$p" ] && { CA_BUNDLE="$p"; break; }
done
[ -n "${CA_BUNDLE:-}" ] || { echo "no system CA bundle found"; exit 1; }

echo "OPENSSLDIR: $OPENSSLDIR"
echo "CA bundle:  $CA_BUNDLE"

sudo mkdir -p "$OPENSSLDIR"
sudo ln -sf "$CA_BUNDLE" "$OPENSSLDIR/cert.pem"
sudo ln -sf "$(dirname "$CA_BUNDLE")" "$OPENSSLDIR/certs"

sudo tee "$OPENSSLDIR/openssl.cnf" > /dev/null <<'CONF'
openssl_conf = openssl_init
[openssl_init]
providers = provider_sect
[provider_sect]
default = default_sect
fips = fips_sect
[default_sect]
activate = 1
CONF

echo "done. now restart Webex"

Restart it, and the same startup sequence produces the opposite outcome. Same binary. Same log lines. Token refresh, which failed within sixty milliseconds before, now finishes in three hundred and thirty:

AuthTokenRequester.cpp:579  Managed to fetch a new Kms access token.
AuthTokenRequester.cpp:579  Managed to fetch a new CloudApps access token.
AuthTokenSupervisor.cpp:310 Auth tokens refreshed. Expires in [64799 secs].
AuthenticationManager.cpp:1860 onUserAuthenticated: User authenticated.
ConnectivityStateMachine::Authentication - UserNotAuthenticated -> UserAuthenticated

Authenticated in about 750 ms, so the fifteen-second timer never fires and the banner never appears. Phone services go from Disconnected to Connecting, a state the broken session never reached in a minute of trying.

BeforeAfter
Network checkpassespasses
First HTTPS request167772294 Error in SSL handshakeHTTP 200
CloudApps tokenfailedfetched
Kms tokenfailedfetched
Authenticationstuck at UserNotAuthenticatedUserAuthenticated
Banner at 15 s“Offline - No internet connection”none
Phone servicesnever attemptedconnecting
Time to authenticatenever~750 ms

Note what the fix does to your filesystem, because it should bother you. It creates a top-level directory called /workspace on your machine, a name the Filesystem Hierarchy Standard has no place for10, holding a Conan cache path and a build hash belonging to a company you bought software from. That is the shape of the remedy Cisco have left you: mount somebody else’s build environment at the root of your own.

It will also break. Three ways:

When it breaksWhyWhat you do
A Webex updatecisco8ee8b59cf93de is derived from the build inputs, so a rebuilt dependency means a new directoryRe-run the script; it reads the path out of the new binary rather than assuming the old one
A distribution changeThe symlink target is a distribution decision, not a standard11Re-point it; the script probes four known locations
A reinstall/workspace is not backed up, packaged, or owned by anythingRun it again, every time, forever

None of that is maintenance. It is you standing in for a step in someone else’s build pipeline, indefinitely, unpaid. Patching a defect at runtime, on every machine you own, because the vendor would not patch it once at build time.

They Knew The Rule And Applied It To Half The Build

Here is what turns this from a bug report into an argument.

Read the dynamic section of the shipped binaries:

$ readelf -d /opt/Webex/bin/libcurl.so | grep RUNPATH
 0x1d (RUNPATH)  Library runpath: [$ORIGIN:$ORIGIN/../lib]
$ readelf -d /opt/Webex/bin/CiscoCollabHost | grep RUNPATH
 0x1d (RUNPATH)  Library runpath: [$ORIGIN/../lib]

$ORIGIN expands at load time to the directory holding the object itself12. It is the correct tool for a relocatable bundle and they used it properly. They had to: Webex ships the same tree twice, once to /opt/Webex and once to ~/.local/share/WebexLauncher/46.8.0.35631_9e6196c9-…/, and a launcher picks between them at startup. Two prefixes, one build, and the linker finds its libraries in both.

So the people who made this package understood the problem exactly. Absolute paths do not survive being shipped. They solved it for the code.

Then they left the data paths as absolute strings naming the container that compiled them. Same build. Same afternoon.

One path in the bundle relocates itself. The other names a machine in a data centre somewhere.They knew the bundle had to move. They only applied it to the code.Both values are set at build time by the same people on the same day. One expands at load time, the other never expands at all.Where the code comes from: recorded as a relative expressionRUNPATH in libcurl.so$ORIGIN:$ORIGIN/../lib/opt/Webex/lib resolved~/.local/share/WebexLauncher/46.8.0.35631_.../lib also resolvedCorrect, and deliberately so. The same tree is shipped twice to two different prefixes and the linker finds it in both.Where the trust comes from: recorded as somebody's working directoryOPENSSLDIR in libcrypto.so.3/workspace/.conan2/p/b/cisco8ee.../p/sslno such path not resolvedENGINESDIR and MODULESDIR: same tree, same outcomeThree absolute paths into a build container, shipped to every customer, on a product sold with a support contract.The distance between the two halves of this picture is one person running the packaged artefact on a machine that did not build it.That is the whole defect. Not a hard bug. An untested one.
The same build, the same day, the same engineers. The library search path is recorded as an expression that resolves wherever the tree lands. The trust path is recorded as somebody’s working directory.

So this cannot be filed under they did not know. $ORIGIN is not something you stumble into. You reach for it because you have understood that an absolute path baked into a shipped artefact is a defect, and understood it well enough to go and fix it in the linker. Then the same build writes three absolute paths into the same libraries, and ships them.

Knowing the rule and applying it to half the build is worse than not knowing it. Not knowing is a training problem and training has a fix. This is a package that had the right idea in it, in writing, in the ELF header where anyone could read it, and it went out of the door broken anyway. Which tells you that nothing downstream of the compiler was looking at the result. Nowt was.

The Container Was Already There

Where /workspace came from is not a mystery, and you do not have to guess. It is in the package header:

$ rpm -qi webex | grep -E 'Build Host|Build Date|Vendor'
Build Date  : Sat 08 Aug 2026 20:47:43 BST
Build Host  : c964ea9239ae
Vendor      : Cisco

c964ea9239ae is not a hostname anybody typed. It is twelve hexadecimal characters, which is what a container reports as its hostname when nothing sets one. So the package was built inside a container, by a company that plainly has the images, the registry and the orchestration to do it, and three separate artefacts on this machine say so independently:

Evidence, read off the installed packageValueWhat it proves
Build Host in the RPM headerc964ea9239aea container ID, not a build machine
OPENSSLDIR in libcrypto.so.3/workspace/.conan2/…a path that only exists inside that container
PLATFORM in the same libraryconan-Release-Linux-x86_64-gcc-13a containerised Conan toolchain
Requires in the RPMglibc >= 2.28a deliberately chosen, very old ABI floor

They then signed it. The build finished at 20:47:43 and the signature is dated 21:02:07 the same evening, key ID 9995e5bbb5ccde3c. Fifteen minutes. So there is a release gate, somebody or something operates it, and what it attests is who made the package, not whether the package works. A signature is a statement about provenance. It has never been a statement about fitness, and a process that has one and not the other has its priorities in the wrong order.

Because the missing step is the cheap one. The container is already in the pipeline. Take the artefact that just came out of it, start a clean image of each distribution you claim to support, install it, launch it, and read the first hundred lines of the log:

docker run --rm fedora:44 sh -c '
  dnf -y install ./webex-46.8.0.35631-1.x86_64.rpm &&
  timeout 25 /opt/Webex/bin/CiscoCollabHost &
  sleep 20
  grep -c "Error in SSL handshake" ~/.local/share/Webex/current_log.txt'

Non-zero, every time, on this build. The install is 1.1 GB, so call it a minute per target on a warm cache. Six distributions is six minutes of a machine that is already running, on hardware Cisco already own, in a pipeline that already exists. It was not run. Not once.

And that is the answer to the thing people still say about Linux, that supporting several distributions is hard. It stopped being hard the day this tooling arrived, and the tooling is the same tooling they compile with. One base image per target. Same artefact into each. The matrix is a loop.

A pipeline that builds in a container and never runs the result in one is not a pipeline. It is a compiler with a cron job in front of it and a signing key behind it, and whatever it produces is a guess.

They build inside a container and never run the result in oneThe container is already in the pipeline. It is only used for the half that suits the vendor.Every value below is read out of the package sat on the machine, not inferred.Build, inside a containerBuild Host: c964ea9239ae/workspace/.conan2/p/b/...conan-Release-Linux-x86_64-gcc-13three separate proofs of a containerSign and publishbuilt 20:47:43signed 21:02:07fifteen minutes, and a release gatethat attests who, never whetherCustomerdnf install webexopens it"Offline - No internet"The step that is not in the pipeline anywheredocker run --rm fedora:44 sh -c 'dnf -y install ./webex.rpm && CiscoCollabHost &sleep 20; grep -c "Error in SSL handshake" ~/.local/share/Webex/current_log.txt'Same infrastructure. One image per target distribution. Under a minute each, and it fails loudly on this build.Building for several distributions stopped being hard the day this tooling arrived, and it is the same tooling they compile with.A pipeline that builds in a container and never runs the result in one is a compiler with a cron job in front and a signing key behind.
Three independent proofs in the shipped package that the build ran in a container, a signature applied fifteen minutes after the build, and the one step that appears nowhere: running the finished package in a clean image of each platform it is sold as supporting.

Why Is A 2026 Release Built Against A 2018 Libc?

The package declares what it needs, and the interesting line is the first one:

$ rpm -q --requires webex | grep glibc
glibc >= 2.28

glibc 2.28 was released on 1 August 201813. It is the version in Red Hat Enterprise Linux 814, a release whose full support ended in 2024. This is a 2026 product, compiled in 2026, targeting the C library of 2018. Then shipping its own 2.5 MB copy of libstdc++.so.6 into the user’s home directory, because the C++ runtime that goes with a base that old cannot carry the code.

Why would anybody still do that? Because they will not link statically.

That is the whole of it. The moment you dynamically link against the host’s C library, the oldest distribution you are willing to support becomes a constraint on the machine you compile on. You cannot use a symbol that the oldest target has never heard of, so you pin the build to an ancient base image and you stay there. Every year the gap widens. Every new language or library feature arrives with an argument about whether the floor can move. Ship your own libstdc++ to paper over the worst of it, and you are now maintaining a private runtime as well.

That trade made sense when a build host was a physical machine in a rack that somebody had to reinstall. It has not made sense for a decade. If you must link against a host libc, a container per target gives you a real build on a real version of each platform, and none of them constrain the others.

But look at what the old-target discipline actually bought here. It is ABI conservatism at considerable cost: an eight-year-old floor, a bundled C++ runtime, a support matrix frozen around it. And the client still will not start. Because what broke was a file path, and no amount of care about symbol versions protects a file path. They paid the compatibility tax and the bundling tax, and skipped the one check that costs nothing. Both bills. No product.

They Paid For A Self-Contained Bundle And Did Not Get One

The long-standing way to ship commercial software on Linux is to depend on as little of the host as possible. Static where you can, a self-contained tree where you cannot, and no assumptions about the distribution underneath. It is not elegant and nobody pretends it is. It exists because of the alternative. A binary that needs a particular version of a particular library in a particular place turns every customer’s machine into a support case.

Cisco went the second route and bundled. Here is the bill:

What the bundle holdsSize or count
Shared objects in /opt/Webex/lib150
Their own libcurl, CiscoSSL, zlib-ng, ICU, Kerberos, CUPS client, hunspell and inference engineall of them
Installed under /opt/Webex1.1 GB
A second copy under ~/.local/share/WebexLauncher, per user1.1 GB
On disc for a chat and calling client2.2 GB

Two gigabytes of dependencies. That is the full price of bundling: the download, the disc, the duplication, the security burden of being the only party who can patch any of it, the whole lot. Pay that and what you buy is a program that does not care what the host has installed, that behaves the same on Fedora and Debian and Arch and whatever a customer has standardised on this year, and that cannot be broken by a distribution upgrade it never heard about.

Except it does. It cares very much about one directory, and it is a directory on a build server.

They shipped their own Kerberos and their own ICU and their own spell checker, and they could not ship a working path to the certificates. The bundle’s entire purpose is to be self-contained, and it is not self-contained in the one respect that stops it working. Every one of those 1.1 GB gets found correctly through $ORIGIN. The forty-odd bytes that matter most do not.

And this is the bit that gets me. Bundling is the expensive option. They took the expense, they did the hard engineering, they got the relocation right for a hundred and fifty libraries across two install prefixes. Then pointed the one part that decides whether any of it works at a machine no customer has ever owned.

Nobody Documented It Either

A second failure sits alongside the first, and it is the one that would have caught the first.

Ask the package what documentation it ships:

$ rpm -qd webex | wc -l
0
$ rpm -qc webex | wc -l
0

No documentation files. And no configuration files. Zero entries marked %config in a package that ships an openssl.cnf. That is not cosmetic. An RPM marks a file %config so that the package manager preserves what the administrator changed, saving a .rpmsave rather than clobbering it1516. /opt/Webex/lib/openssl.cnf is shipped as an ordinary file, so the next update overwrites any edit you made to it and tells you nothing. The one file a customer might legitimately need to adjust is the one the packaging treats as disposable.

Nothing published says which trust store the client uses, which environment variables it honours, or where it reads its TLS configuration from. There is no page to check. None was written. The only way I established any of it was strings, readelf, ldd and ctypes against the shipped binaries. Reverse-engineering a supported product to answer a question its documentation should have answered in a sentence.

And here is why that matters more than it sounds. Writing that documentation is itself a test. Put anyone at the vendor in front of a blank page headed where Webex for Linux reads its CA certificates from, and the first thing they have to do is go and look. The moment they look, they find /workspace/.conan2/p/b/cisco8ee8b59cf93de/p/ssl, and the next thing out of them is a question. Documented configuration paths are not paperwork for the customer’s benefit. They are the cheapest audit a vendor can run on its own build, and skipping them is how a path like that survives to a release.

None of that is a big ask. Say where your configuration lives. Say which environment variables you honour. Mark your configuration files as configuration so an update does not eat them. Then the customer who hits a fault has somewhere to look that is not a hex editor.

Nobody Ran It

The missing step has been named enough times above. What is worth asking is why it went missing on this platform and not the others.

Not on Fedora, anyway. On macOS and the Fisher-Price OS (Windows) this class of fault cannot show up the same way, because those platforms have a system TLS stack with a trust store the operating system manages. Linux has no such thing. OpenSSL is the trust store, and as such whoever ships it owns where it looks. So the one platform where the bundled library is load-bearing is the platform that got shipped untested, which is a decision about which customers are worth a smoke test.

The whole investigation took an evening: reading the log, spotting that network detection passed before the banner claimed otherwise, pulling the compiled path out of the binary, reproducing the exact error code against the shipped library. All of it done with an AI agent doing the log correlation and the ctypes harness while I worked out what to ask it. I mention that for one reason: the diagnosis a vendor never made before signing this package and putting it in their own repository is now inside an evening’s reach of any customer with the patience to look. Cisco’s engineers have Claude available to them the same as anyone else, and it would have built this properly. You cannot ask it to bake /workspace/.conan2 into a shipping artefact without it telling you what will happen when the artefact leaves the workspace. The tooling to catch this is no longer scarce, and neither is the knowledge. What is missing is anyone at the vendor whose job it was to look.

Meanwhile the support path for the person hitting this is a banner that says their internet is down. They will restart the router. They will ring their provider. They will raise a ticket that goes nowhere, because the symptom Cisco chose to display points away from Cisco.

That is the complete list of what went wrong. It is worth saying what right would have looked like, because every item on it has a settled answer that predates this product.

How It Should Have Been Built

Enough of what went wrong. Here is the standard, and none of it is novel. It is what shipping a binary to somebody else’s computer has asked of you for twenty years.

Start with the decision Cisco got right in principle and wrong in execution: how much of the host you are willing to depend on. Linking statically is the strongest answer, and it is worth being concrete about it, because “just link it statically” gets waved about by people who have never had to and dismissed by people who have never tried.

What it removesWhat it costs
RUNPATH, and every way of getting it wrong, because there is no search at load timeglibc does not link statically clean: name and user lookup go through dlopen, so the binary still reaches for the host’s NSS modules17
The glibc floor, so the oldest distribution stops dictating what you may compile onLGPL parts carry a relinking obligation, so they stay dynamic or you ship what is needed to relink
Breakage from a distribution upgrade, a library rename or a stale ldconfig cacheYou own every patch: no distribution security update reaches your customers
dlopen of a versioned .so from a directory that may not be thereA bigger download, and no sharing of pages between processes

And one line in the left column that the others are only supporting: the artefact your pipeline produced is the artefact the customer runs, byte for byte. Test it and you have tested the thing you shipped. That is the property this whole post is about the absence of.

The right column is worth being honest about. Genuine costs, and the reason people reach for musl or accept a hybrid. But look at the third row. Owning every patch applies identically to what Cisco already did: a bundled tree of 150 libraries is the same commitment with none of the load-time guarantees. They signed up to own every patch either way and got nothing back for it.

So the rule is simple, and it is the rule they broke: whatever you cannot link in, you must find by a path relative to the binary. $ORIGIN for the code, and the same discipline, deliberately, for every data path the library will go looking for. In this case there are four of them and every one has a documented handle on it:

What the library goes looking forWhat shippedWhat it should have been
Trust anchorsOPENSSLDIR/cert.pem, fixed at build timeshipped in the tree, or SSL_CERT_FILE set at startup5
ConfigurationOPENSSLDIR/openssl.cnf, same path, never foundOPENSSL_CONF, pointed at the copy in the package5
Providers, including FIPSMODULESDIR, absolute, so fips.so is unreachableOPENSSL_MODULES, “the directory from which cryptographic providers are loaded”5
EnginesENGINESDIR, absoluteOPENSSL_ENGINES, or nothing, since OpenSSL 4.0 removed engine support altogether5

Four paths, four environment variables, all of them in one manual page their own library ships with. If any string in your artefact starts with a / and was decided at build time, it is a defect waiting for a customer to find. There is no third option where an absolute build path is fine.

The Fixes For This One, And The Check That Catches It

Down from the principle to this specific defect. Six changes, not one of them research:

FixEffortWhy it is right
Set --openssldir=/opt/Webex/lib/ssl and ship the treeone configure flagThe path then exists in the package, which is what the flag is for2
Set SSL_CERT_FILE/SSL_CERT_DIR in CiscoSSLUtils at startup, probing the known distribution pathsa dozen linesStandard, documented, already supported by their own library5
Ship the CA bundle themselves inside the packagepackaging onlyFull control of trust, at the price of owning its freshness
Fix the openssl.cnf to activate the default providerone lineNeeded regardless, and currently masked by the path fault6
Mark openssl.cnf as %configone spec lineStops an update silently eating an administrator’s change15
Publish the paths and the variables honouredone pageThe cheapest audit there is, and it finds this fault while being written

The first is the one-flag fix and it would have shipped the product working. It is a single value in a build script, set once, that they got wrong because nothing downstream ever checked it.

The check is smaller than the fix:

# in CI, on the packaged artefact, in a clean container
test -d "$(strings lib/libcrypto.so.3 | grep -oP '(?<=OPENSSLDIR: ")[^"]+')" \
  || { echo "shipping a trust store path that does not exist"; exit 1; }

One line. It would have failed this release loudly, in February, on the machine that made it, in the container that made it, before the package was signed. The reason it is not there is not difficulty and not cost. It is that nobody was asked to write it, and so whether the packaged artefact behaved like software was not anyone’s job.

What A Sloppy Build Process Costs Everybody Else

Everything above is one product’s build process seen from the inside, and I will not walk you back through it. The question worth asking is whether that standard of care is likely to stop at one team.

So put the outside record next to it. CISA maintain a catalogue of vulnerabilities known to be exploited in the wild. Not theoretical, not scored. Observed being used against people. As of 14 September 2026 it holds 1,710 entries18:

VendorEntries in the KEV catalogue
Microsoft388
Cisco98
Apple94
Adobe81
Google74
Oracle46
Fortinet30
VMware26

Second, behind an operating system monopoly and ahead of everybody else. The most recent Cisco addition went in on 14 September 2026, the day before this was written.

I counted the same file three weeks earlier for /en/random/is-your-msp-lying-to-you-part2/: version 2026.08.27, 1,685 entries, Cisco on 96. Two more since, in twenty-one days.

Be fair about what that table does and does not prove on its own. A large installed base in high-value places attracts attention, and attention finds bugs, so any vendor at that scale will carry a long list. The number is a prior, not a verdict.

The composition is harder to wave away than the total. Twenty-five of Cisco’s ninety-eight sit on the security lines: the firewalls, the appliances, the VPN concentrators, the mail and web gateways, the identity services. And the four most recent entries added against them, in a row, are Secure Firewall Management Center twice, Secure Firewall ASA, and Secure Email Gateway, the last of those on 14 September 202618. Not the switches. Not the collaboration kit. The products sold specifically to be the thing that keeps everybody else safe.

Which is the sentence this whole post has been walking towards, so here it is plainly. This is a company whose business is selling security appliances, and it cannot get a desktop client to check a certificate. Not a hard case. Not a novel attack. The single most routine security operation in computing, performed by every browser on every page load, in a library they forked themselves, and they shipped it pointed at a directory that has never existed on a customer machine. Then signed it.

What this post adds is a sample of the process behind it. Not a vulnerability. A plain packaging defect, the least subtle class of fault there is, on a supported platform, caught by any smoke test anyone cared to run, and shipped anyway. If a build pipeline will put that in front of a paying customer, it is not obvious what it would stop. Nothing did.

Can I Ask Why This Was Signed?

Can I ask why a package that cannot complete a TLS handshake was signed and published against a platform your own requirements page lists as supported? Not who signed it. I have no interest in a name and it is not about one. What permitted it, because something did, fifteen minutes after the build finished, and whatever it is it is still running today.

Now the blunt version. This is not a project I pulled off a forge and took my chances with. It is paid for. There is a contract behind it, a support line, a requirements page making a claim about Linux, and a signing key asserting the package came from Cisco and is fit to install. Each of those is a statement to a customer, and on this release each was worth nothing, because the result was never opened.

And the standard being missed here is not mine. It is theirs. The four variables that would have carried those paths are documented in a manual page you ship inside the bundle. Their own packaging format has a %config marker they did not use and a documentation section they left empty. Their own build ran in a container they never used to run the output. I am not asking a networking and collaboration company to invent anything. I am asking why it did not read its own manuals.

As such the excuse I will not accept is that this is hard. It is not hard for anybody, and it is certainly not hard for a company that does this daily, at this scale, for this money. Professionals doing this every day have no excuse, and being large is not one.

And can I ask one more, because it is the one that actually matters. You sell firewalls. You sell an email gateway, a VPN concentrator, an identity service, a management centre for all of it, and the pitch on all of them is that you understand this better than your customer does. So how does a company that positions itself as an authority on network security ship a client that cannot validate a certificate? Not fails to validate it cleverly. Cannot look one up, because the directory was never there.

There is no version of that answer I want to hear that starts with the desktop team being separate from the appliance team. They are the same signature, the same pipeline, the same published claim about a supported platform, and the same missing step at the end, which is opening the box. If certificate validation is not checked before shipping in the product where breakage is loud and harmless, I have no reason to believe it is checked in the product where breakage is quiet and expensive.

And the honest part, said plainly. None of this surprised me. I have come to expect this standard from this vendor, which is why, where the choice is mine, I do not buy their kit and I do not build on it. That is not a preference about badges. It is the same judgement I would make about any supplier: I have measured their work, more than once, and it keeps coming back the same. The catalogue above is one measurement. What it took to write the first half of this post is another.

The reason I was on Webex at all is that the choice was not mine, and that is worth naming, because it is the position most people reading this are in. You rarely get to avoid a vendor whose quality you have already taken the measure of. Somebody else signs the contract, the kit arrives, and the first person to find out what was skipped in the build is you, at your desk, with a meeting starting.

Shipping Something You Never Ran

There will be an internal explanation. A busy sprint, a pipeline that changed hands, a platform with no owner. None of it is worth hearing, because each describes the same thing: the work was not done and nothing in the process required it to be.

There is an old standard in trades work that has never made it into software: you do not leave the job until you have run the thing. You fill the system and check every joint. You energise the board and test every circuit. Not because you doubt your work. Because the customer is going to use it, and finding out in front of them is not a professional outcome. Nobody watches you do it. You do it anyway. That is the entire meaning of the word.

Software has spent thirty years arguing that it is different, that the build is the deliverable and the install is somebody else’s problem, that a green pipeline is the same thing as a working product, and it is not. A build that has never been executed outside the container that produced it has not been finished, it has been abandoned at the point where finishing gets boring. Everything downstream of it is a claim about work that was not done.

The fix is one configure flag. The check is one line of shell. The cost of neither is what is interesting; the interesting number is how many people typed their credentials into a Webex window, watched it say their internet was down, and believed it, because Cisco told them so and Cisco are a networking company. That is what the missing test actually bought: not a bug, a lie that the product tells confidently, every launch, to people who have no way to know better.

And that is the line worth drawing before you close the tab, because the two halves of this post are not two subjects. A trust path that points at a build container and an authentication bypass on an edge appliance are the same failure at different stakes. Both are a value nobody checked, in an artefact nobody ran, signed by a process that attests provenance and not fitness. The one in front of me was the harmless kind. It broke loudly, on my own desk, and I was the first to know. The other kind does not do that for you.

The same missing check at two different stakes, and only one of them tells youOne missing check, two stakes. Only one of them tells you it is there.Same underlying failure: a value nobody checked, in an artefact nobody ran, signed by a process that attests provenanceThe kind that breaks loudlywhat it wasa trust store path that does not existwho found itthe customer, first launchwhat it costone evening, one deskwho learnedme, immediatelyEnds up written down in public.The kind that breaks nothingwhat it wasa length nobody bounds-checkedwho found itwhoever went lookingwhat it costan incidentwho learnedthe customer, from the reportEnds up as 1 of Cisco's 98 in the catalogue.A process that will not catch the left-hand column was never going to catch the right-hand one. The difference is luck, not diligence.
The defect in this post and the entries in that catalogue are the same failure wearing different clothes. One announced itself on my desk on the first launch. The other kind announces itself to somebody else first.

Nobody at Cisco decided to ship an exploitable firewall, any more than they decided to ship a client that cannot reach the internet. That is not a defence. It is the charge. Neither needs deciding, and that is precisely the problem: both are what comes out the far end when a pipeline compiles, signs and publishes without anyone being made responsible for opening the result. A process that will not catch a directory that does not exist was never going to catch a length that is not checked, and a vendor that sells you the appliance guarding your perimeter is not entitled to that process.

So when a vendor’s name keeps appearing on that list, resist the comfortable explanation that they are simply big and heavily targeted. Scale explains the volume. It does not explain the kind. Look instead at what their build process does with the boring things, because the boring things are measurable from the outside, by you, today, on kit you already own.

Check your own bundles. strings and readelf and twenty minutes will tell you which of your vendors ship a path to a machine you will never see. What you are really measuring is not the path. It is whether anybody there was looking, and if the answer is no on something this cheap to catch, you already know what it is on the things that are not.


  1. Cisco — Webex App system requirements — the supported platform list, Linux included. ↩︎

  2. OpenSSL — INSTALL.md, --openssldir — “Directory for OpenSSL configuration files, and also the default certificate and key store.” ↩︎ ↩︎

  3. OpenSSL — SSL_CTX_load_verify_locations(3) — the default CA file is cert.pem and the default CA directory certs, both inside the default OpenSSL directory; and on return values, “A missing default location is still treated as a success.” ↩︎ ↩︎

  4. Conan 2 — conan cache — package binaries live under a hashed path in the local cache, which is where /workspace/.conan2/p/b/<hash>/p comes from. ↩︎

  5. OpenSSL — openssl-env(7) — SSL_CERT_DIR and SSL_CERT_FILE “specify the default directory or file containing CA certificates”. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  6. OpenSSL — provider(7) — the default provider and what a configuration that omits it leaves unavailable. ↩︎ ↩︎

  7. OpenSSL — config(5) — the configuration file OpenSSL loads at initialisation, and where it is looked for. ↩︎

  8. freedesktop.org — XDG Base Directory Specification — “The base directory defined by $XDG_DATA_HOME is considered more important than any of the base directories defined by $XDG_DATA_DIRS.” ↩︎

  9. freedesktop.org — Desktop Entry Specification — where .desktop files are searched for and how one shadows another of the same name. ↩︎

  10. Filesystem Hierarchy Standard 3.0 — the directories a root filesystem is expected to contain, /workspace not among them. ↩︎

  11. update-ca-trust(8) — how the consolidated bundle under /etc/pki/ca-trust/extracted is produced on Fedora and its relatives. ↩︎

  12. ld.so(8) — $ORIGIN expands to the directory containing the program or shared object, which is what makes a bundled tree relocatable. ↩︎

  13. glibc timeline — “2018-08-01 GLIBC 2.28 — The GNU C Library version 2.28 is now available”. ↩︎

  14. glibc — Release wiki — the version-to-distribution table; Red Hat Enterprise Linux 8 is glibc 2.28. ↩︎

  15. RPM — spec file reference — %config and what the package manager does with a file marked as configuration. ↩︎ ↩︎

  16. Fedora packaging guidelines — configuration files — when a shipped file must be marked %config. ↩︎

  17. glibc FAQ — why a statically linked glibc binary still needs the host’s NSS modules at runtime. ↩︎

  18. CISA — Known Exploited Vulnerabilities Catalog — counted from the published JSON feed, catalogue version 2026.09.14, 1,710 entries. ↩︎ ↩︎