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;

Reply via email to