Advisories

Advisory 01/2026: Tcl safe-interpreter escapes via clock locale and time zone names

Risk
High CVSS 3.1 8.3 for (1), (2) and (4)
Advisory released
3 November 2026
Vulnerability public
20 October 2026
CVE
none assigned
Advisory
Tcl safe-interpreter escape and local file inclusion via [clock ... -locale], msgcat and [clock ... -timezone]
Application
Tcl core, library packages msgcat and clock
Affected
  • (1) Tcl 8.6.5 - 8.6.18 (msgcat 1.6.x), 9.0.0 - 9.0.4 and 9.1.0 (msgcat 1.7.1)
  • (2) Tcl core-8-6-branch, 1 - 29 October 2026 (msgcat 1.6.2, unreleased)
  • (3) Tcl 8.6.5 - 8.6.18, 9.0.0 - 9.0.4, 9.1.0, and 9.x branches until 29 October 2026
  • (4) Tcl 8.6.16 - 8.6.18 and core-8-6-branch up to now
Not affected
Tcl 8.5 and earlier; Tcl 8.6.0 - 8.6.4 for (1) - (3); Tcl 9.x for (2) and (4)
Severity
  • (1), (2), (4): code in a safe interpreter makes the trusted parent source an attacker-chosen file, i.e. arbitrary code execution outside the sandbox
  • (3): local file inclusion in trusted code that passes an untrusted locale to msgcat
CWE
CWE-265, CWE-94, CWE-98
Vendor status
  • (1) - (3) fixed in core-8-6-branch, core-9-0-branch and main, not yet in a release
  • (4) reported 3 November 2026, open
Upstream tickets
Vulnerability public
20 October 2026 (ticket 2f8bb48009 in the public tracker); (4) on 3 November 2026 (ticket 3d978e0a84)
Advisory release
3 November 2026
Last modified
3 November 2026
Author
Ben Fuhrmannek, SektionEins GmbH
Contact
Contact form on this website
Reference
https://www.sektioneins.de/en/advisories/advisory-012026-tcl-clock-safe-interp-escape.html

Status. Issues (1) to (3) are fixed in all three development branches as of 29 October 2026, and the 9.0.5 release branch (core-9-0-5-rc, opened 2 November 2026) contains the fix. At the time of release of this advisory, no public Tcl release ships it yet. Issue (4) was found while re-checking that fix on 3 November 2026 and reported the same day in the public tracker as ticket 3d978e0a84, including a working PoC. It was still open and unassigned when this advisory was released.

Overview

Tcl can run untrusted scripts in a safe interpreter (interp create -safe, or the Safe Base ::safe::interpCreate). A safe interpreter has no exec, open, socket or source of arbitrary files; applications use it for plugin and macro systems, formula and rule engines, templating and similar places where users supply Tcl code. A few commands are made available to the child as aliases that run in the trusted parent, and clock is one of them. Everything clock does on the child's behalf, including loading locale catalogues through msgcat and loading time zone files, therefore runs with the parent's full command set and with arguments the child chooses.

Both loaders turn a name into a file path and source that file. Both try to keep the name inside their data directory with a regular expression. This advisory describes four ways in which that went wrong:

  1. clock ... -locale <path> made the parent source <path>.msg from anywhere, because the -locale value reached msgcat::mcpackagelocale without any check.
  2. The first upstream fix (1 and 2 October 2026) rejected absolute and .. paths but not a leading ~. On 8.6, file join expands ~, so -locale ~/dir/file still escaped.
  3. Trusted code that forwards an untrusted locale to msgcat loaded attacker-named .msg files. After the first fix this remained open on 9.x through mcpackagelocale preferences and mcpreferences, which skipped the new check.
  4. clock ... -timezone :~/dir/file has the same tilde gap in clock's own time zone guard. It makes the parent source any file below a home directory, with no .msg suffix. This is a regression from 8.6.16, reported on 3 November 2026 and still open.

(1) Safe-interpreter escape via clock -locale with a path

Affected: Tcl 8.6.5 to 8.6.18 (msgcat 1.6.0 and 1.6.1), 9.0.0 to 9.0.4 and 9.1.0 (msgcat 1.7.1). Fixed in the development branches on 1 and 2 October 2026 (msgcat 1.6.2 and 1.7.2).

A single line in the safe child is enough:

