Skip to content

CWE-477: Use of Obsolete Function - Python

Overview

CWE-477 is about the status of the function: Python has deprecated it, or removed it outright. Python does this on a published schedule, so the population is knowable rather than a matter of judgement - PEP 594 removed nineteen standard library modules in 3.13, the crypt module among them, and ssl.wrap_socket() went in 3.12. The security-relevant ones are the modules that were the security control: a TLS helper that never verified anything, a password-hashing module with no successor in the standard library, and a remote-access client with no transport encryption.

Primary Defence: Establish which Python version the code must run on, then replace each withdrawn API with its named successor - ssl.SSLContext.wrap_socket() for ssl.wrap_socket(), a maintained password-hashing library for crypt, shlex.quote() for pipes.quote(), paramiko or a subprocess ssh call for telnetlib.

What This Page Does Not Cover

Scanners routinely report hashlib.md5(), hashlib.sha1() and the random module under CWE-477, and that is the wrong number. None of them is deprecated, none emits a warning, and all are current supported API with legitimate uses - which is precisely why they are not a function-status finding. What is wrong in those cases is the algorithm chosen for a security purpose, and this corpus documents that elsewhere:

  • hashlib.md5 / hashlib.sha1 for integrity or signatures - CWE-328 (Use of Weak Hash), or CWE-327 for the broader algorithm question. Both are still the right tool for an ETag or a cache key; mark such a call usedforsecurity=False (Python 3.9+) so the intent is recorded and the call keeps working on a FIPS-enabled build, where an unflagged hashlib.md5(data) raises ValueError.
  • random for a value that must be unpredictable - CWE-338 (Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG)), which has a Python page covering the secrets and random.SystemRandom replacements in full.

The distinction matters for more than filing. An obsolete function has a named successor and the fix is a substitution the interpreter can confirm; a weak-algorithm finding needs a judgement about what the value protects, and the same call is correct in one place and wrong in another.

Common Vulnerable Patterns

ssl.wrap_socket() - removed in Python 3.12

import socket, ssl

sock = socket.create_connection(("api.example.com", 443))
# VULNERABLE - deprecated in 3.7, removed in 3.12; verifies nothing by default
tls = ssl.wrap_socket(sock)
tls.send(b"GET /account HTTP/1.1\r\nHost: api.example.com\r\n\r\n")

Why this is vulnerable: the module-level ssl.wrap_socket() defaulted to CERT_NONE and performed no hostname check, so the connection encrypts but authenticates nothing - anyone able to answer for the address can present any certificate and read and rewrite the traffic. The encryption is what makes it hard to spot: the socket is genuinely TLS, the traffic is genuinely ciphertext, and nothing at the call site reports that the peer was never identified.

This is a function-status finding rather than a configuration one, which changes what "fixed" means. There is no argument you can add to make this call correct on a current interpreter, because the function does not exist there - on Python 3.12 or later the line raises AttributeError at runtime. A codebase still calling it is running on 3.11 or earlier, so the finding travels with an interpreter upgrade that will turn it into a crash.

crypt for password hashing - removed in Python 3.13

# VULNERABLE - the crypt module was removed in Python 3.13 (PEP 594)
import crypt

# a two-character salt selects METHOD_CRYPT: traditional DES,
# and only the first 8 bytes of the password are used
hashed = crypt.crypt(password, "ab")

# also removed, and the method is whatever the host libc offers
hashed = crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512))

Why this is vulnerable: two separate problems arrive on the same import, and only one of them is about the algorithm.

The first call is the weak one. Passing a bare two-character salt selects the traditional DES-based method, which uses only the first eight bytes of the password - so correct horse battery and correct horse staple produce the same hash, and the search space is whatever eight characters can hold regardless of how long the passphrase was. The second call does not have that defect: it pins SHA-512 explicitly, and neither truncates nor falls back to DES.

The second call is still a finding, and this is the part specific to CWE-477. crypt was a thin wrapper over the platform's crypt(3), so which methods exist at all depended on the host libc - crypt.methods was populated by probing it, and a method available on one distribution could be missing on another. More to the point, the whole module was removed in 3.13, so both calls fail at import time there whatever salt they pass. There is no replacement in the standard library, which is what catches people: the upgrade removes the module and there is nothing to import instead.

