TACACS+ is one of the ways large networks centralise administrative access to their equipment, alongside RADIUS and DIAMETER, and it is the one that tends to be chosen where per-command control matters. Instead of every router, switch, firewall and console server keeping its own local accounts, each device asks a TACACS+ server whether a login is allowed, at what privilege level, and often whether each individual command should be permitted. It is standard in enterprise, telco and critical infrastructure environments, usually with a single pair of servers answering for the entire fleet, which makes it an obvious target for anyone who already has a foothold on the management network and wants the rest of it.
On a large number of those networks the daemon answering is tac_plus, a piece of C descended from reference code Cisco published in the early nineties and abandoned long ago. This post details a remote, pre-authentication format string vulnerability (CVE-pending) on an error path in that twenty-five year old ancestor code, which yields code execution as the daemon user, root by default. Shrubbery Networks has patched it in F4.0.4.32, the Facebook fork never will, and the write-up is in our advisory published alongside this post.
The bug on its own is not the interesting part, so the post covers the ground around it as well. It starts with how TACACS+ works and how its code changed hands over most of the protocol's forty-two year life, outliving every organisation that maintained it, and what earlier researchers had already found in it. Then the vulnerability, a proof of concept, and the PSK oracle that turns the two into a practical chain, followed by how several months of disclosure went.
Taken together it reads as a case study in how software security actually works in 2026: a bug class the industry thought it had retired, code that nobody quite owns, and a disclosure process that only began working on the day the ninety-day deadline expired.
One path we did not take all the way is worth flagging up front, because it is the only one that does not require being on the management network already. A device that speaks TACACS+ holds the key and will obfuscate whatever it is handed, so an attacker who can reach that device's login form, which on plenty of estates means the corporate network and on some means the public internet, may be able to get a payload to the server correctly keyed without ever holding the secret. We confirmed the server-side behaviour that makes it possible and stopped short of vendor equipment, so if you ship a TACACS+ client that accepts a username from outside the management network, or you are looking at one from the other side, that is the part of this post to read.
If you would rather skip the background, jump to the vulnerability and attack chain.
Authentication, authorisation and accounting (AAA) are three separate exchanges in TACACS+, and most deployments use all of them, so authentication decides whether you get in, authorisation decides what you are allowed to run once you are there, and accounting records what you did. When you SSH into a managed switch and type a password, the switch is not checking that password itself. It opens a TCP connection to port 49 on a TACACS+ server, hands over your username, the port you came in on, where you came from and, for the simple password case, the password itself, and then does whatever the server says. Other authentication types run as a challenge and response exchange rather than a single password, CHAP, MSCHAP and MSCHAPv2 among them. Every command you subsequently type may be sent for authorisation in the same way, so a server in that position sees administrative credentials for the whole fleet in close to real time. That per-command control is what keeps TACACS+ in service where RADIUS would otherwise do the job, since RADIUS bundles authentication with authorisation and protects only the password field, while TACACS+ separates them and obfuscates the entire packet body.
Confidentiality in the protocol comes from a pre-shared key (PSK), with the body of each packet XORed against an MD5-derived keystream computed from the session ID, the key, the version and the sequence number. There is no integrity check, no authenticated encryption and no key exchange. Before anyone jumps up and down, none of this is a secret, since it is described plainly in RFC 8907, which documented the protocol in September 2020, more than two decades after Cisco started shipping it. The RFC is blunt about the consequences, deprecating the unencrypted flag outright, since "This option is deprecated and MUST NOT be used in production" (section 4.5), and requiring that TACACS+ "MUST be deployed over networks that ensure privacy and integrity" and be separated from other traffic (section 10.5).
Both are requirements on the deployment rather than on the protocol, and nothing in TACACS+ enforces or reports on either, so where a management network is not in fact private the obfuscation layer is the only thing standing between the session and anyone watching it. The key itself is static and shared, with the same value configured on the server and on every device that talks to it, so rotating it means changing all of them together.
RFC 9887, published in December 2025 on the Standards Track, goes further than a warning and specifies TACACS+ over TLS 1.3, retiring obfuscation outright on the grounds that "The introduction of TLS authentication and encryption to TACACS+ replaces this former mechanism, so obfuscation is hereby obsoleted". That is the current best practice and the eventual answer to most of this post, but it arrived long after the code discussed below, and the installed base still speaking plain TACACS+ is very large.
Attackers with the resources to care have been working against that obfuscation layer for years. Cisco Talos documented it publicly, reporting in February 2025 that the actor it tracks as Salt Typhoon was capturing SNMP, TACACS+ and RADIUS traffic on compromised network devices, including the shared secret keys exchanged with their AAA servers, and had modified TACACS+ server addresses on some of that gear. The joint advisory published six months later, in August 2025, by CISA, the FBI, the NSA and international partners as AA25-239A describes the same actor in more detail, capturing TACACS+ traffic from compromised routers (Network Sniffing, T1040) and redirecting it by modifying the TACACS+ configuration on those routers (Modify Authentication Process, T1556), in one case recovering a shared secret stored with Cisco Type 7 encoding, which is an obfuscation rather than encryption and is reversed by tools that have been public for decades. None of it involves the bug in this post, but between them the two show how a capable actor treats that layer, which is to leave MD5 alone and get the key some other way, abusing the innate transient trust the network design hands them.
More recently, in August 2026, Sygnia's account of Fire Ant, an intrusion set it links to the same China-nexus cluster Mandiant tracks as UNC3886, goes further still. It describes an actor inside tac_plus itself, where a toolset called TacTap hooks accept and accept4 inside the daemon to capture credentials from the session-handling path before they reach disk, storing them XORed with a static key of 0xEF. None of that touches the bug in this post either, but it says something that tac_plus itself, not just the traffic passing through it, is somewhere a capable actor has already been living.
An authentication session is not much more than a client opening a TCP connection to port 49 and sending a single AUTHEN/START describing who is logging in and from where, to which the server answers with a status of allow, deny, error, or a request for more information. Where more is needed the two exchange CONTINUE and REPLY packets over the same connection until the session resolves one way or the other, and authorisation and accounting follow the same shape with their own packet types.
Every one of those packets has a twelve-byte header that is never obfuscated, followed by a body that always is. For an AUTHEN/START the body carries the username, the port, the remote address and any associated data, each preceded by a single byte giving its length:

TACACS began at BBN as a simple UDP protocol for MILNET, and by the time it reached the IETF as RFC 1492 it was already a decade old, with that RFC noting the original specification had become effectively unobtainable. Cisco extended it as XTACACS and then replaced it outright with TACACS+, an incompatible TCP protocol sharing little but the name, and distributed an open source Developer's Kit so that others could build servers of their own. It was explicitly not a product, and its own documentation, in classic fashion, pointed anyone needing a working daemon at Cisco's commercial offering, but it is the common ancestor of every fork discussed here.
ftpeng.cisco.com, reaching version F4.0.4.alpha, with documentation telling anyone who actually needs a TACACS+ daemon to buy the commercial product insteadSolar Designer's paper is still the pivotal piece of work on this protocol more than a quarter of a century after it appeared. Still being revised as recently as 2024, it sets out seven weaknesses in the obfuscation scheme described above, several of them structural and unfixable without breaking interoperability. They include the near-total absence of integrity checking, the lack of replay protection, forced session ID collisions, birthday-bound collisions across large session populations, and the absence of padding, which leaks the length of user passwords straight off the wire.
The weakness that bears most directly on this post is the one he puts like this:
Offline attacks against the encryption key are possible with only one packet collected off the wire, and run much faster than similar attacks against UNIX passwords do.
Writing in 2000, he had already described almost exactly the attack we chain to the format string bug below, the only meaningful difference lying in the threat model. His seventh weakness, summarised in the discovery section below, is an unchecked length and an integer overflow in packet body length handling in Cisco's own daemon, which makes the lineage of memory-safety bugs in this codebase as old as the public analysis of the protocol itself.
With Cisco out of the picture, Shrubbery Networks picked up the kit and maintained it for close to two decades as the de facto community tac_plus, reaching the F4.0.4.x series. Facebook in turn forked Shrubbery, ran it at scale, and published that tree as F4.0.4.28-7fb in late 2015, keeping it up for nearly ten years. Downstream of the two sit distribution packages, the FreeBSD port net/tac_plus4 and the pkgsrc entries net/tacacs-shrubbery and net/tacacs among the ones still carried today, along with an unknown number of vendor appliances and internal builds. Debian dropped its tacacs+ package in September 2019, at 4.0.4.27a-3, so the Linux side is now mostly the AUR, third-party repositories and whatever was built locally. The protocol itself was finally written down as an RFC in 2020, by which point the code most people were running had not had an official release in five years.
tac_plusShrubbery's F4.0.4.28 turned out to be its last official tarball for eleven years, and the code did not stop changing over that time. Shrubbery published nothing in that window, not even a patch for the 2023 command injection issues, so the fixes that reached users came from distributions and independent maintainers patching their own packages at build time. By 2025 the thing running on any given host was a private accumulation of fixes on top of a decade-old tarball, and not anything a single upstream had blessed. Both of those CVEs needed a non-default configuration to be reachable at all, which the bugs in this post do not.
Facebook archived its repository in August 2025, the IETF standardised a TLS transport four months later, and Shrubbery broke its silence in February 2026 with two releases in eight days.
before authorization or after authorization directive is configured, which is not the defaulttac_plus itself to intercept credentials from inside the daemonsend_authen_error() (this post)tac_plus went onto our target backlog after we spotted the Facebook fork being used in the wild, which raised the obvious question of how a codebase in that position had fared over the past quarter of a century. It came off the backlog on a short flight from Australia to Malaysia, with no internet, no local LLM on the laptop, and a copy of the fork checked out, since any travel out of Australia needs a few ways to pass the time and auditing code has been a good spend of a few hours for a couple of decades now.
The daemon is small and easy to follow, so the plan was an ordinary mapping pass from startup through the connection path into the authentication flow, looking for where data off the wire reaches the code that acts on it and which parts had already had attention. The entry point is start_session() in tac_plus.c, which runs for every accepted connection, and it begins by calling read_packet() in packet.c. That function reads the 12-byte cleartext header, takes the size of the body from hdr.datalength, allocates for header and body together and reads the rest off the socket.
That allocation is where Solar Designer's seventh weakness lived, a classic integer overflow into memory corruption where a large length wrapped once the header size was added, giving a smaller allocation than the caller expected and a buffer overflow on the copy that followed. He published a patch for it against F4.0.3.alpha at the time, and the tree in front of us was still carrying it:
Past the read, start_session() dispatches on hdr->type into authen(), author() or accounting(), and for an AUTHEN/START the length arithmetic is checked once more in authen() before do_start() copies the fields of the packet into the global session structure.
Every failure along the way, starting with a body shorter than the fixed fields it is required to carry, funnels into send_authen_error() in packet.c, a short function that builds a one line message out of the session data and logs it before replying to the client:
The same buf is used twice, report() writing it to the daemon's log through syslog or the log file, and send_authen_reply() putting it on the wire as the server message of the error reply, obfuscated with the shared secret like any other packet body.
Both matter later in this post, but the one that sets off a certain bug pattern recognition nostalgia is the report() call, because the helper takes its format string as an argument and hands it straight to vsnprintf():
Passing a variable as the format string is bad wherever it appears and exploitable wherever that variable comes off the wire, so the instinct is to look at every instance before tracing any one of them:
The one in maxsessint.c sits behind a debug flag and only exists in a MAXSESS build, but the other four all run in normal operation, since report() formats and logs whatever it is handed regardless of the priority it is given. The strings they format are built from a pid and the numeric client address, though, which leaves nowhere to put a specifier.
The promising one is the call in packet.c, already open in front of us, on an error path a client reaches with its first packet.
Of the two values it formats, session.peer is the duller, filled in right after accept() by a getnameinfo() call with NI_NUMERICHOST, so it holds the client's address as digits and dots. On Shrubbery that is conditional, since -L sets lookup_peer and the name then comes from reverse DNS, while the fork dropped that branch from the accept path at some point and now always asks for the numeric form.
session.port leads back to do_start() in authen.c, where the fields of the AUTHEN/START are carved out of the packet body one after another using the single byte lengths the client supplied:
An attacker supplied string is copied straight into session.port, which tac_plus.h declares as char port[NAS_PORT_MAX_LEN+1] and annotates as being there for error reporting.
Nothing validates the field, so a client that puts %x in it has the daemon read its own stack and one that puts %n there has it write to memory, none of which requires authenticating first, though there is one caveat we will come to shortly, since all of this sits in the TACACS+ packet body.
Arriving at that inside the first hour, and the first gin and tonic on a plane, was not the expected outcome, and it suggested that the prior attention we had gone looking for had largely never arrived.