clock format 0 -gmt 1 -locale /path/to/attacker/file   ;# sources /path/to/attacker/file.msg

Root cause. Since Tcl 8.6.5 (msgcat 1.6.0, March 2016) clock loads locales through the package-level msgcat API instead of msgcat::mclocale. On 8.6 format, scan and add lowercase the -locale value and call EnterLocale, which ends in mcpackagelocale set $locale. On 9.x the C formatter calls ::tcl::clock::mcget, which does the same. mcpackagelocale set builds a preference list (getpreferences lowercases the value and splits it on _) and calls msgcat::Load:

set langfile [file join $langdir $p.msg]
if {[file exists $langfile]} {
    namespace inscope $ns [list ::source -encoding utf-8 $langfile]
}

file join discards $langdir when $p is absolute, and a relative $p with ../ climbs out of it. The neighbouring code paths did check: mclocale rejected any value that differed from its [file tail], and clock's time zone loader had an explicit "security sensitive" guard. The -locale route met neither. Tcl 8.5 and 8.6.0 to 8.6.4 still go through mclocale and are not affected (verified on 8.5.9 and by source inspection of the 8.6.0 to 8.6.4 tags).

Preconditions. A file ending in .msg with attacker-controlled content, at a known path the parent can read. The suffix cannot be stripped (a NUL byte does not truncate the path). On a case-sensitive file system the path must exist in lowercase. source stops at a Ctrl-Z byte, so only the part up to the payload needs to be valid Tcl. msgcat caches loaded locales, so each locale string loads once per process.

Proof of concept. On pre-fix 8.6.18 and 9.1.0 the payload in the .msg file runs in the parent with issafe 0 in namespace ::tcl::clock and can call exec.

Fix. Upstream commits c0753a3a4b (8.6, msgcat 1.6.2; Fossil cff6854951), 7634fe4a9e and e02a566084 (9.x, msgcat 1.7.2), amended by bf6ea373b7 (Fossil a7fb3fdd63) and 7eb4ba31c6. They add the check

regexp {^[/\\]|^[a-z]+:|(?:^|[/\\])\.\.} $locale

to mclocale, mcpackagelocale set and mcutil::getpreferences, mirroring clock's time zone guard. The change came from ticket 301613 (a traceback with the he_IL locale) and was not handled as a security fix. Hashes here are from the git mirror of the Tcl repository; Fossil check-in hashes are given where known.

(2) Bypass of the first fix on 8.6 via a tilde path

Affected: core-8-6-branch from 1 October 2026 (msgcat 1.6.2) until the fix of 29 October 2026. msgcat 1.6.2 was never part of a public release. Not affected: 9.x, where file join leaves a leading ~ literal.

The regular expression of the first fix does not reject a leading ~. On 8.6, file join expands it, so ~/dir/file or ~user/dir/file resolves to an absolute path and is sourced in the parent exactly as in (1):

interp eval [interp create -safe] {clock format 0 -gmt 1 -locale ~/tcldemo/x}
# -> ~/tcldemo/x.msg runs in the parent: issafe=0, exec present

The 1.6.2 check was also weaker than the one it replaced: pre-fix mclocale rejected a tilde value through its [file tail] comparison. Preconditions as in (1), with the file below a home directory reachable as ~ or ~user.

Reported as ticket 2f8bb48009 on 20 October 2026.

(3) Local file inclusion in trusted code via msgcat

Affected: Tcl 8.6.5 to 8.6.18 and 9.0.0 to 9.1.0 through mcpackagelocale set; on 9.x additionally through mcpackagelocale preferences and msgcat::mcpreferences, which stayed open after the first fix until 29 October 2026. Not reachable from a safe child (msgcat is not aliased into a safe interpreter), so this is not a sandbox escape.

Applications that take a locale from request data (?lang=, Accept-Language, a stored user preference) and pass it to clock ... -locale or to msgcat source attacker-named .msg files. On 9.x with msgcat 1.7.2, after the first fix, mcpackagelocale preferences /abs/x still sourced /abs/x.msg, while mcpackagelocale set /abs/x was rejected: preferences set the preference list straight from its arguments and called Load without going through getpreferences. msgcat::mcpreferences /abs/x did the same once a package had registered a catalogue folder (verified on 9.1.0 with msgcat 1.7.1 and on the msgcat 1.7.2 file from before the 29 October fix). Neither route lowercases the value. On 8.6 both commands are read-only, so only set and clock -locale apply there.

