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.
| Package | Affected versions | Patched versions |
|---|---|---|
ansiblePyPI | >= 2.8.0a1, < 2.8.9 | 2.8.9 |
ansiblePyPI | >= 2.9.0a1, < 2.9.6 | 2.9.6 |
ansiblePyPI | < 2.7.17 | 2.7.17 |
Affected products
1Patches
31de638b4d38dwin_unzip - normalize and compare paths to prevent path traversal (#67799)
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'
aaf549d7870bwin_unzip - normalize and compare paths to prevent path traversal (#67799)
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'
b60aa26e2313win_unzip - normalize and compare paths to prevent path traversal (#67799)
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- github.com/advisories/GHSA-893h-35v4-mxqxghsaADVISORY
- lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/FWDK3QUVBULS3Q3PQTGEKUQYPSNOU5M3/mitrevendor-advisoryx_refsource_FEDORA
- lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/QT27K5ZRGDPCH7GT3DRI3LO4IVDVQUB7/mitrevendor-advisoryx_refsource_FEDORA
- lists.fedoraproject.org/archives/list/package-announce%40lists.fedoraproject.org/message/U3IMV3XEIUXL6S4KPLYYM4TVJQ2VNEP2/mitrevendor-advisoryx_refsource_FEDORA
- nvd.nist.gov/vuln/detail/CVE-2020-1737ghsaADVISORY
- security.gentoo.org/glsa/202006-11ghsavendor-advisoryx_refsource_GENTOOWEB
- bugzilla.redhat.com/show_bug.cgighsax_refsource_CONFIRMWEB
- github.com/ansible/ansible/issues/67795ghsax_refsource_MISCWEB
- github.com/ansible/ansible/pull/67799ghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/ansible/PYSEC-2020-9.yamlghsaWEB
- github.com/samdoran/ansible/commit/1de638b4d38d6d916588e2ad48d01f90dab8c36dghsaWEB
- github.com/samdoran/ansible/commit/aaf549d7870b8687209a3282841b59207735b676ghsaWEB
- github.com/samdoran/ansible/commit/b60aa26e2313a8d52c0e0d3fd01696e797605b72ghsaWEB
- lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/FWDK3QUVBULS3Q3PQTGEKUQYPSNOU5M3ghsaWEB
- lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/QT27K5ZRGDPCH7GT3DRI3LO4IVDVQUB7ghsaWEB
- lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/U3IMV3XEIUXL6S4KPLYYM4TVJQ2VNEP2ghsaWEB
News mentions
0No linked articles in our index yet.