On Mon, Jun 08, 2026 at 01:29:27AM +0200, kpcyrd wrote: > Package: devscripts > Version: 2.26.9 > Severity: critical > File: /usr/bin/dcmd > > hello! > > When I read through #1138907 the error message caught my attention and I > crafted a .changes file that executes arbitrary code when processed: > > --- 8< --- > Format: 1.8 > Files: > ffffffffffffffffffffffffffffffff 1337 abc optional "$(id)" > --- >8 --- > > $ /usr/bin/dcmd echo hax.changes > uid=1000(user) gid=1000(user) groups=1000(user),27(sudo),112(sbuild) > hax.changes > > This creates an undocumented execution path that may cross security > boundaries on Debian build server infrastructure. > > This bug is related to #1138923.
Hi, I may have missed it, but I did not find an easy way to avoid expansions in current shell script implementation of dcmd. I started playing with a perl implementation and seems I got something that is apparently working. It is by far not the best perl you can find (I wrote it ;-)), but seems to work. In its current shape it adds some control on allowed extensions and avoids expansions. (Checked listing "$(id)".deb, all other commands skip it as it does not exists). It has an additional --debug option I used when writing things. Feel free to use it if you find it useful in some way (let me know if you think is better to use GPL2+), as a proof of concept or whatever else. Hope this helps, -- Agustin
#!/usr/bin/perl -w # # dcmd: expand file lists of .dsc/.changes files in the command line # # Copyright (C) 2026 Agustin Martin <[email protected]> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. # # This program tries to mimic behavior of previous shell script # implementation of dcmd, which is # # Copyright (C) 2008 Romain Francoise <[email protected]> # Copyright (C) 2008 Christoph Berg <[email protected]> # Copyright (C) 2008 Adam D. Barratt <[email protected]> # Usage: # # dcmd replaces any reference to a .dsc or .changes file in the command # line with the list of files in its 'Files' section, plus the # .dsc/.changes file itself. # # $ dcmd sha1sum rcs_5.7-23_amd64.changes # f61254e2b61e483c0de2fc163321399bbbeb43f1 rcs_5.7-23.dsc # 7a2b283b4c505d8272a756b230486a9232376771 rcs_5.7-23.diff.gz # e3bac970a57a6b0b41c28c615f2919c931a6cb68 rcs_5.7-23_amd64.deb # c531310b18773d943249cfaa8b539a9b6e14b8f4 rcs_5.7-23_amd64.changes # $ use File::Basename; my $debug; my $sort; my $tac; my $list; my $ignore_missing_files; my $program = basename($0); my @values = (); my %positive_options = (); my %negative_options = (); my %allowed_types = my %filtered_allowed_types = map { $_ => 1 } ( "dsc", "buildinfo", "tar", "diff", "bchanges", "schanges", "archdeb", "indepdeb", "archudeb", "indepudeb"); my %allowed_aliases = map { $_ => 1 } ( "changes", "deb", "udeb", "debtar", "orig" ); sub usage { print STDERR "Usage: $program [options] [command] <dsc or changes file> [...]\n"; } sub usage_die { my $message = shift; chomp $message; print STDERR "$message \n\n"; usage; die "\n"; } sub show_version { usage_die "This is $program, from the Debian devscripts package.\n" . "This code is copyright 2026 by Agustin Martin, all rights reserved.\n" . "This program comes with ABSOLUTELY NO WARRANTY.\n" . "You are free to redistribute this code under the terms of the\n" . "GNU General Public License, version 3 or later."; } sub get_file_type { my $file = shift; my @exts = ('.deb', '.dsc', '.changes', '.buildinfo', '.udeb'); my ($basename,$path,$suffix) = fileparse($file,@exts); my $type = $suffix; $type =~ s/^.//; if ( $type eq "deb" ){ $type = ( $file =~ m/".*all.deb$"/ ) ? "indepdeb" : "archdeb"; } elsif ( $type eq "changes" ) { $type = ( $file =~ m/".*source.changes$"/ ) ? "schanges" : "bchanges"; } elsif ( $type eq "udeb" ){ $type = ( $file =~ m/".*all.udeb$"/ ) ? "indepudeb" : "archudeb"; } elsif ( $file =~ m/debian.tar.(gz|xz)$/) { $type = "diff"; } elsif ( $file =~ m/tar.(gz|xz)$/) { $type = "tar"; } elsif ( $file =~ m/tar.(gz|xz).(asc|gpg|sig)$/) { $type = "tar"; } return $type; } sub handle_expanded_options { my $options = shift; if ( defined $options->{"changes"} ){ $options->{"bchanges"}++; $options->{"schanges"}++; } elsif ( defined $options->{"deb"} ){ $options->{"archdeb"}++; $options->{"indepdeb"}++; } elsif ( defined $options->{"udeb"} ){ $options->{"archudeb"}++; $options->{"indepudeb"}++; } elsif ( defined $options->{"orig"} ){ $options->{"tar"}++; } elsif ( defined $options->{"debtar"} ){ $options->{"diff"}++; } foreach ( keys %allowed_aliases ){ delete $options->{$_} if exists $options->{$_}; } } foreach (@ARGV){ if ( m/--help/ ){ usage; exit 1; } elsif ( m/^--debug$/ ){ $debug++; } elsif ( m/^(--no-fail-on-missing|-r)$/ ){ $ignore_missing_files++; } elsif ( m/^(--package|-p)$/ ){ $list++; } elsif ( m/^(--sort|-s)$/ ){ $sort++; } elsif ( m/^--version$/ ){ show_version; } elsif ( m/^(--tac|-t)$/ ){ $tac++; } elsif ( s/^--no-// ) { die "$program: Can't combine --foo and --no-foo options. Aborting ...\n" if scalar %positive_options; die "$program: Unsupported file type in option --no-$_.\n" unless ( defined $allowed_types{$_} or defined $allowed_aliases{$_} ); $negative_options{$_}++; } elsif ( s/^--// ) { die "$program: Can't combine --foo and --no-foo options. Aborting ...\n" if scalar %negative_options; die "$program: Unsupported file type in option --$_.\n" unless ( defined $allowed_types{$_} or defined $allowed_aliases{$_} ); $positive_options{$_}++; } else { push (@values, $_); } } handle_expanded_options (\%positive_options); handle_expanded_options (\%negative_options); my $action = shift @values; my $inputfile = shift @values; my @action2 = @values; # Rest is part of secondary action if ( not $action ){ usage_die "$program: Did not pass action or file to act on." } elsif ( not $inputfile ){ # Not $action passed, just $inputfile. Show files in it. $inputfile = $action; $list++; } my $inputfile_index; my $inputfile_type = get_file_type($inputfile); if ( $inputfile_type =~m/.*changes$/ ){ $inputfile_index = 4; } elsif ( $inputfile_type =~m/.*dsc$/ ){ $inputfile_index = 2; } else { die "$program: Unsupported type \"$inputfile_type\" for input file \"$inputfile\"\n"; } open (my $INFILE, "<", $inputfile) or die "Could not open \"$inputfile\" for reading.\n"; chomp (my @ALL_LINES = <$INFILE>); close $INFILE; # Instead of parsing the file completely as the previous Python # implementation did (using python-debian), let's just select lines # that look like they might be part of the file list. my $RE = '^ [0-9a-f]{32} [0-9]+ ((([a-zA-Z0-9_.-]+/)?[a-zA-Z0-9_.-]+|-) ([a-zA-Z]+|-) )?(.*)$'; my @MAYBE_LINES = grep(/$RE/,@ALL_LINES); my @MAYBE_FILES = map ( (split(' ',$_))[$inputfile_index], @MAYBE_LINES); push(@MAYBE_FILES,$inputfile); @MAYBE_FILES = sort @MAYBE_FILES if $sort; @MAYBE_FILES = reverse @MAYBE_FILES if $tac; if ( scalar %positive_options ){ %filtered_allowed_types = (); foreach ( keys %positive_options ){ $filtered_allowed_types{$_}++; } } elsif ( scalar %negative_options ) { foreach ( keys %negative_options ){ delete $filtered_allowed_types{$_}; } } if ( $debug) { print "-- Lines:\n ", join("\n ",@MAYBE_LINES), "\n"; print "-- Files:\n ", join("\n ",@MAYBE_FILES), "\n"; print "-- Unfiltered:\n ", join("\n ",sort keys %allowed_types), "\n"; print "-- Positive options:\n ", join("\n ",sort keys %positive_options), "\n"; print "-- Negative options:\n ", join("\n ",sort keys %negative_options), "\n"; print "-- Filtered:\n ", join("\n ",sort keys %filtered_allowed_types), "\n"; } my @list_files = (); foreach ( @MAYBE_FILES ){ my $type = get_file_type ($_); print "-*- $_, [$type]", ", is_allowed_type: ", $filtered_allowed_types{$type} || "0", ", action2: ", join (' ',@action2), "\n" if $debug; next unless ( defined $filtered_allowed_types{$type} ); if ( $list ){ push(@list_files,$_); # To be processed later } else { if ( -e $_ ){ my @command = ("$action", "$_", @action2); print " - Command: ", join(' ',@command), "\n" if $debug; system ( @command ) == 0 or die "Command \"", join(' ',@command), "\" failed\n"; } else { die "$program: File \"$_\" not found. Aborting ...\n" unless $ignore_missing_files; } } } print join(' ',@list_files), "\n" if $list;