The impact depends entirely on the surrounding application. In a web application that forwards the locale, roughly AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H.

Fix for (2) and (3). Proposed by Serhiy Storchaka on 23 October 2026 in branches msgcat-unsafe-locale (b13d4f9267, Fossil 6e51715b5c) and msgcat-unsafe-locale-8-6 (26ca9658cf); merged by Jan Nijtmans on 29 October 2026 into main (e715784869, Fossil 85a543ddd2), core-9-0-branch (3c36b6428d, Fossil 15da8b971e) and core-8-6-branch (dbcbea1084, Fossil d89487325d). The fix adds one helper,

proc msgcat::UnsafeLocale {locale} {
    regexp {^[/\\~]|^[a-z]+:|(?:^|[/\\])\.\.} [string tolower $locale]
}

and uses it in mclocale, mcpackagelocale set, getpreferences, every argument of mcpackagelocale preferences (9.x), and in msgcat::Load right before a catalogue file is sourced. Load is the only place in msgcat that sources a file, so every route is now covered, including mcpreferences. New tests: msgcat-12.13 to 12.15 (9.x), msgcat-1.14, msgcat-1.15 and extra msgcat-12.11 cases (8.6). The msgcat version was not bumped because 1.6.2 and 1.7.2 were never released. The changelog entry ("msgcat did not check the locales") does not mark it as a security fix.

