VYPR
High severityNVD Advisory· Published Mar 9, 2020· Updated Aug 4, 2024

CVE-2020-1737

CVE-2020-1737

Description

A flaw was found in Ansible 2.7.17 and prior, 2.8.9 and prior, and 2.9.6 and prior when using the Extract-Zip function from the win_unzip module as the extracted file(s) are not checked if they belong to the destination folder. An attacker could take advantage of this flaw by crafting an archive anywhere in the file system, using a path traversal. This issue is fixed in 2.10.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
ansiblePyPI
>= 2.8.0a1, < 2.8.92.8.9
ansiblePyPI
>= 2.9.0a1, < 2.9.62.9.6
ansiblePyPI
< 2.7.172.7.17

Affected products

1

Patches

3
1de638b4d38d

win_unzip - normalize and compare paths to prevent path traversal (#67799)

https://github.com/samdoran/ansibleSam DoranFeb 28, 2020via ghsa
4 files changed · +234 0
  • changelogs/fragments/win-unzip-check-extraction-path.yml+4 0 added
    @@ -0,0 +1,4 @@
    +bugfixes:
    +  - >
    +    **security issue** win_unzip - normalize paths in archive to ensure extracted
    +    files do not escape from the target directory (CVE-2020-1737)
    
  • lib/ansible/modules/windows/win_unzip.ps1+9 0 modified
    @@ -40,6 +40,15 @@ Function Extract-Zip($src, $dest) {
             $entry_target_path = [System.IO.Path]::Combine($dest, $archive_name)
             $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path)
     
    +        # Normalize paths for further evaluation
    +        $full_target_path = [System.IO.Path]::GetFullPath($entry_target_path)
    +        $full_dest_path = [System.IO.Path]::GetFullPath($dest + [System.IO.Path]::DirectorySeparatorChar)
    +
    +        # Ensure file in the archive does not escape the extraction path
    +        if (-not $full_target_path.StartsWith($full_dest_path)) {
    +            Fail-Json -obj $result -message "Error unzipping '$src' to '$dest'! Filename contains relative paths which would extract outside the destination: $entry_target_path"
    +        }
    +
             if (-not (Test-Path -Path $entry_dir)) {
                 New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null
                 $result.changed = $true
    
  • test/integration/targets/win_unzip/files/create_crafty_zip_files.py+65 0 added
    @@ -0,0 +1,65 @@
    +#!/usr/bin/env python
    +# -*- coding: utf-8 -*-
    +
    +# Copyright (c) 2020 Ansible Project
    +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
    +
    +from __future__ import absolute_import, division, print_function
    +__metaclass__ = type
    +
    +import os
    +import shutil
    +import sys
    +import zipfile
    +
    +# Each key is a zip file and the vaule is the list of files that will be created
    +# and placed in the archive
    +zip_files = {
    +    'hat1': [r'hat/..\rabbit.txt'],
    +    'hat2': [r'hat/..\..\rabbit.txt'],
    +    'handcuffs': [r'..\..\houidini.txt'],
    +    'prison': [r'..\houidini.txt'],
    +}
    +
    +# Accept an argument of where to create the files, defaulting to
    +# the current working directory.
    +try:
    +    output_dir = sys.argv[1]
    +except IndexError:
    +    output_dir = os.getcwd()
    +
    +if not os.path.isdir(output_dir):
    +    os.mkdir(output_dir)
    +
    +os.chdir(output_dir)
    +
    +for name, files in zip_files.items():
    +    # Create the files to go in the zip archive
    +    for entry in files:
    +        dirname = os.path.dirname(entry)
    +        if dirname:
    +            if os.path.isdir(dirname):
    +                shutil.rmtree(dirname)
    +            os.mkdir(dirname)
    +
    +        with open(entry, 'w') as e:
    +            e.write('escape!\n')
    +
    +    # Create the zip archive with the files
    +    filename = '%s.zip' % name
    +    if os.path.isfile(filename):
    +        os.unlink(filename)
    +
    +    with zipfile.ZipFile(filename, 'w') as zf:
    +        for entry in files:
    +            zf.write(entry)
    +
    +    # Cleanup
    +    if dirname:
    +        shutil.rmtree(dirname)
    +
    +    for entry in files:
    +        try:
    +            os.unlink(entry)
    +        except OSError:
    +            pass
    
  • test/integration/targets/win_unzip/tasks/main.yml+156 0 modified
    @@ -14,3 +14,159 @@
       vars:
         in_check_mode: yes
       check_mode: yes
    +
    +- name: create test directory
    +  win_file:
    +    path: '{{ win_unzip_dir }}\output'
    +    state: directory
    +
    +- name: get result of unarchive zip (check)
    +  win_stat:
    +    path: '{{ win_unzip_dir }}\output\café.txt'
    +  register: unzip_actual_check
    +
    +- name: assert result of unarchive zip (check)
    +  assert:
    +    that:
    +    - unzip_check is changed
    +    - not unzip_check.removed
    +    - not unzip_actual_check.stat.exists
    +
    +- name: unarchive zip
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\win_unzip.zip'
    +    dest: '{{ win_unzip_dir }}\output'
    +  register: unzip
    +
    +- name: get result of unarchive zip
    +  slurp:
    +    path: '{{ win_unzip_dir }}\output\café.txt'
    +  register: unzip_actual
    +
    +- name: assert result of unarchive zip
    +  assert:
    +    that:
    +    - unzip is changed
    +    - not unzip.removed
    +    - unzip_actual.content | b64decode == 'café.txt'
    +
    +# Module is not idempotent, will always change without creates
    +- name: unarchive zip again without creates
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\win_unzip.zip'
    +    dest: '{{ win_unzip_dir }}\output'
    +  register: unzip_again
    +
    +- name: assert unarchive zip again without creates
    +  assert:
    +    that:
    +    - unzip_again is changed
    +    - not unzip_again.removed
    +
    +- name: unarchive zip with creates
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\win_unzip.zip'
    +    dest: '{{ win_unzip_dir }}\outout'
    +    creates: '{{ win_unzip_dir }}\output\café.txt'
    +  register: unzip_again_creates
    +
    +- name: assert unarchive zip with creates
    +  assert:
    +    that:
    +    - not unzip_again_creates is changed
    +    - not unzip_again_creates.removed
    +
    +- name: unarchive zip with delete (check)
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\win_unzip.zip'
    +    dest: '{{ win_unzip_dir }}\output'
    +    delete_archive: yes
    +  register: unzip_delete_check
    +  check_mode: yes
    +
    +- name: get result of unarchive zip with delete (check)
    +  win_stat:
    +    path: '{{ win_unzip_dir }}\win_unzip.zip'
    +  register: unzip_delete_actual_check
    +
    +- name: assert unarchive zip with delete (check)
    +  assert:
    +    that:
    +    - unzip_delete_check is changed
    +    - unzip_delete_check.removed
    +    - unzip_delete_actual_check.stat.exists
    +
    +- name: unarchive zip with delete
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\win_unzip.zip'
    +    dest: '{{ win_unzip_dir }}\output'
    +    delete_archive: yes
    +  register: unzip_delete
    +
    +- name: get result of unarchive zip with delete
    +  win_stat:
    +    path: '{{ win_unzip_dir }}\win_unzip.zip'
    +  register: unzip_delete_actual
    +
    +- name: assert unarchive zip with delete
    +  assert:
    +    that:
    +    - unzip_delete is changed
    +    - unzip_delete.removed
    +    - not unzip_delete_actual.stat.exists
    +
    +# Path traversal tests (CVE-2020-1737)
    +- name: Create zip files
    +  script: create_crafty_zip_files.py {{ output_dir }}
    +  delegate_to: localhost
    +
    +- name: Copy zip files to Windows host
    +  win_copy:
    +    src: "{{ output_dir }}/{{ item }}.zip"
    +    dest: "{{ win_unzip_dir }}/"
    +  loop:
    +    - hat1
    +    - hat2
    +    - handcuffs
    +    - prison
    +
    +- name: Perform first trick
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\hat1.zip'
    +    dest: '{{ win_unzip_dir }}\output'
    +  register: hat_trick1
    +
    +- name: Check for file
    +  win_stat:
    +    path: '{{ win_unzip_dir }}\output\rabbit.txt'
    +  register: rabbit
    +
    +- name: Perform next tricks (which should all fail)
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\{{ item }}.zip'
    +    dest: '{{ win_unzip_dir }}\output'
    +  ignore_errors: yes
    +  register: escape
    +  loop:
    +    - hat2
    +    - handcuffs
    +    - prison
    +
    +- name: Search for files
    +  win_find:
    +    recurse: yes
    +    paths:
    +      - '{{ win_unzip_dir }}'
    +    patterns:
    +      - '*houdini.txt'
    +      - '*rabbit.txt'
    +  register: files
    +
    +- name: Check results
    +  assert:
    +    that:
    +      - rabbit.stat.exists
    +      - hat_trick1 is success
    +      - escape.results | map(attribute='failed') | unique | list == [True]
    +      - files.matched == 1
    +      - files.files[0]['filename'] == 'rabbit.txt'
    
aaf549d7870b

win_unzip - normalize and compare paths to prevent path traversal (#67799)

https://github.com/samdoran/ansibleSam DoranFeb 28, 2020via ghsa
4 files changed · +134 1
  • changelogs/fragments/win-unzip-check-extraction-path.yml+4 0 added
    @@ -0,0 +1,4 @@
    +bugfixes:
    +  - >
    +    **security issue** win_unzip - normalize paths in archive to ensure extracted
    +    files do not escape from the target directory (CVE-2020-1737)
    
  • lib/ansible/modules/windows/win_unzip.ps1+9 0 modified
    @@ -40,6 +40,15 @@ Function Extract-Zip($src, $dest) {
             $entry_target_path = [System.IO.Path]::Combine($dest, $archive_name)
             $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path)
     
    +        # Normalize paths for further evaluation
    +        $full_target_path = [System.IO.Path]::GetFullPath($entry_target_path)
    +        $full_dest_path = [System.IO.Path]::GetFullPath($dest + [System.IO.Path]::DirectorySeparatorChar)
    +
    +        # Ensure file in the archive does not escape the extraction path
    +        if (-not $full_target_path.StartsWith($full_dest_path)) {
    +            Fail-Json -obj $result -message "Error unzipping '$src' to '$dest'! Filename contains relative paths which would extract outside the destination: $entry_target_path"
    +        }
    +
             if (-not (Test-Path -LiteralPath $entry_dir)) {
                 New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null
                 $result.changed = $true
    
  • test/integration/targets/win_unzip/files/create_crafty_zip_files.py+65 0 added
    @@ -0,0 +1,65 @@
    +#!/usr/bin/env python
    +# -*- coding: utf-8 -*-
    +
    +# Copyright (c) 2020 Ansible Project
    +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
    +
    +from __future__ import absolute_import, division, print_function
    +__metaclass__ = type
    +
    +import os
    +import shutil
    +import sys
    +import zipfile
    +
    +# Each key is a zip file and the vaule is the list of files that will be created
    +# and placed in the archive
    +zip_files = {
    +    'hat1': [r'hat/..\rabbit.txt'],
    +    'hat2': [r'hat/..\..\rabbit.txt'],
    +    'handcuffs': [r'..\..\houidini.txt'],
    +    'prison': [r'..\houidini.txt'],
    +}
    +
    +# Accept an argument of where to create the files, defaulting to
    +# the current working directory.
    +try:
    +    output_dir = sys.argv[1]
    +except IndexError:
    +    output_dir = os.getcwd()
    +
    +if not os.path.isdir(output_dir):
    +    os.mkdir(output_dir)
    +
    +os.chdir(output_dir)
    +
    +for name, files in zip_files.items():
    +    # Create the files to go in the zip archive
    +    for entry in files:
    +        dirname = os.path.dirname(entry)
    +        if dirname:
    +            if os.path.isdir(dirname):
    +                shutil.rmtree(dirname)
    +            os.mkdir(dirname)
    +
    +        with open(entry, 'w') as e:
    +            e.write('escape!\n')
    +
    +    # Create the zip archive with the files
    +    filename = '%s.zip' % name
    +    if os.path.isfile(filename):
    +        os.unlink(filename)
    +
    +    with zipfile.ZipFile(filename, 'w') as zf:
    +        for entry in files:
    +            zf.write(entry)
    +
    +    # Cleanup
    +    if dirname:
    +        shutil.rmtree(dirname)
    +
    +    for entry in files:
    +        try:
    +            os.unlink(entry)
    +        except OSError:
    +            pass
    
  • test/integration/targets/win_unzip/tasks/main.yml+56 1 modified
    @@ -1,4 +1,3 @@
    ----
     - name: create test directory
       win_file:
         path: '{{ win_unzip_dir }}\output'
    @@ -114,3 +113,59 @@
         - unzip_delete is changed
         - unzip_delete.removed
         - not unzip_delete_actual.stat.exists
    +
    +# Path traversal tests (CVE-2020-1737)
    +- name: Create zip files
    +  script: create_crafty_zip_files.py {{ output_dir }}
    +  delegate_to: localhost
    +
    +- name: Copy zip files to Windows host
    +  win_copy:
    +    src: "{{ output_dir }}/{{ item }}.zip"
    +    dest: "{{ win_unzip_dir }}/"
    +  loop:
    +    - hat1
    +    - hat2
    +    - handcuffs
    +    - prison
    +
    +- name: Perform first trick
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\hat1.zip'
    +    dest: '{{ win_unzip_dir }}\output'
    +  register: hat_trick1
    +
    +- name: Check for file
    +  win_stat:
    +    path: '{{ win_unzip_dir }}\output\rabbit.txt'
    +  register: rabbit
    +
    +- name: Perform next tricks (which should all fail)
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\{{ item }}.zip'
    +    dest: '{{ win_unzip_dir }}\output'
    +  ignore_errors: yes
    +  register: escape
    +  loop:
    +    - hat2
    +    - handcuffs
    +    - prison
    +
    +- name: Search for files
    +  win_find:
    +    recurse: yes
    +    paths:
    +      - '{{ win_unzip_dir }}'
    +    patterns:
    +      - '*houdini.txt'
    +      - '*rabbit.txt'
    +  register: files
    +
    +- name: Check results
    +  assert:
    +    that:
    +      - rabbit.stat.exists
    +      - hat_trick1 is success
    +      - escape.results | map(attribute='failed') | unique | list == [True]
    +      - files.matched == 1
    +      - files.files[0]['filename'] == 'rabbit.txt'
    
b60aa26e2313

win_unzip - normalize and compare paths to prevent path traversal (#67799)

https://github.com/samdoran/ansibleSam DoranFeb 28, 2020via ghsa
4 files changed · +134 1
  • changelogs/fragments/win-unzip-check-extraction-path.yml+4 0 added
    @@ -0,0 +1,4 @@
    +bugfixes:
    +  - >
    +    **security issue** win_unzip - normalize paths in archive to ensure extracted
    +    files do not escape from the target directory (CVE-2020-1737)
    
  • lib/ansible/modules/windows/win_unzip.ps1+9 0 modified
    @@ -40,6 +40,15 @@ Function Extract-Zip($src, $dest) {
             $entry_target_path = [System.IO.Path]::Combine($dest, $archive_name)
             $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path)
     
    +        # Normalize paths for further evaluation
    +        $full_target_path = [System.IO.Path]::GetFullPath($entry_target_path)
    +        $full_dest_path = [System.IO.Path]::GetFullPath($dest + [System.IO.Path]::DirectorySeparatorChar)
    +
    +        # Ensure file in the archive does not escape the extraction path
    +        if (-not $full_target_path.StartsWith($full_dest_path)) {
    +            Fail-Json -obj $result -message "Error unzipping '$src' to '$dest'! Filename contains relative paths which would extract outside the destination: $entry_target_path"
    +        }
    +
             if (-not (Test-Path -LiteralPath $entry_dir)) {
                 New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null
                 $result.changed = $true
    
  • test/integration/targets/win_unzip/files/create_crafty_zip_files.py+65 0 added
    @@ -0,0 +1,65 @@
    +#!/usr/bin/env python
    +# -*- coding: utf-8 -*-
    +
    +# Copyright (c) 2020 Ansible Project
    +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
    +
    +from __future__ import absolute_import, division, print_function
    +__metaclass__ = type
    +
    +import os
    +import shutil
    +import sys
    +import zipfile
    +
    +# Each key is a zip file and the vaule is the list of files that will be created
    +# and placed in the archive
    +zip_files = {
    +    'hat1': [r'hat/..\rabbit.txt'],
    +    'hat2': [r'hat/..\..\rabbit.txt'],
    +    'handcuffs': [r'..\..\houidini.txt'],
    +    'prison': [r'..\houidini.txt'],
    +}
    +
    +# Accept an argument of where to create the files, defaulting to
    +# the current working directory.
    +try:
    +    output_dir = sys.argv[1]
    +except IndexError:
    +    output_dir = os.getcwd()
    +
    +if not os.path.isdir(output_dir):
    +    os.mkdir(output_dir)
    +
    +os.chdir(output_dir)
    +
    +for name, files in zip_files.items():
    +    # Create the files to go in the zip archive
    +    for entry in files:
    +        dirname = os.path.dirname(entry)
    +        if dirname:
    +            if os.path.isdir(dirname):
    +                shutil.rmtree(dirname)
    +            os.mkdir(dirname)
    +
    +        with open(entry, 'w') as e:
    +            e.write('escape!\n')
    +
    +    # Create the zip archive with the files
    +    filename = '%s.zip' % name
    +    if os.path.isfile(filename):
    +        os.unlink(filename)
    +
    +    with zipfile.ZipFile(filename, 'w') as zf:
    +        for entry in files:
    +            zf.write(entry)
    +
    +    # Cleanup
    +    if dirname:
    +        shutil.rmtree(dirname)
    +
    +    for entry in files:
    +        try:
    +            os.unlink(entry)
    +        except OSError:
    +            pass
    
  • test/integration/targets/win_unzip/tasks/main.yml+56 1 modified
    @@ -1,4 +1,3 @@
    ----
     - name: create test directory
       win_file:
         path: '{{ win_unzip_dir }}\output'
    @@ -114,3 +113,59 @@
         - unzip_delete is changed
         - unzip_delete.removed
         - not unzip_delete_actual.stat.exists
    +
    +# Path traversal tests (CVE-2020-1737)
    +- name: Create zip files
    +  script: create_crafty_zip_files.py {{ output_dir }}
    +  delegate_to: localhost
    +
    +- name: Copy zip files to Windows host
    +  win_copy:
    +    src: "{{ output_dir }}/{{ item }}.zip"
    +    dest: "{{ win_unzip_dir }}/"
    +  loop:
    +    - hat1
    +    - hat2
    +    - handcuffs
    +    - prison
    +
    +- name: Perform first trick
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\hat1.zip'
    +    dest: '{{ win_unzip_dir }}\output'
    +  register: hat_trick1
    +
    +- name: Check for file
    +  win_stat:
    +    path: '{{ win_unzip_dir }}\output\rabbit.txt'
    +  register: rabbit
    +
    +- name: Perform next tricks (which should all fail)
    +  win_unzip:
    +    src: '{{ win_unzip_dir }}\{{ item }}.zip'
    +    dest: '{{ win_unzip_dir }}\output'
    +  ignore_errors: yes
    +  register: escape
    +  loop:
    +    - hat2
    +    - handcuffs
    +    - prison
    +
    +- name: Search for files
    +  win_find:
    +    recurse: yes
    +    paths:
    +      - '{{ win_unzip_dir }}'
    +    patterns:
    +      - '*houdini.txt'
    +      - '*rabbit.txt'
    +  register: files
    +
    +- name: Check results
    +  assert:
    +    that:
    +      - rabbit.stat.exists
    +      - hat_trick1 is success
    +      - escape.results | map(attribute='failed') | unique | list == [True]
    +      - files.matched == 1
    +      - files.files[0]['filename'] == 'rabbit.txt'
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

16

News mentions

0

No linked articles in our index yet.