Handing buf to report() and then to send_authen_reply() invites the assumption that the expanded string is what comes back to the client, but report() formats into a buffer of its own and leaves buf untouched, so the specifiers reach the client verbatim.
Against a running daemon, an AUTHEN/START carrying port="%x.%x.%x.%x.%x.%x.%x.%x" produces a log line reading 127.0.0.1 0.0.73.0.2e373231.78252031.252e7825.2e78252e: Illegally sized... while the reply on the wire carries 127.0.0.1 %x.%x.%x.%x.%x.%x.%x.%x: Illegally sized... unchanged.
The memory disclosure therefore lands in the daemon's log rather than in the attacker's hands, and the write primitive is blind, with no read-back channel to leak addresses through.
The Shrubbery tree carries the same mistake a second time, in get_authen_continue():
Here the tainted source is session.peer, the reverse-DNS name of the client, which is only populated when the daemon runs with -L.
The Facebook fork already passes a constant format string at this call site, and the commit that did it is worth looking at, because it is the last change packet.c ever received, made in August 2022 by someone at Meta who applied precisely the fix this post recommends:
Except, and as you may have inferred by now, the line directly beneath that fix is a call into send_authen_error(), which carries the identical bug and was left as it was.
Shrubbery never took even that much, and it is also the tree that still honours -L on the accept path, so on a Shrubbery build started that way an attacker who controls reverse DNS for their own address controls a second format string in a second function.
Nor was that the first time somebody had read these two functions closely, because the CHANGES file of the tree names both of them in release F4.0.4.24:
To prove the primitive quickly, so the report could go out and get patched efficiently (we assumed at least), we targeted the Facebook fork, F4.0.4.28-7fb, built from source in a container on i386 with randomize_va_space set to 0 and NX disabled, all shortcuts we chose deliberately.
Triggering the bug requires two packets, the first being a well-formed AUTHEN/START whose port field carries the payload, which lands it in session.port in authen.c:do_start(), and the second being a deliberately malformed continuation that sends the daemon down the error path into send_authen_error(). The payload is port_b, built further down the page, and here it is only the value placed in the port field:
port_b itself is a standard pwntools format string write targeting the GOT entry for free(), which is called shortly after the sink is reached:
0xfffecc00 is where the shellcode ends up on the stack, which is a fixed address only because randomize_va_space is 0 on this target, and the 128-byte NOP sled in front of the shellcode covers the slack in that guess. The shellcode duplicates file descriptor 4, args.fd in the script, which is the client's own socket in the forked child, so the resulting shell is interactive over the same connection that delivered the payload and no callback is required:
The first line of that output is the GOT address of free(), printed by the script from the local copy of the binary, and the second is the log line the daemon emits when the malformed continuation arrives, with the payload and shellcode visible in the port field before the message is truncated. The shell lands as tacacs because our container starts the daemon as an unprivileged user, while a default tac_plus install runs as root unless started with -U or -Q.
We did not target a hardened build, but tac_plus forks a child per connection without exec'ing a fresh image, so the randomisation slide holds for the life of the daemon and a failed attempt costs only that child. Whether a reply comes back tells the two apart, which is the crash oracle Hacking Blind was written for. Baptiste Moine's Exploiting a blind format string vulnerability in modern binaries does the harder version of this against ASLR, PIE, NX and Full RELRO, blind, with no leak channel and no sight of the formatted output, working inside 128 characters that the client IP eats into and without the bytes from 0x00 to 0x1F. Our sink is roomier than that, against a target that forks rather than respawns.
Either way, our read is that a modern build is a question of engineering effort, with every primitive already present, and we leave that as an exercise for the rea^H^H^Hagents.
None of that works without the shared key, since the port field lives in the obfuscated body and an attacker who cannot produce the keystream cannot choose what lands in session.port. It is a real constraint in theory, and the reason our advisory carries two scores, 9.8 without a secret in effect and 8.1 with one, the difference being attack complexity rather than impact, since the outcome is the same either way. In the reporting above, the actors already have the secret, taken from a device they had compromised. What we wanted was a way to attack the service directly over TCP, with no on-path position, no captured traffic, no client involved and no key lifted from a compromised device or a configuration backup. Working that out was a joint session with a few workmates, with special greets to Dan, Coen and Luke for their inputs that got it working so quickly.
It turns on two properties of the daemon, which never fails closed and hands out a known plaintext on its error path. Taking those in order, md5_xor() handles both directions of the obfuscation and does not consult TAC_PLUS_UNENCRYPTED when deciding whether to transform the payload. It transforms whenever a key is present and then toggles the flag, treating it as an internal marker of whether the payload is currently plaintext or obfuscated, not as the protocol policy bit that RFC 8907 describes.
On the receive side, read_packet() stores the peer's flags and carries on without rejecting unencrypted packets, and if no key resolves for the client then md5_xor() is simply a no-op. There is no point anywhere in the receive path at which a packet is dropped for failing to prove knowledge of the key, largely because nothing in the design ever establishes that knowledge in the first place.
The second piece is the oracle itself, since send_authen_error(), the same function that contains the format string sink, is reachable and produces a reply before the peer has demonstrated any knowledge of the key at all.
Sending a packet with a valid TACACS+ header and a deliberately truncated AUTHEN/START body causes authen.c:authen() to respond with send_authen_error("Invalid AUTHEN/START packet (too short)"), and the resulting plaintext takes the following form:
<your own IP> : Invalid AUTHEN/START packet (too short)Every byte of that is known to the attacker in advance, including the IP address, for the simple reason that it is their own. The full reply body, comprising status, flags, lengths and message, is therefore entirely predictable. It comes back XORed with a keystream derived from the session ID the attacker chose, the version, the sequence number and the key. That yields a key verification oracle from a single connection, with no capture, no on-path position and no valid credentials:
Against a secret of the sort that appears in rockyou.txt, this resolves in seconds:
Solar Designer noted in 2000 that a single captured packet permits an offline attack on the key, and Alexey Tyurin's 3 attacks on Cisco TACACS+ attacks the same construction from an on-path position in 2015, but both of those threat models assume an attacker who can observe legitimate traffic. This one does not, because the server hands over the material on request to anyone who can reach TCP/49, and it remains that way at the time of writing.
Putting the two halves of the post together gives a chain that exploits the service directly over TCP, with nothing but connectivity to the daemon:

There is another avenue that uses trusted clients to trigger the bug downstream, which opens the possibility of reaching it from outside the management network entirely.
Consider an attacker who cannot reach the TACACS+ server but can reach the login page of a device that uses it. The device is a legitimate client, it holds the key, and it will perform the obfuscation on anyone's behalf.
The AUTHEN/START field lengths are single bytes, so a client handed a username longer than that field can describe has to do something with the length. If it truncates or wraps rather than rejecting the input, the server parses the packet on the wrong boundary and reads the tail of the username as the fields that follow, port among them. A format string placed in that tail would arrive in session.port correctly keyed, because a legitimate client produced it, with no knowledge of the shared secret at all.

Reaching tac_plus directly, as every other path in this post requires, means reaching TCP/49, which for most deployments means being on the management network already. This path needs only reach to a login form, which on plenty of estates means the corporate network and on some means the public internet.
We tested this in the lab, handing a client an oversized username and watching the server read the tail of it as the fields that follow, which is the behaviour the proof of concept above relies on. What we have not done is run it against vendor devices in production configurations, and we would expect the handling of an over-long username to vary considerably from one vendor to the next.
We raise it because the pattern generalises, since laundering a payload through a trusted intermediary to sidestep a key you do not hold applies to any protocol combining per-field 8-bit lengths with a device that builds packets out of user-supplied input, and TACACS+ deployments are full of such devices.
Reaching the sink takes very little, since connecting to the daemon on TCP/49 and sending two packets puts the format string in session.port before anyone has authenticated, with no credentials, no session and no prior traffic involved. The shared secret is the only thing standing in front of it, and it is a prerequisite rather than a barrier. The ways of satisfying it sort by where the attacker has to be, with how far we took each one in the last column:
As for what is affected, the sink is in Cisco's original Developer's Kit, in every Shrubbery release up to and including F4.0.4.31 from February this year, and in the Facebook fork.
Shrubbery fixed it in F4.0.4.32, published on 21 September, and the Facebook fork is archived, so it will not be fixed at all. Anything that inherited that code inherited the bug with it, and a TACACS+ service is a feature of network appliances from dozens of vendors, most of which never said which tree they started from.
The full timeline is in the advisory, and the dates run as follows.
Meta was our first stop: Shrubbery's last release was then eleven years old, the Facebook fork had been maintained publicly until the previous August, and Meta's program covers the company's GitHub projects. After a couple of months of waiting and a follow-up through a backchannel, the answer was that scope covers active projects, and this one had been archived, so the bug was somebody else's problem. They did flag that Shrubbery's tree was active again, which was news to us, and pointed at Cisco, which was useful but still left the report with no owner. Cisco came back that nothing of theirs is affected and that the Developer's Kit was never meant for production, true enough and no help to anybody running it. Neither party issued a CVE, so we asked MITRE for the identifier ourselves.
Shrubbery was contacted after Meta flagged their recent release, and then said nothing for three months and four follow-ups. The reply came on the ninetieth day, which was also the day we were ready to publish, explaining that the correspondence had been sitting in a mailing list spam queue.
We deferred publication twice, the second time after Shrubbery said the issue would be handled on their return to the office on 14 September, and when that date passed with no release and no answer to multiple follow-ups, eventually F4.0.4.32 landed on the 21st-Sep-2026.
The release records it in CHANGES as a single line, "Remote pre-authentication format string vulnerability (CWE-134)", without the CVE identifier or any credit, and F4.0.4.32 is now the only version left in the download directory.
Both sinks are corrected the same way, by passing the data as an argument rather than as the format:
It is not quite the end of the pattern in the tree, since maxsessint.c:103 still hands report() a variable as its format, and while the string there is built server side and behind a debug flag in a MAXSESS build, it is the same shape of mistake sitting one annotation away from being caught.
The plan was to publish the advisory and leave it there, because taken piece by piece there is nothing remarkable in any of this. The bug is the textbook CWE-134: Use of Externally-Controlled Format String example, on an error path, reachable before authentication, in a process running as root, and it came out of an ordinary read of code. What changed our minds was everything around it. A protocol either unknown or long forgotten that a great deal of network authentication still rests on, a PSK oracle that turns its one remaining protection into an offline cracking problem, and a trusted client that will do the obfuscation for you if you ask it the right way. Underneath all of that, a picture that is unfortunately ordinary for code running in real production environments, inherited and unowned, still carrying a bug class the industry has the best claim to having beaten well into 2026.
Format strings were understood around 1999 and 2000, pillaged hard by a bunch of good hackers, and written up definitively by scut of team teso in Exploiting Format String Vulnerabilities before tooling engineered most of them out. Solar Designer's analysis landed in that same window, in May 2000, and send_authen_error() came through it untouched. He did describe the cryptographic weaknesses judged unfixable without breaking interoperability, and twenty-six years later we chained one of them to a memory corruption bug in the function that leaks the plaintext, which leaves send_authen_error() serving as both the lock and the key.
A compiler could have pointed at this exact line for twenty-five years, and the reason none ever did is a missing annotation rather than a missing flag, since tac_plus.h declares report() as a plain variadic function and no compiler has any idea it is a printf wrapper. The bug did not survive because it was hard to find. It also survived years of Facebook running the fork in public under one of the better funded bug bounty programs in the industry.
When it came to reporting the issue, every answer we got was reasonable on its own, and none of them adds up to an owner, because the code is shared and the responsibility for it is not. The fix was two lines, and the several months it took dragged out in whatever free time we could spare on finding someone to receive it, which is the gap initiatives like OpenAI's Patch the Planet, run with Trail of Bits, are trying to close.
The forks nobody published are harder still, since the same reference code sits inside appliances and internal builds where a TACACS+ server is a datasheet feature, nothing says which tree it came from, and a vendor has to recognise its own lineage in a post like this one for a report to have anywhere to go. Those are also the softest targets, because the hardening that makes the write difficult is what embedded firmware tends to ship without, and confirming one would mean having the firmware, which is where we would look next.
If you ship a TACACS+ server in a product, recognising your own lineage is a short job, so read send_authen_error() and get_authen_continue(), then annotate your logging wrappers with __attribute__((format(printf, n, n+1))) and build with -Wformat -Wformat-security -Werror=format-security, which between them will find the rest. The flags on their own will not, because a wrapper the compiler does not know is a format function is a wrapper it will not check.
If you are assessing equipment you have no source for, a server that answers a truncated AUTHEN/START with <client ip> : Invalid AUTHEN/START packet (too short) is running something descended from this code.
If you run tac_plus, upgrade to F4.0.4.32, or apply the change above to your own build if your packages have not caught up, and after that the controls carrying real weight are segmentation, so TCP/49 is reachable only from your NAS management addresses, and a shared secret a wordlist will not find.
Longer term the answer is RFC 9887, so the question worth asking is what else on your management network is riding a transport its own RFC has obsoleted.
Until we next read your source on a plane, ciao bella!
before/after authorization directivepacket.c