Verification of the fix (3 November 2026). Fresh builds of core-8-6-branch (8.6.18, 52a3c1d31a), core-9-0-branch (9.0.5, 08e3eb51a4) and main (9.1.1, f4a1c65f3a), tested with a probe script:

  • From a safe child, clock -locale with an absolute path, a ../ path, ~/... and ~user/... is rejected with locale "..." not valid on all three branches. Nothing is sourced.
  • In trusted code, mcpackagelocale set, mcpackagelocale preferences (absolute and tilde), mcpreferences and mclocale no longer load the probe.
  • A white-box pass over 25 candidate strings (leading blanks, ./~, x/~user, backslash and mixed separators, ..., drive letters, UNC, //zipfs:/, zipfs:, _ prefixes that getpreferences produces) found no value that passes UnsafeLocale and makes file join $langdir $p.msg resolve outside the msgs directory. Inner ~ components are not expanded by 8.6 file join.

Two minor points, neither exploitable as far as tested. The check is still a denylist: on Windows, Tcl treats any character followed by : as a drive prefix (ExtractWinRoot in generic/tclFileName.c tests only path[1] == ':'), whereas the regular expression matches only [a-z]+:. A locale such as 1:/x therefore passes and file join makes it absolute; Windows has no such drive, so it should not resolve (not tested on Windows). An allowlist such as ^[a-z0-9_]*$ would remove the question. Also, on main the mcpackagelocale present subcommand lost its check in the merge (it is still there on core-9-0-branch); present loads nothing, so this is only an inconsistency.

(4) Safe-interpreter escape via clock -timezone with a tilde path

Affected: Tcl 8.6.16, 8.6.17, 8.6.18 and core-8-6-branch as of 3 November 2026 (head 52a3c1d31a, which includes the msgcat fix). Not affected: 8.6.15 and earlier, 8.5, all 9.x. Reported on 3 November 2026 as ticket 3d978e0a84 (public tracker, status open, not fixed).

interp eval [interp create -safe] {clock format 0 -timezone :~/tcldemo/tz}
# -> ~/tcldemo/tz runs in the parent: issafe=0, ns=::tcl::clock, exec present

Root cause. ::tcl::clock::LoadTimeZoneFile sources time zone files:

if { [regexp {^[/\\]|^[a-zA-Z]+:|(?:^|[/\\])\.\.} $fileName] } {
    return -code error ... "time zone \":$fileName\" not valid"
}
try {
    source -encoding utf-8 [file join $DataDir $fileName]
} ...

It is the same denylist that msgcat 1.6.2 copied, with the same gap: no ~. On 8.6, file join $DataDir ~/tcldemo/tz returns ~/tcldemo/tz, and source expands the tilde. Until 8.6.15 the guard was an allowlist, ^[[.-.][:alpha:]_]+(?:/[[.-.][:alpha:]_]+)*$, which rejected ~. Commit e03a8502e0 (Fossil 67e7522bd1, sebres, 29 October 2024, "additional fix for [2c237beffbace823]: usage of time zones Etc/GMT±n were impossible") replaced it with the denylist so that names containing + would load. That change first shipped in 8.6.16. The 2f8bb4 fix hardened msgcat but left this guard unchanged; the tests for it (clock-1.5.1-* in tests/clock.test) cover .., //, zipfs: and C:, but no tilde.

The time zone route is more convenient for an attacker than (1) or (2):

  • The file can have any name. No .msg suffix is appended.
  • The value is not lowercased, so mixed-case paths work on case-sensitive file systems.
  • All of -timezone :~/x, -timezone ~/x (the fallback without a colon), any unique abbreviation (-t, -tim) and the scan and add subcommands reach it.

The child's call returns an error afterwards (the file defines no time zone data), but by then the file has run in the parent. 9.x is not affected: file join leaves the ~ literal and the file is not found (verified on 9.0.5 and 9.1.1). LoadZoneinfoFile has the same gap but only parses binary TZif data and does not execute it.

Preconditions. A file with attacker-controlled Tcl at a known path below a home directory that the parent process can resolve (~ is the parent's $HOME, ~user any account's home). Typical candidates are upload or cache directories below a service account's home, or files a multi-user system lets users write into their own home and the service then reads.

Proof of concept. With the one-liner above and a file ~/tcldemo/tz that prints the state of the interpreter it runs in, the output on core-8-6-branch and on Homebrew 8.6.18 is PWNED issafe=0 ns=::tcl::clock exec=exec. On 9.0.5 and 9.1.1: time zone ":~/tcldemo/tz" not found.

Suggested fix. Add ~ to both clock guards ({^[/\\~]|^[a-zA-Z]+:|(?:^|[/\\])\.\.}), or better, return to an allowlist that also admits + and digits, for example ^[A-Za-z0-9_+-]+(?:/[A-Za-z0-9_+-]+)*$. Ideally clock and msgcat share one helper so the two checks cannot drift apart again. Add ~/x and ~user/x cases to clock-1.5.1 and a safe-interpreter test like msgcat-1.15.

Severity

Issues (1), (2) and (4) are full escapes from a security boundary that exists to run untrusted code, with arbitrary command execution in the parent process as the result. They are gated by one real precondition: the attacker must also place a file with chosen content at a path the parent can read (for (4), below a home directory).

Estimated CVSS 3.1 for (1), (2) and (4): AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H, 8.3 (High). S:C because the code crosses out of the safe interpreter; AC:H and PR:L because the attacker needs script execution in the sandbox and a separately planted file. Where both are available to anonymous network users (PR:N) the score rises to about 9.0. Where planting a file is not possible, the issues are not exploitable. Treat the number as deployment-dependent.

Issue (3) is lower: no sandbox is crossed, it needs an application that forwards an untrusted locale, and the file must end in .msg.

Affected versions in the field

Tcl msgcat (1) -locale path (2) -locale ~ (3) trusted LFI (4) -timezone ~
8.5.x and earlier 1.4.x, 1.5.x no no no no
8.6.0 - 8.6.4 1.5.x no no no no
8.6.5 - 8.6.15 1.6.0, 1.6.1 yes n/a (part of 1) yes (set) no
8.6.16 - 8.6.18 1.6.1 yes n/a (part of 1) yes (set) yes
core-8-6-branch now 1.6.2 (fixed) no no no yes
9.0.0 - 9.0.4, 9.1.0 1.7.1 yes no yes (set, preferences, mcpreferences) no
core-9-0-branch, main now 1.7.2 (fixed) no no no no

On 8.6 releases, (1) already includes the tilde form (verified on Homebrew 8.6.18), so (2) only matters for builds of core-8-6-branch from October 2026. Verified builds: Apple 8.5.9 (msgcat 1.4.3); Homebrew tcl-tk@8 8.6.18 and tcl-tk 9.1.0; fresh builds of the three development branches on 20 October and 3 November 2026. The 8.6.0 to 8.6.15 boundaries come from source inspection of the release tags. As of 20 October 2026 Alpine latest (3.24.2) shipped tcl-8.6.17-r1 with msgcat 1.6.1, vulnerable to (1) and (4). Most installed systems are in one of the vulnerable rows until the next releases are out.

Recommendations

For the Tcl Core Team

  1. Fix (4) on core-8-6-branch before the next 8.6 release (see the suggested fix above), and consider sharing one validation helper between clock and msgcat.
  2. Release the msgcat fix and mark it as a security fix in the release notes. The October changes arrived via functional bug reports, so downstream packagers have no reason to prioritise them.
  3. Request a CVE for the safe-interpreter escapes, or allow the reporter to do so.
  4. Prefer allowlists for names that become file paths. Both denylists in this advisory missed the same case.

For application developers

If sandboxed code does not need clock, remove it from the child:

interp alias $child clock {}

Otherwise wrap the alias and reject path-like locale and time zone values. clock accepts option abbreviations (-l, -loc, -t, -tim) and the position of options varies between subcommands, so scan every argument:

proc ::sandbox_clock {args} {
    for {set i 1} {$i < [llength $args] - 1} {incr i} {
        set opt [lindex $args $i]
        set val [lindex $args $i+1]
        if {[string match -l* $opt]
                && ![regexp {^(system|current|[a-z]{2,3}(_[a-z0-9]+)*)?$} \
                         [string tolower $val]]} {
            return -code error "locale not allowed in sandbox"
        }
        if {[string match -t* $opt]
                && [regexp {^:?[~/\\]|^:?[A-Za-z]+:|\.\.} $val]} {
            return -code error "time zone not allowed in sandbox"
        }
    }
    tailcall ::clock {*}$args
}
interp alias $child clock {} ::sandbox_clock

Tested on 8.6.18 (core-8-6-branch) and 9.1.1: all -locale and -timezone path forms above are rejected; :America/New_York, :Etc/GMT+5, EST5EDT, +0100, :localtime, de_DE and current still work.

In trusted code, validate locales against an allowlist (at least ^[a-z]{2,3}(_[a-z0-9]+)*$) before passing them to clock or msgcat.

A safe interpreter limits commands, not memory or CPU. Run genuinely untrusted code in a separate, unprivileged process as well.

Disclosure timeline

A note on disclosure. The Tcl project does not document a private channel for security reports. Its README asks for bug reports as tickets in the issue tracker at core.tcl-lang.org, which is public, and that is where we reported. The details of (2) and (3) therefore became public on 20 October 2026, and those of (4) on 3 November 2026, the days the tickets were filed. Issue (1) had already been fixed upstream through an unrelated public ticket. This advisory adds no new attack details; it collects the analysis, the affected versions and the workarounds in one place.

Date Event
1 March 2016 Tcl 8.6.5 ships msgcat 1.6.0; clock now loads locales through mcpackagelocale. Start of (1) and (3).
29 October 2024 e03a8502e0 (Fossil 67e7522bd1) replaces clock's time zone allowlist with a denylist that misses ~; first released in 8.6.16. Start of (4).
1 - 2 October 2026 Upstream adds the locale path check (msgcat 1.6.2 and 1.7.2) via ticket 301613, not as a security fix. Fixes the absolute and .. forms of (1); (2) and the 9.x part of (3) remain.
20 October 2026 Our analysis: 8.5 cleared, the October fix found incomplete. Ticket 2f8bb48009 filed in the public tracker for (2), mentioning the 9.x part of (3). Jan Nijtmans forwards it to sebres, author of the October amendment.
23 October 2026 Serhiy Storchaka confirms (2) and (3) and proposes fixes in branches msgcat-unsafe-locale and msgcat-unsafe-locale-8-6.
29 October 2026 Jan Nijtmans merges the fix into main, core-9-0-branch and core-8-6-branch and closes the ticket as fixed. msgcat versions are not bumped since 1.6.2 and 1.7.2 were never released.
2 November 2026 9.0.5 release branch core-9-0-5-rc opened; it contains the fix.
3 November 2026 Fix verified on fresh builds of all three branches; no bypass found. Issue (4) found and reproduced on 8.6.16 to current core-8-6-branch.
3 November 2026 (4) reported in the public tracker as ticket 3d978e0a84, with PoC and suggested fix.
3 November 2026 Public release of this advisory. (4) is not fixed yet, no public Tcl release contains the fixes for (1) to (3), and no CVE is assigned.

Credits

Discovery and analysis: Ben Fuhrmannek, SektionEins GmbH. Fix for (2) and (3) by Serhiy Storchaka, reviewed and merged by Jan Nijtmans. The first locale check (1 and 2 October 2026) is by Jan Nijtmans and sebres.

References