Package: docx2txt
Version: 1.4-5
Severity: normal
Tags: patch

docx2txt builds its unzip invocations as shell command strings and runs
them with backticks, interpolating the input filename inside double
quotes:

  404: my $unzip_cmd = "'$config_unzip' $config_unzip_opts";
  409: $content = `$unzip_cmd "$ARGV[0]" word/document.xml 2>$nullDevice`;
  441: $_ = `$unzip_cmd "$ARGV[0]" word/_rels/document.xml.rels 2>$nullDevice`;
  458: $_ = `$unzip_cmd "$ARGV[0]" word/numbering.xml 2>$nullDevice`;

Double quotes do not protect against ", $(...) or backticks in the
name, so a file whose name contains them runs arbitrary commands as the
user who converts it.

Reproducer:

  $ cp any.docx 'x"; touch PWNED; ".docx'
  $ docx2txt 'x"; touch PWNED; ".docx' - >/dev/null
  $ ls PWNED
  PWNED

Impact: anyone who can choose the name of a file the victim later
converts (an archive member, a download, a shared directory, a mail
attachment) can execute commands as the victim. Callers that quote the
filename correctly are still affected, because docx2txt re-embeds it in
a shell string. The package's own mailcap entry ("docx2txt %s -") is a
possible path for this; I have not verified which mail clients preserve
attachment names there. I found this while adding .docx support to
less's lesspipe (#1152840), which works around it by passing docx2txt a
symlink with a fixed name.

The same class of problem exists for extracted-directory input:
readFileInto() and readOptionalFileInto() use 2-argument open on
"$ARGV[0]/word/...", so a directory named e.g. '|touch PWNED;:'
(containing word/document.xml and word/_rels/document.xml.rels) runs
the command when given as a relative path (verified).

The attached patch adds debian/patches/0002-run-unzip-without-a-shell.patch,
which replaces the three backtick calls with a small unzipMember()
helper using a list-form pipe open (stderr still discarded), and uses
3-argument open in the two read helpers. Tested against the unpatched
script: output is byte-identical for a real-world .docx, a synthetic
.docx with and without numbering.xml, stdin input and the default
output file; error messages for missing, non-docx and non-zip input
are unchanged; and the hostile names above (", $(...), backticks, and
the '|' directory) no longer execute anything. Upstream appears
inactive since 1.4 (2014), so the patch is not forwarded.

Not addressed here: stdin mode writes to a predictable temporary file
("$config_tempDir/dx2tTemp_<pid>_<time>.docx", opened without O_EXCL).
I can file that separately if useful.

This is similar in nature to #985594 (quoted %s in the mailcap entry),
which was handled as a public normal/security bug.


-- System Information:
Debian Release: trixie/sid
  APT prefers noble-updates
  APT policy: (500, 'noble-updates'), (500, 'noble-security'), (500, 'noble'), 
(100, 'noble-backports')
Architecture: amd64 (x86_64)
Foreign Architectures: i386

Kernel: Linux 7.0.0-42-generic (SMP w/8 CPU threads; PREEMPT)
Kernel taint flags: TAINT_USER
Locale: LANG=en_US.UTF-8, LC_CTYPE=en_US.UTF-8 (charmap=UTF-8), LANGUAGE=en_US
Shell: /bin/sh linked to /usr/bin/dash
Init: systemd (via /run/systemd/system)
LSM: AppArmor: enabled

Versions of packages docx2txt depends on:
ii  unzip  6.0-28ubuntu4.1

docx2txt recommends no packages.

docx2txt suggests no packages.

-- no debconf information
>From 0404ad9fd2156bf1b831bed2a6215fd022bf500d Mon Sep 17 00:00:00 2001
From: Koyo Keiter <[email protected]>
Date: Mon, 2 Nov 2026 02:42:14 -0500
Subject: [PATCH] Run unzip without a shell

docx2txt passed the input filename to unzip through backtick shell
commands, so a filename containing ", $(...) or backticks executed
arbitrary commands. Add debian/patches/0002 switching to list-form pipe
opens, and to 3-argument open for files read from an extracted
directory, where a name starting with '|' was also run as a command.
---
 .../0002-run-unzip-without-a-shell.patch      | 80 +++++++++++++++++++
 debian/patches/series                         |  1 +
 2 files changed, 81 insertions(+)
 create mode 100644 debian/patches/0002-run-unzip-without-a-shell.patch