That is why the fix is not a smaller edit for the SHA-512 caller. Pinning the module is not possible, because the C function it wrapped is what varies; migrating means choosing an algorithm and a library explicitly, and existing hashes of either kind do not carry over - see Migration Considerations below.

Withdrawn modules that were the security control

# VULNERABLE - all removed in Python 3.13 (PEP 594)
import telnetlib          # cleartext remote access; no transport encryption at all
import pipes              # pipes.quote() was the shell-quoting helper
import cgi                # cgi.escape() had already gone in 3.8

Why this is vulnerable: each of these was withdrawn because it encouraged something the language no longer wants to support, and the security consequence differs by module. telnetlib speaks a protocol that sends credentials in the clear, so the fix is a different transport rather than a different call. pipes.quote() is the older spelling of shlex.quote() and the two behave the same, so that one is a rename - but code still importing pipes in 3.13 fails at import time, and the usual quick fix under time pressure is to drop the quoting rather than to find its successor, which turns a removal into a command injection.

Secure Patterns

Verify the peer: SSLContext.wrap_socket()

import socket, ssl

# SECURE - create_default_context() verifies the chain AND the hostname
context = ssl.create_default_context()          # check_hostname=True, verify_mode=CERT_REQUIRED
context.minimum_version = ssl.TLSVersion.TLSv1_2

with socket.create_connection(("api.example.com", 443)) as sock:
    with context.wrap_socket(sock, server_hostname="api.example.com") as tls:
        tls.send(b"GET /account HTTP/1.1\r\nHost: api.example.com\r\n\r\n")

Why this works: ssl.create_default_context() returns a context with check_hostname=True and verify_mode=CERT_REQUIRED already set, so the two checks the removed function skipped are on before you write any configuration. Passing server_hostname is what makes the hostname check possible - without it the context has no name to compare the certificate against, and wrap_socket raises rather than silently skipping the check. Setting minimum_version explicitly replaces the deprecated ssl.PROTOCOL_TLSv1-style constants, which still exist on 3.13 but emit a DeprecationWarning when passed to SSLContext.

Replace crypt with a maintained password hasher

# SECURE - argon2, the current OWASP-recommended default (pip install argon2-cffi)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()
stored = ph.hash(password)

try:
    ph.verify(stored, submitted_password)
except VerifyMismatchError:
    reject_login()

# or bcrypt, where an existing bcrypt corpus already has to be supported
import bcrypt
stored = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))

Why this works: both replace the host's crypt(3) with an algorithm the application chooses and pins, so the hash no longer changes with the operating system underneath it. Both are also deliberately slow and parameterised by a work factor, which is the property password hashing needs and a general-purpose digest does not have - and unlike crypt, neither truncates the password at eight characters. bcrypt does have a limit of its own at 72 bytes; where that matters, argon2 has none.

Replace the withdrawn helpers with their named successors

# MIGRATION ONLY - shlex.quote() is what pipes.quote() became; same behaviour,
# still present. It neutralises shell metacharacters and nothing else, so "--"
# is still required: shlex.quote("-R") returns -R unquoted, and grep reads it
# as an option after the shell is done.
import shlex
command = f"grep -- {shlex.quote(user_pattern)} /var/log/app.log"

# SECURE - do not build a shell string at all
import subprocess
subprocess.run(["grep", "--", user_pattern, "/var/log/app.log"], check=True)

# SECURE - a genuinely non-security checksum, declared as one (Python 3.9+)
import hashlib
etag = hashlib.md5(body, usedforsecurity=False).hexdigest()

Why this works: shlex.quote() is the same function pipes.quote() delegated to, so replacing the import is the whole migration - and it is worth doing rather than deleting the call, because the quickest way to make a pipes import stop failing is to remove the quoting, which converts a module removal into a command injection.

The first form is labelled migration-only on purpose: it is a faithful pipes.quote() replacement, not a secure command pattern. shlex.quote() decides what the shell parses and has no opinion about what grep parses afterwards - it returns short option-like strings such as -R completely unquoted, because there is nothing in them a shell would treat specially, and the quotes it does add around -e a are removed by the shell before grep ever sees the value. The -- is what stops the argument being read as an option, and it is required in both forms.

