diff --git a/src/librustdoc/lint.rs b/src/librustdoc/lint.rs
index dcc27cd62e389..9dbaa9fc55380 100644
--- a/src/librustdoc/lint.rs
+++ b/src/librustdoc/lint.rs
@@ -204,6 +204,13 @@ declare_rustdoc_lint! {
     "detects markdown that is interpreted differently in different parser"
 }
 
+declare_rustdoc_lint! {
+    /// This lint checks for uses of footnote references without definition.
+    BROKEN_FOOTNOTE,
+    Warn,
+    "footnote reference with no associated definition"
+}
+
 pub(crate) static RUSTDOC_LINTS: Lazy<Vec<&'static Lint>> = Lazy::new(|| {
     vec![
         BROKEN_INTRA_DOC_LINKS,
@@ -218,6 +225,7 @@ pub(crate) static RUSTDOC_LINTS: Lazy<Vec<&'static Lint>> = Lazy::new(|| {
         UNESCAPED_BACKTICKS,
         REDUNDANT_EXPLICIT_LINKS,
         UNPORTABLE_MARKDOWN,
+        BROKEN_FOOTNOTE,
     ]
 });
 
diff --git a/src/librustdoc/passes/lint.rs b/src/librustdoc/passes/lint.rs
index 1ecb53e61ac39..eedf24d1a79db 100644
--- a/src/librustdoc/passes/lint.rs
+++ b/src/librustdoc/passes/lint.rs
@@ -3,6 +3,7 @@
 
 mod bare_urls;
 mod check_code_block_syntax;
+mod footnotes;
 mod html_tags;
 mod redundant_explicit_links;
 mod unescaped_backticks;
@@ -42,6 +43,7 @@ impl DocVisitor<'_> for Linter<'_, '_> {
             if may_have_link {
                 bare_urls::visit_item(self.cx, item, hir_id, &dox);
                 redundant_explicit_links::visit_item(self.cx, item, hir_id);
+                footnotes::visit_item(self.cx, item, hir_id, &dox);
             }
             if may_have_code {
                 check_code_block_syntax::visit_item(self.cx, item, &dox);
diff --git a/src/librustdoc/passes/lint/footnotes.rs b/src/librustdoc/passes/lint/footnotes.rs
new file mode 100644
index 0000000000000..dd15b6bdfbe3c
--- /dev/null
+++ b/src/librustdoc/passes/lint/footnotes.rs
@@ -0,0 +1,66 @@
+//! Detects specific markdown syntax that's different between pulldown-cmark
+//! 0.9 and 0.11.
+//!
+//! This is a mitigation for old parser bugs that affected some
+//! real crates' docs. The old parser claimed to comply with CommonMark,
+//! but it did not. These warnings will eventually be removed,
+//! though some of them may become Clippy lints.
+//!
+//! <https://github.com/rust-lang/rust/pull/121659#issuecomment-1992752820>
+//!
+//! <https://rustc-dev-guide.rust-lang.org/bug-fix-procedure.html#add-the-lint-to-the-list-of-removed-lists>
+
+use std::ops::Range;
+
+use pulldown_cmark::{Event, Options, Parser};
+use rustc_data_structures::fx::FxHashSet;
+use rustc_hir::HirId;
+use rustc_lint_defs::Applicability;
+use rustc_resolve::rustdoc::source_span_for_markdown_range;
+
+use crate::clean::Item;
+use crate::core::DocContext;
+
+pub(crate) fn visit_item(cx: &DocContext<'_>, item: &Item, hir_id: HirId, dox: &str) {
+    let tcx = cx.tcx;
+
+    let mut missing_footnote_references = FxHashSet::default();
+
+    let options = Options::ENABLE_FOOTNOTES;
+    let mut parser = Parser::new_ext(dox, options).into_offset_iter().peekable();
+    while let Some((event, span)) = parser.next() {
+        match event {
+            Event::Text(text)
+                if &*text == "["
+                    && let Some((Event::Text(text), _)) = parser.peek()
+                    && text.trim_start().starts_with('^')
+                    && parser.next().is_some()
+                    && let Some((Event::Text(text), end_span)) = parser.peek()
+                    && &**text == "]" =>
+            {
+                missing_footnote_references.insert(Range { start: span.start, end: end_span.end });
+            }
+            _ => {}
+        }
+    }
+
+    #[allow(rustc::potential_query_instability)]
+    for span in missing_footnote_references {
+        let (ref_span, precise) =
+            source_span_for_markdown_range(tcx, dox, &span, &item.attrs.doc_strings)
+                .map(|span| (span, true))
+                .unwrap_or_else(|| (item.attr_span(tcx), false));
+
+        if precise {
+            tcx.node_span_lint(crate::lint::BROKEN_FOOTNOTE, hir_id, ref_span, |lint| {
+                lint.primary_message("no footnote definition matching this footnote");
+                lint.span_suggestion(
+                    ref_span.shrink_to_lo(),
+                    "if it should not be a footnote, escape it",
+                    "\\",
+                    Applicability::MaybeIncorrect,
+                );
+            });
+        }
+    }
+}
diff --git a/tests/rustdoc-ui/lints/broken-footnote.rs b/tests/rustdoc-ui/lints/broken-footnote.rs
new file mode 100644
index 0000000000000..b32c9f3db9481
--- /dev/null
+++ b/tests/rustdoc-ui/lints/broken-footnote.rs
@@ -0,0 +1,8 @@
+#![deny(rustdoc::broken_footnote)]
+#![allow(rustdoc::unportable_markdown)]
+
+//! Footnote referenced [^1]. And [^2]. And [^bla].
+//!
+//! [^1]: footnote defined
+//~^^^ ERROR: no footnote definition matching this footnote
+//~| ERROR: no footnote definition matching this footnote
diff --git a/tests/rustdoc-ui/lints/broken-footnote.stderr b/tests/rustdoc-ui/lints/broken-footnote.stderr
new file mode 100644
index 0000000000000..a039135aef669
--- /dev/null
+++ b/tests/rustdoc-ui/lints/broken-footnote.stderr
@@ -0,0 +1,24 @@
+error: no footnote definition matching this footnote
+  --> $DIR/broken-footnote.rs:4:45
+   |
+LL | //! Footnote referenced [^1]. And [^2]. And [^bla].
+   |                                             -^^^^^
+   |                                             |
+   |                                             help: if it should not be a footnote, escape it: `\`
+   |
+note: the lint level is defined here
+  --> $DIR/broken-footnote.rs:1:9
+   |
+LL | #![deny(rustdoc::broken_footnote)]
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^
+
+error: no footnote definition matching this footnote
+  --> $DIR/broken-footnote.rs:4:35
+   |
+LL | //! Footnote referenced [^1]. And [^2]. And [^bla].
+   |                                   -^^^
+   |                                   |
+   |                                   help: if it should not be a footnote, escape it: `\`
+
+error: aborting due to 2 previous errors
+