diff --git a/debian/patches/0002-run-unzip-without-a-shell.patch 
b/debian/patches/0002-run-unzip-without-a-shell.patch
new file mode 100644
index 0000000..66e509d
--- /dev/null
+++ b/debian/patches/0002-run-unzip-without-a-shell.patch
@@ -0,0 +1,80 @@
+Description: run unzip without a shell
+ docx2txt interpolated the input filename into backtick shell commands
+ inside double quotes, so a filename containing ", $(...) or backticks
+ executed arbitrary commands. Use list-form pipe opens instead.
+ .
+ Also use 3-argument open when reading from an extracted directory,
+ where a directory name starting with '|' was run as a command by the
+ 2-argument form.
+Author: Koyo Keiter <[email protected]>
+Forwarded: no
+Last-Update: 2026-11-02
+
+diff --git a/docx2txt.pl b/docx2txt.pl
+index 7398de3..98b06b8 100755
+--- a/docx2txt.pl
++++ b/docx2txt.pl
+@@ -348,7 +348,7 @@ sub check_for_required_file_in_folder {
+ 
+ sub readFileInto {
+     local $/ = undef;
+-    open my $fh, "$_[0]" or die "Couldn't read file <$_[0]>!\n";
++    open my $fh, '<', $_[0] or die "Couldn't read file <$_[0]>!\n";
+     binmode $fh;
+     $_[1] = <$fh>;
+     close $fh;
+@@ -360,7 +360,7 @@ sub readOptionalFileInto {
+     stat("$_[0]");
+     if (-f _) {
+         if (-r _ && -T _) {
+-            open my $fh, "$_[0]" or die "Couldn't read file <$_[0]>!\n";
++            open my $fh, '<', $_[0] or die "Couldn't read file <$_[0]>!\n";
+             binmode $fh;
+             $_[1] = <$fh>;
+             close $fh;
+@@ -401,12 +401,25 @@ else {
+ # Extract xml document content from argument docx file/directory.
+ #
+ 
+-my $unzip_cmd = "'$config_unzip' $config_unzip_opts";
++# Run unzip without a shell, so the docx filename is never parsed as shell 
code.
++sub unzipMember {
++    local $/ = undef;
++    open(my $savedErr, '>&', \*STDERR);
++    open(STDERR, '>', $nullDevice);
++    my $ok = open(my $fh, '-|', $config_unzip, split(' ', $config_unzip_opts),
++                  $ARGV[0], $_[0]);
++    open(STDERR, '>&', $savedErr);
++    return '' if ! $ok;
++    binmode $fh;
++    my $data = <$fh>;
++    close $fh;
++    return defined $data ? $data : '';
++}
+ 
+ if ($inpIsDir eq 'y') {
+     readFileInto("$ARGV[0]/word/document.xml", $content);
+ } else {
+-    $content = `$unzip_cmd "$ARGV[0]" word/document.xml 2>$nullDevice`;
++    $content = unzipMember('word/document.xml');
+ }
+ 
+ cleandie "Failed to extract required information from <$inputFileName>!\n" if 
! $content;
+@@ -438,7 +451,7 @@ binmode $txtfile;    # Ensure no auto-conversion of '\n' 
to '\r\n' on Windows.
+ if ($inpIsDir eq 'y') {
+     readFileInto("$ARGV[0]/word/_rels/document.xml.rels", $_);
+ } else {
+-    $_ = `$unzip_cmd "$ARGV[0]" word/_rels/document.xml.rels 2>$nullDevice`;
++    $_ = unzipMember('word/_rels/document.xml.rels');
+ }
+ 
+ my %docurels;
+@@ -455,7 +468,7 @@ $_ = "";
+ if ($inpIsDir eq 'y') {
+     readOptionalFileInto("$ARGV[0]/word/numbering.xml", $_);
+ } else {
+-    $_ = `$unzip_cmd "$ARGV[0]" word/numbering.xml 2>$nullDevice`;
++    $_ = unzipMember('word/numbering.xml');
+ }
+ 
+ my %abstractNum;
diff --git a/debian/patches/series b/debian/patches/series
index 1863f74..9ebca55 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -1 +1,2 @@
 0001-shebangs.patch
+0002-run-unzip-without-a-shell.patch
-- 
2.43.0

Reply via email to