Commit 90621b1728 for openssl.org
commit 90621b1728bda81b33674eb5fbe68f614bbfc51c
Author: Bob Beck <beck@openssl.org>
Date: Mon Sep 14 10:11:47 2026 -0600
Poison the ASN1_STRING NUL terminator under ASan and MSan
The terminator libcrypto writes after ASN1_STRING data stays present, but
sanitizer builds mark the byte inaccessible, so any C-string use of
ASN1_STRING_get0_data() output is reported. Add a test that expects the
report, and say in CHANGES.md that the terminator is going away.
This is stacked on https://github.com/openssl/openssl/pull/32828
Reviewed-by: Neil Horman <nhorman@openssl.org>
Reviewed-by: Andrew Dinh <andrewd@openssl.org>
Reviewed-by: Milan Broz <mbroz@openssl.org>
Reviewed-by: Tomas Mraz <tomas@openssl.foundation>
Merge-date: Fri Sep 18 09:05:59 2026
Merged-from: https://github.com/openssl/openssl/pull/32829
diff --git a/CHANGES.md b/CHANGES.md
index e2cf20579e..f7327a6354 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -541,6 +541,16 @@ OpenSSL 4.1
or `ASN1_STRING_set1_string()`, and `ASN1_STRING_get_length()` should be used
in their place. This prepares the `ASN1_STRING` type to support modern
`size_t` length values in the future.
+
+ The data of an `ASN1_STRING` has never been guaranteed to be
+ NUL-terminated, although some operations terminated it anyway. A future
+ release will stop doing so; the new setters above already do not add a
+ terminator. Strings built by libcrypto itself, including decoded ones,
+ still carry one, but when OpenSSL is built with AddressSanitizer or
+ MemorySanitizer that byte is marked inaccessible, so treating the result
+ of `ASN1_STRING_get0_data()` as a C string (`strlen()`, `%s`, `strdup()`
+ and the like) is reported as an error. All such uses must be changed to
+ honour `ASN1_STRING_get_length()`.
<!-- https://github.com/openssl/openssl/pull/31194 -->
*Bob Beck*
diff --git a/crypto/asn1/asn1_lib.c b/crypto/asn1/asn1_lib.c
index 159e084c52..b815851e96 100644
--- a/crypto/asn1/asn1_lib.c
+++ b/crypto/asn1/asn1_lib.c
@@ -13,6 +13,24 @@
#include <openssl/asn1.h>
#include "asn1_local.h"
+#if defined(__has_feature)
+#if __has_feature(address_sanitizer)
+#define ASN1_HAVE_ASAN 1
+#endif
+#if __has_feature(memory_sanitizer)
+#define ASN1_HAVE_MSAN 1
+#endif
+#endif /* defined(__has_feature) */
+#if defined(__SANITIZE_ADDRESS__) && !defined(ASN1_HAVE_ASAN)
+#define ASN1_HAVE_ASAN 1
+#endif
+#if defined(ASN1_HAVE_ASAN)
+#include <sanitizer/asan_interface.h>
+#endif
+#if defined(ASN1_HAVE_MSAN)
+#include <sanitizer/msan_interface.h>
+#endif
+
static int asn1_get_length(const unsigned char **pp, int *inf, long *rl,
long max);
static void asn1_put_length(unsigned char **pp, int length);
@@ -290,6 +308,37 @@ ASN1_STRING *ASN1_STRING_dup(const ASN1_STRING *str)
return ret;
}
+/**
+ * @brief Mark the NUL terminator at p as inaccessible to memory checkers.
+ * Under AddressSanitizer and MemorySanitizer a read of the byte is reported
+ * as an error, so C-string use of ASN1_STRING data is caught while the byte
+ * stays present for builds without a sanitizer.
+ * @param p the terminator byte
+ */
+static void poison_terminator(uint8_t *p)
+{
+#if defined(ASN1_HAVE_ASAN)
+ ASAN_POISON_MEMORY_REGION(p, 1);
+#endif
+#if defined(ASN1_HAVE_MSAN)
+ __msan_poison(p, 1);
+#endif
+}
+
+/**
+ * @brief Make the byte at p accessible again before it is written.
+ * @param p the byte about to hold a NUL terminator
+ */
+static void unpoison_terminator(uint8_t *p)
+{
+#if defined(ASN1_HAVE_ASAN)
+ ASAN_UNPOISON_MEMORY_REGION(p, 1);
+#endif
+#if defined(ASN1_HAVE_MSAN)
+ __msan_unpoison(p, 1);
+#endif
+}
+
int ossl_asn1_string_set_internal(ASN1_STRING *str, const uint8_t *data,
int len_in, int add_nul_byte)
{
@@ -348,15 +397,18 @@ int ossl_asn1_string_set_internal(ASN1_STRING *str, const uint8_t *data,
/* length never includes the added \0 byte */
str->length = (int)len;
- if (data != NULL && str->data != NULL) {
+ if (data != NULL && str->data != NULL)
memcpy(str->data, data, len);
- if (add_nul_byte) {
- /*
- * Add a '\0' terminator. This should not be necessary - but we add it as
- * a safety precaution
- */
+ if (add_nul_byte) {
+ /*
+ * The terminator byte lies beyond str->length. It is written only
+ * when data is supplied, and is inaccessible to memory checkers
+ * either way; see poison_terminator().
+ */
+ unpoison_terminator(&str->data[len]);
+ if (data != NULL)
str->data[len] = '\0';
- }
+ poison_terminator(&str->data[len]);
}
ossl_asn1_bit_string_clear_unused_bits(str);
diff --git a/test/asn1_string_poison_test.c b/test/asn1_string_poison_test.c
new file mode 100644
index 0000000000..fff6ea1db5
--- /dev/null
+++ b/test/asn1_string_poison_test.c
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2026 The OpenSSL Project Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License 2.0 (the "License"). You may not use
+ * this file except in compliance with the License. You can obtain a copy
+ * in the file LICENSE in the source distribution or at
+ * https://www.openssl.org/source/license.html
+ */
+
+/**
+ * @file asn1_string_poison_test.c
+ * Checks that the NUL terminator libcrypto writes after ASN1_STRING data is
+ * inaccessible under AddressSanitizer and MemorySanitizer. Run with no
+ * argument the program reads the terminator with strlen() and is expected
+ * to be killed by the sanitizer; run with "counted" it reads only the
+ * counted bytes and is expected to exit successfully. Without a sanitizer
+ * the strlen() run exits with failure itself; with one, surviving the
+ * strlen() exits successfully, which the recipe reports as a failure.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <openssl/asn1.h>
+
+#include "testutil.h"
+
+#if defined(__has_feature)
+#if __has_feature(address_sanitizer) || __has_feature(memory_sanitizer)
+#define HAVE_SANITIZER 1
+#endif
+#endif /* defined(__has_feature) */
+#if defined(__SANITIZE_ADDRESS__) && !defined(HAVE_SANITIZER)
+#define HAVE_SANITIZER 1
+#endif
+
+/* DER UTF8String "hello" */
+static const unsigned char der[] = { 0x0c, 0x05, 'h', 'e', 'l', 'l', 'o' };
+
+/*
+ * A plain main() rather than the test framework's: the failing run is
+ * expected to die inside the sanitizer, which the framework would report
+ * as a crash.
+ */
+int main(int argc, char *argv[])
+{
+ const unsigned char *p = der;
+ ASN1_UTF8STRING *str = d2i_ASN1_UTF8STRING(NULL, &p, sizeof(der));
+ const unsigned char *data;
+ volatile size_t sink;
+ int exitcode = EXIT_FAILURE;
+
+ if (!TEST_ptr(str)
+ || !TEST_size_t_eq(ASN1_STRING_get_length(str), 5))
+ goto end;
+ data = ASN1_STRING_get0_data(str);
+
+ if (argc > 1 && strcmp(argv[1], "counted") == 0) {
+ /* Counted access never touches the terminator. */
+ if (TEST_mem_eq(data, ASN1_STRING_get_length(str), "hello", 5))
+ exitcode = EXIT_SUCCESS;
+ goto end;
+ }
+
+ /* Reads the terminator; a sanitizer kills the process here. */
+ sink = strlen((const char *)data);
+ (void)sink;
+#if defined(HAVE_SANITIZER)
+ /*
+ * Reached only when the sanitizer did not report the read. The recipe
+ * expects this run to fail; a successful exit is the failure it sees.
+ */
+ TEST_error("strlen() on ASN1_STRING data was not reported");
+ exitcode = EXIT_SUCCESS;
+#endif
+
+end:
+ ASN1_UTF8STRING_free(str);
+ return exitcode;
+}
diff --git a/test/build.info b/test/build.info
index 20e2ef19ea..dbfc127e54 100644
--- a/test/build.info
+++ b/test/build.info
@@ -59,6 +59,7 @@ IF[{- !$disabled{tests} -}]
constant_time_test crypto_memcmp_test ct_validation_helpers_test \
safe_math_test verify_extra_test clienthellotest \
packettest asynctest secmemtest srptest memleaktest stack_test \
+ asn1_string_poison_test \
ct_test threadstest d2i_test \
ssl_test_ctx_test ssl_test x509aux cipherlist_test asynciotest \
bio_callback_test bio_memleak_test bio_ndef_test bio_core_test bio_dgram_test param_build_test \
@@ -522,6 +523,10 @@ IF[{- !$disabled{tests} -}]
INCLUDE[memleaktest]=../include ../apps/include
DEPEND[memleaktest]=../libcrypto libtestutil.a
+ SOURCE[asn1_string_poison_test]=asn1_string_poison_test.c
+ INCLUDE[asn1_string_poison_test]=../include ../apps/include
+ DEPEND[asn1_string_poison_test]=../libcrypto libtestutil.a
+
SOURCE[pkcs12_format_test]=pkcs12_format_test.c helpers/pkcs12.c
INCLUDE[pkcs12_format_test]=../include ../apps/include
DEPEND[pkcs12_format_test]=../libcrypto libtestutil.a
diff --git a/test/recipes/90-test_asn1_string_poison.t b/test/recipes/90-test_asn1_string_poison.t
new file mode 100644
index 0000000000..e6a584384f
--- /dev/null
+++ b/test/recipes/90-test_asn1_string_poison.t
@@ -0,0 +1,19 @@
+#! /usr/bin/env perl
+# Copyright 2026 The OpenSSL Project Authors. All Rights Reserved.
+#
+# Licensed under the Apache License 2.0 (the "License"). You may not use
+# this file except in compliance with the License. You can obtain a copy
+# in the file LICENSE in the source distribution or at
+# https://www.openssl.org/source/license.html
+
+
+use OpenSSL::Test;
+use OpenSSL::Test::Utils;
+
+setup("test_asn1_string_poison");
+
+plan tests => 2;
+ok(!run(test(["asn1_string_poison_test"])),
+ "strlen() on ASN1_STRING data is reported");
+ok(run(test(["asn1_string_poison_test", "counted"])),
+ "counted access to ASN1_STRING data is clean");