The second form is the one to reach for. Dropping the shell removes command injection outright rather than escaping around it; CWE-676 covers that choice, and the argument still needs -- there too.

usedforsecurity=False does not weaken anything - MD5 is exactly as broken with the flag as without it. What it does is record at the call site that this digest is an ETag rather than a security control, which is the fact no scanner can infer. It has a practical effect too: on a FIPS-enabled build hashlib.md5(data) raises ValueError while the flagged call keeps working, so an unflagged non-security checksum is a crash waiting for the first hardened host.

Considerations

The finding is bounded by the interpreter version, so establish that first. Unlike most weaknesses, this one has a definite answer: either the target Python still has the function or it does not. A telnetlib import is a live finding on 3.12 and a startup failure on 3.13, and the same call site needs a different response in each case. Check the version the code actually runs on - and the oldest one it must still support, because that is what decides whether the fix can be a straight substitution or has to work on both sides.

A removal with no standard library successor is a design change, not an import change. pipes to shlex is a rename. crypt is not: there is nothing in the standard library to move to, the replacement is a third-party dependency, and the stored hashes do not carry over. Sort the findings by which kind they are before estimating any of them.

Deprecated is not removed, and the gap is where the work belongs. ssl.PROTOCOL_TLSv1 still exists on 3.13 and only warns; ssl.wrap_socket() is gone. Both are CWE-477, but only one of them breaks the build, and a warning nobody reads is the one that reaches the next release unchanged. Treat the deprecation as the deadline rather than the removal.

Testing

The obvious check - run the test suite on the new interpreter and see whether it imports - is necessary and not sufficient, because a removed module usually fails at import time only on the paths the tests actually reach. Assert the behaviour the removed API used to provide instead:

  • Run the suite under -W error::DeprecationWarning on the current interpreter. This turns the warnings nobody reads into failures and finds the next removal a release early - it is the check that would have caught ssl.wrap_socket in 3.7 rather than 3.12.
  • For the TLS replacement, assert that verification is actually on: connect to a host presenting a self-signed or wrong-name certificate and confirm the call raises ssl.SSLCertVerificationError. A test against a valid certificate passes identically with and without check_hostname, so it proves nothing.
  • For password hashing, verify a correct password still authenticates after migration and an incorrect one is rejected, and verify a password longer than 72 bytes is handled as intended rather than silently truncated.
  • For shlex.quote(), assert a pattern containing ; rm -rf / reaches the target program as literal characters. A test with a well-behaved pattern passes whether the quoting is present or was dropped to make the import error go away.
  • Assert the option case separately, because quoting does not cover it: pass -R as the pattern and confirm grep treats it as a search term rather than as recursive mode. shlex.quote("-R") returns -R unquoted, so this test fails on any command still missing the -- separator and passes on one that has it - which the metacharacter test above cannot distinguish.
  • Where a checksum was kept rather than replaced, assert the call passes usedforsecurity=False, so a later reader cannot mistake it for an unreviewed MD5 use.

Migration Considerations

Password hashes do not carry over. Moving off crypt changes the stored hash format, so existing values will not verify against the new algorithm. Migrate with a dual-read strategy: on successful login, verify against the old hash, then re-hash and store using the new one, so users are migrated transparently the next time they sign in rather than by a mass password reset. Keep the old verification path until the re-hash count stops moving, and only then remove it - the accounts that never log in are the ones that will still be on the old format a year later.

A removed module can break code that never imported it. A dependency is enough: a package that imports crypt or telnetlib fails on 3.13 regardless of what the application does, and the failure surfaces at import of your module. Check the tree with pip list --outdated and a test install on the target interpreter before scheduling the upgrade, not after.

The TLS change can reject traffic that previously worked. ssl.wrap_socket() verified nothing, so a switch to create_default_context() starts enforcing chain and hostname checks that internal endpoints with self-signed or misnamed certificates will now fail. That is the fix working, not a regression - but it is a behavioural change to schedule deliberately, with the affected endpoints given real certificates rather than the verification turned back off.

Additional Resources