diff mbox series

object-name: reject too-deep recursive ancestor queries

Message ID 57c0b30ddfe7c0ae78069682ff8454791e54469f.1700496801.git.me@ttaylorr.com (mailing list archive)
State New, archived
Headers show
Series object-name: reject too-deep recursive ancestor queries | expand

Commit Message

Taylor Blau Nov. 20, 2023, 4:13 p.m. UTC
When trying to resolve a revision query like "HEAD~~~~~", our call
pattern looks something like:

  - object-name.c::get_oid_with_context()
  - object-name.c::get_oid_1()
  - object-name.c::get_nth_ancestor()
  - object-name.c::get_oid_1()
  - ...

With `get_nth_ancestor()` and `get_oid_1()` mutually recurring, popping
one '~' off of the revision query for each round of the recursion.

Since this recursive behavior is unbounded, having too many "~"'s
contained in a revision query will cause us to blow the stack.
Generating a message like this when compiled under SANITIZE=address:

    $ valgrind git rev-parse "HEAD$(perl -e "print \"~\" x 1000000000000")"
    ==597453== Memcheck, a memory error detector
    ==597453== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al.
    ==597453== Using Valgrind-3.19.0 and LibVEX; rerun with -h for copyright info
    ==597453== Command: /home/ttaylorr/local/bin/git.compile diff HEAD~~~~~~~~~~~~[...]
    ==597453==
    AddressSanitizer:DEADLYSIGNAL
    =================================================================
    ==597453==ERROR: AddressSanitizer: stack-overflow on address 0x7fffdd838ff8 (pc 0x7f2726082748 bp 0x7fffdd839110 sp 0x7fffdd839000 T0)
        #0 0x7f2726082748 in __asan::GetTLSFakeStack() ../../../../src/libsanitizer/asan/asan_fake_stack.cpp:176
        #1 0x7f2726082748 in GetFakeStackFast ../../../../src/libsanitizer/asan/asan_fake_stack.cpp:193
        #2 0x7f27260833de in OnMalloc ../../../../src/libsanitizer/asan/asan_fake_stack.cpp:207
        #3 0x7f27260833de in __asan_stack_malloc_1 ../../../../src/libsanitizer/asan/asan_fake_stack.cpp:256
        #4 0x563f9077d9d8 in get_nth_ancestor /home/ttaylorr/src/git/object-name.c:1087
        #5 0x563f9077e957 in get_oid_1 /home/ttaylorr/src/git/object-name.c:1295
        #6 0x563f9077da64 in get_nth_ancestor /home/ttaylorr/src/git/object-name.c:1092
        #7 0x563f9077e957 in get_oid_1 /home/ttaylorr/src/git/object-name.c:1295
        #8 0x563f9077da64 in get_nth_ancestor /home/ttaylorr/src/git/object-name.c:1092
        [...]
        #247 0x563f9077e957 in get_oid_1 /home/ttaylorr/src/git/object-name.c:1295
        #248 0x563f9077da64 in get_nth_ancestor /home/ttaylorr/src/git/object-name.c:1092

    SUMMARY: AddressSanitizer: stack-overflow ../../../../src/libsanitizer/asan/asan_fake_stack.cpp:176 in __asan::GetTLSFakeStack()
    ==597453==ABORTING

(Note that the actual stack is much deeper. GDB reports that the bottom
of the stack looks something like the following):

    #54866 0x0000555555c6d3bf in get_oid_with_context_1 (repo=0x5555563849a0 <the_repo>, name=0x7fffffff4be5 "HEAD", '~' <repeats 196 times>..., flags=128, prefix=0x0, oid=0x7ffff5713d40, oc=0x7ffff5713d90) at object-name.c:1947
    #54867 0x0000555555c6e2fa in get_oid_with_context (repo=0x5555563849a0 <the_repo>, str=0x7fffffff4be5 "HEAD", '~' <repeats 196 times>..., flags=128, oid=0x7ffff5713d40, oc=0x7ffff5713d90) at object-name.c:2096
    #54868 0x0000555555d8eed8 in handle_revision_arg_1 (arg_=0x7fffffff4be5 "HEAD", '~' <repeats 196 times>..., revs=0x7ffff5b000d0, flags=0, revarg_opt=0) at revision.c:2174
    #54869 0x0000555555d8f1a9 in handle_revision_arg (arg=0x7fffffff4be5 "HEAD", '~' <repeats 196 times>..., revs=0x7ffff5b000d0, flags=0, revarg_opt=0) at revision.c:2189
    #54870 0x0000555555d97ca9 in setup_revisions (argc=2, argv=0x7fffffff4970, revs=0x7ffff5b000d0, opt=0x0) at revision.c:2932
    #54871 0x00005555557d6a63 in cmd_diff (argc=2, argv=0x7fffffff4970, prefix=0x0) at builtin/diff.c:502
    #54872 0x00005555557367bf in run_builtin (p=0x5555561c4c30 <commands+816>, argc=2, argv=0x7fffffff4970) at git.c:469
    #54873 0x000055555573716b in handle_builtin (argc=2, argv=0x7fffffff4970) at git.c:723
    #54874 0x000055555573785a in run_argv (argcp=0x7ffff56028b0, argv=0x7ffff56028e0) at git.c:787
    #54875 0x0000555555738626 in cmd_main (argc=2, argv=0x7fffffff4970) at git.c:922
    #54876 0x00005555559d3fdd in main (argc=3, argv=0x7fffffff4968) at common-main.c:62

Fortunately, we can impose a limit on the maximum recursion depth we're
willing to accept when resolving queries like the above without
significantly impeding users. This patch sets the limit at 4096, though
we could probably increase that limit depending on the size of each
frame.

The limit introduced here is large enough that any reasonable query
should still run to completion, but small enough that if the frame size
were to significantly increase, our protection would still be effective.

The change here is straightforward: each call to get_nth_ancestor()
increases a counter, and then decrements that counter before returning.

The diff is a little noisy since there are a handful of return paths
from `get_nth_ancestor()`, all of which need to decrement the depth
variable.

Since this is a local-only exploit, a user would have to be tricked into
running such a query by an adversary. Even if they were successfully
tricked into running the malicious query, the blast radius is limited to
a local stack overflow, which does not have meaningful paths to remote
code execution, arbitrary memory reads, or any more grave security
concerns.

Reported-by: Carlos Andrés Ramírez Cataño <antaigroupltda@gmail.com>
Signed-off-by: Taylor Blau <me@ttaylorr.com>
---
 object-name.c                  | 26 ++++++++++++++++++++------
 t/t1506-rev-parse-diagnosis.sh |  5 +++++
 2 files changed, 25 insertions(+), 6 deletions(-)


base-commit: cfb8a6e9a93adbe81efca66e6110c9b4d2e57169

Comments

Junio C Hamano Nov. 20, 2023, 5:14 p.m. UTC | #1
Taylor Blau <me@ttaylorr.com> writes:

> Since this is a local-only exploit, a user would have to be tricked into
> running such a query by an adversary. Even if they were successfully
> tricked into running the malicious query, the blast radius is limited to
> a local stack overflow, which does not have meaningful paths to remote
> code execution, arbitrary memory reads, or any more grave security
> concerns.
> ...

So the difference in practice is if we make a controlled call to
die() or just let it crash?  It still does sound worthwhile thing to
do to make sure we make a controlled death.  But ...

> +static int get_nth_ancestor_max_depth = 4096;
> +static int get_nth_ancestor_curr_depth;

... do we have a lock at a much higher level that prevents multiple
name-to-oid look-ups from running simultaneously, or something
similar, to make use of this static counter safe?  I am not offhand
sure how safe it is to assume that we'd always be single-threaded.
This variable leaves a bad taste in my mouth.

I am not offhand sure how hard it is to count the depth per
callpath; get_oid_1() is the sole caller of get_nth_ancestor(), so
if you rename the former into a separate helper with a new
"recursion_depth" parameter, create a thin wrapper around it that
starts the recursion at depth 0 and have everybody else (i.e.,
peel_onion() and get_oid_with_context_1()) call it, and have
get_nth_ancestor increment (and die as needed) the counter, would
that be sufficient to ensure that we count the depth per call
invocation?

Thanks.
Patrick Steinhardt Nov. 23, 2023, 1:53 p.m. UTC | #2
On Mon, Nov 20, 2023 at 11:13:45AM -0500, Taylor Blau wrote:
> When trying to resolve a revision query like "HEAD~~~~~", our call
> pattern looks something like:
> 
>   - object-name.c::get_oid_with_context()
>   - object-name.c::get_oid_1()
>   - object-name.c::get_nth_ancestor()
>   - object-name.c::get_oid_1()
>   - ...
> 
> With `get_nth_ancestor()` and `get_oid_1()` mutually recurring, popping
> one '~' off of the revision query for each round of the recursion.

One thing I noticed just now is that we have exactly the same problem
with `^`, just with a different callstack. This problem isn't yet
addressed by your patch.

I have to wonder whether we should tighten restrictions even further:
instead of manually keeping track of how deep in the stack we are, we
limit the length of revisions to at most 1MB. I would claim that this
limit is sufficiently large to never be a problem in practice. Revisions
are limited to 4kB on most platforms anyway due to the maximum path
length. And if somebody really wants to request the (1024 * 1024) + 1th
parent, they shouldn't do that by appending this many "~" or "^" chars,
but instead they should ask for "~1048577" or "^1048577".

I realize that this is much more restrictive than the current patch. But
it would be a good defensive mechanism against all kinds of weird revs,
and I am very certain that there are other ways to blow the stack or
cause out-of-bounds reads or writes here.

Patrick

> Since this recursive behavior is unbounded, having too many "~"'s
> contained in a revision query will cause us to blow the stack.
> Generating a message like this when compiled under SANITIZE=address:
> 
>     $ valgrind git rev-parse "HEAD$(perl -e "print \"~\" x 1000000000000")"
>     ==597453== Memcheck, a memory error detector
>     ==597453== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al.
>     ==597453== Using Valgrind-3.19.0 and LibVEX; rerun with -h for copyright info
>     ==597453== Command: /home/ttaylorr/local/bin/git.compile diff HEAD~~~~~~~~~~~~[...]
>     ==597453==
>     AddressSanitizer:DEADLYSIGNAL
>     =================================================================
>     ==597453==ERROR: AddressSanitizer: stack-overflow on address 0x7fffdd838ff8 (pc 0x7f2726082748 bp 0x7fffdd839110 sp 0x7fffdd839000 T0)
>         #0 0x7f2726082748 in __asan::GetTLSFakeStack() ../../../../src/libsanitizer/asan/asan_fake_stack.cpp:176
>         #1 0x7f2726082748 in GetFakeStackFast ../../../../src/libsanitizer/asan/asan_fake_stack.cpp:193
>         #2 0x7f27260833de in OnMalloc ../../../../src/libsanitizer/asan/asan_fake_stack.cpp:207
>         #3 0x7f27260833de in __asan_stack_malloc_1 ../../../../src/libsanitizer/asan/asan_fake_stack.cpp:256
>         #4 0x563f9077d9d8 in get_nth_ancestor /home/ttaylorr/src/git/object-name.c:1087
>         #5 0x563f9077e957 in get_oid_1 /home/ttaylorr/src/git/object-name.c:1295
>         #6 0x563f9077da64 in get_nth_ancestor /home/ttaylorr/src/git/object-name.c:1092
>         #7 0x563f9077e957 in get_oid_1 /home/ttaylorr/src/git/object-name.c:1295
>         #8 0x563f9077da64 in get_nth_ancestor /home/ttaylorr/src/git/object-name.c:1092
>         [...]
>         #247 0x563f9077e957 in get_oid_1 /home/ttaylorr/src/git/object-name.c:1295
>         #248 0x563f9077da64 in get_nth_ancestor /home/ttaylorr/src/git/object-name.c:1092
> 
>     SUMMARY: AddressSanitizer: stack-overflow ../../../../src/libsanitizer/asan/asan_fake_stack.cpp:176 in __asan::GetTLSFakeStack()
>     ==597453==ABORTING
> 
> (Note that the actual stack is much deeper. GDB reports that the bottom
> of the stack looks something like the following):
> 
>     #54866 0x0000555555c6d3bf in get_oid_with_context_1 (repo=0x5555563849a0 <the_repo>, name=0x7fffffff4be5 "HEAD", '~' <repeats 196 times>..., flags=128, prefix=0x0, oid=0x7ffff5713d40, oc=0x7ffff5713d90) at object-name.c:1947
>     #54867 0x0000555555c6e2fa in get_oid_with_context (repo=0x5555563849a0 <the_repo>, str=0x7fffffff4be5 "HEAD", '~' <repeats 196 times>..., flags=128, oid=0x7ffff5713d40, oc=0x7ffff5713d90) at object-name.c:2096
>     #54868 0x0000555555d8eed8 in handle_revision_arg_1 (arg_=0x7fffffff4be5 "HEAD", '~' <repeats 196 times>..., revs=0x7ffff5b000d0, flags=0, revarg_opt=0) at revision.c:2174
>     #54869 0x0000555555d8f1a9 in handle_revision_arg (arg=0x7fffffff4be5 "HEAD", '~' <repeats 196 times>..., revs=0x7ffff5b000d0, flags=0, revarg_opt=0) at revision.c:2189
>     #54870 0x0000555555d97ca9 in setup_revisions (argc=2, argv=0x7fffffff4970, revs=0x7ffff5b000d0, opt=0x0) at revision.c:2932
>     #54871 0x00005555557d6a63 in cmd_diff (argc=2, argv=0x7fffffff4970, prefix=0x0) at builtin/diff.c:502
>     #54872 0x00005555557367bf in run_builtin (p=0x5555561c4c30 <commands+816>, argc=2, argv=0x7fffffff4970) at git.c:469
>     #54873 0x000055555573716b in handle_builtin (argc=2, argv=0x7fffffff4970) at git.c:723
>     #54874 0x000055555573785a in run_argv (argcp=0x7ffff56028b0, argv=0x7ffff56028e0) at git.c:787
>     #54875 0x0000555555738626 in cmd_main (argc=2, argv=0x7fffffff4970) at git.c:922
>     #54876 0x00005555559d3fdd in main (argc=3, argv=0x7fffffff4968) at common-main.c:62
> 
> Fortunately, we can impose a limit on the maximum recursion depth we're
> willing to accept when resolving queries like the above without
> significantly impeding users. This patch sets the limit at 4096, though
> we could probably increase that limit depending on the size of each
> frame.
> 
> The limit introduced here is large enough that any reasonable query
> should still run to completion, but small enough that if the frame size
> were to significantly increase, our protection would still be effective.
> 
> The change here is straightforward: each call to get_nth_ancestor()
> increases a counter, and then decrements that counter before returning.
> 
> The diff is a little noisy since there are a handful of return paths
> from `get_nth_ancestor()`, all of which need to decrement the depth
> variable.
> 
> Since this is a local-only exploit, a user would have to be tricked into
> running such a query by an adversary. Even if they were successfully
> tricked into running the malicious query, the blast radius is limited to
> a local stack overflow, which does not have meaningful paths to remote
> code execution, arbitrary memory reads, or any more grave security
> concerns.
> 
> Reported-by: Carlos Andrés Ramírez Cataño <antaigroupltda@gmail.com>
> Signed-off-by: Taylor Blau <me@ttaylorr.com>
> ---
>  object-name.c                  | 26 ++++++++++++++++++++------
>  t/t1506-rev-parse-diagnosis.sh |  5 +++++
>  2 files changed, 25 insertions(+), 6 deletions(-)
> 
> diff --git a/object-name.c b/object-name.c
> index 0bfa29dbbf..675e0a759e 100644
> --- a/object-name.c
> +++ b/object-name.c
> @@ -1080,6 +1080,9 @@ static enum get_oid_result get_parent(struct repository *r,
>  	return MISSING_OBJECT;
>  }
>  
> +static int get_nth_ancestor_max_depth = 4096;
> +static int get_nth_ancestor_curr_depth;
> +
>  static enum get_oid_result get_nth_ancestor(struct repository *r,
>  					    const char *name, int len,
>  					    struct object_id *result,
> @@ -1089,20 +1092,31 @@ static enum get_oid_result get_nth_ancestor(struct repository *r,
>  	struct commit *commit;
>  	int ret;
>  
> +	if (++get_nth_ancestor_curr_depth > get_nth_ancestor_max_depth)
> +		 return error(_("exceeded maximum ancestor depth"));
> +
>  	ret = get_oid_1(r, name, len, &oid, GET_OID_COMMITTISH);
>  	if (ret)
> -		return ret;
> +		goto done;
>  	commit = lookup_commit_reference(r, &oid);
> -	if (!commit)
> -		return MISSING_OBJECT;
> +	if (!commit) {
> +		ret = MISSING_OBJECT;
> +		goto done;
> +	}
>  
>  	while (generation--) {
> -		if (repo_parse_commit(r, commit) || !commit->parents)
> -			return MISSING_OBJECT;
> +		if (repo_parse_commit(r, commit) || !commit->parents) {
> +			ret = MISSING_OBJECT;
> +			goto done;
> +		}
>  		commit = commit->parents->item;
>  	}
>  	oidcpy(result, &commit->object.oid);
> -	return FOUND;
> +
> +	ret = FOUND;
> +done:
> +	get_nth_ancestor_curr_depth--;
> +	return ret;
>  }
>  
>  struct object *repo_peel_to_type(struct repository *r, const char *name, int namelen,
> diff --git a/t/t1506-rev-parse-diagnosis.sh b/t/t1506-rev-parse-diagnosis.sh
> index ef40511d89..b3b9f6c8c5 100755
> --- a/t/t1506-rev-parse-diagnosis.sh
> +++ b/t/t1506-rev-parse-diagnosis.sh
> @@ -244,6 +244,11 @@ test_expect_success 'reject Nth ancestor if N is too high' '
>  	test_must_fail git rev-parse HEAD~100000000000000000000000000000000
>  '
>  
> +test_expect_success 'reject too-deep recursive ancestor queries' '
> +	test_must_fail git rev-parse "HEAD$(perl -e "print \"~\" x 4097")" 2>err &&
> +	grep "error: exceeded maximum ancestor depth" err
> +'
> +
>  test_expect_success 'pathspecs with wildcards are not ambiguous' '
>  	echo "*.c" >expect &&
>  	git rev-parse "*.c" >actual &&
> 
> base-commit: cfb8a6e9a93adbe81efca66e6110c9b4d2e57169
> -- 
> 2.43.0.rc2.19.geadd45bf00
Junio C Hamano Nov. 24, 2023, 9:44 a.m. UTC | #3
Patrick Steinhardt <ps@pks.im> writes:

> I have to wonder whether we should tighten restrictions even further:
> instead of manually keeping track of how deep in the stack we are, we
> limit the length of revisions to at most 1MB. I would claim that this
> limit is sufficiently large to never be a problem in practice.

Tempting.

> Revisions
> are limited to 4kB on most platforms anyway due to the maximum path
> length.

I do not quite get this part, though.

When we get "HEAD~~~~~~~~~^2~~~~~~" from the user, do we somehow try
to create a file or a directory with that name and fail due to
ENAMETOOLONG?

There are ways like "git rev-list --stdin" to cause Git read input
lines of arbitrary length, so I do not think the command line length
limit does not come into the picture, either.

But I do agree that the only useful use of such a revision string
that is longer than 1MB would be to attack.
Patrick Steinhardt Nov. 24, 2023, 10:11 a.m. UTC | #4
On Fri, Nov 24, 2023 at 06:44:33PM +0900, Junio C Hamano wrote:
> Patrick Steinhardt <ps@pks.im> writes:
> 
> > I have to wonder whether we should tighten restrictions even further:
> > instead of manually keeping track of how deep in the stack we are, we
> > limit the length of revisions to at most 1MB. I would claim that this
> > limit is sufficiently large to never be a problem in practice.
> 
> Tempting.
> 
> > Revisions
> > are limited to 4kB on most platforms anyway due to the maximum path
> > length.
> 
> I do not quite get this part, though.
> 
> When we get "HEAD~~~~~~~~~^2~~~~~~" from the user, do we somehow try
> to create a file or a directory with that name and fail due to
> ENAMETOOLONG?

Sorry, this was a typo on my part. I didn't mean "revision", I meant
"reference" here. References are limited to at most 4kB on most
platforms due to filesystem limitations, whereas revisions currently
have no limits in place.

Patrick

> There are ways like "git rev-list --stdin" to cause Git read input
> lines of arbitrary length, so I do not think the command line length
> limit does not come into the picture, either.
> 
> But I do agree that the only useful use of such a revision string
> that is longer than 1MB would be to attack.
>
Jeff King Dec. 6, 2023, 7:40 p.m. UTC | #5
On Fri, Nov 24, 2023 at 11:11:53AM +0100, Patrick Steinhardt wrote:

> > When we get "HEAD~~~~~~~~~^2~~~~~~" from the user, do we somehow try
> > to create a file or a directory with that name and fail due to
> > ENAMETOOLONG?
> 
> Sorry, this was a typo on my part. I didn't mean "revision", I meant
> "reference" here. References are limited to at most 4kB on most
> platforms due to filesystem limitations, whereas revisions currently
> have no limits in place.

Even without filesystem limitations, references are effectively limited
to 64kb due to the pkt-line format.

Revisions can be much longer than a reference, though. We accept
"some_ref:some/path/in/tree", for instance[1].  I think you could argue
that paths are likewise limited by the filesystem, though. Even on
systems like Linux where paths can grow arbitrarily long (by descending
and adding to the current directory), you're still limited in specifying
a full pathname. And Git will always use the full path from the project
root when creating worktree entries. Plus my recent tree-depth patches
effectively limit us to 16MB in the default config.

So I think it might be reasonable to limit revision lengths just as a
belt-and-suspenders against overflow attacks, etc. But I suspect that
the limits we'd choose there might not match what we'd want for
protection against stack exhaustion via recursion. E.g., I think 8k is
probably the minimum I'd want for a revision ("my/4k/ref:my/4k/path").
If one "~" character can create an expensive recursion, that might be
too much.

So we probably need something like Taylor's patch anyway (or to switch
to an iterative algorithm, though that might be tricky because of the
way we parse). I agree it needs to handle "^", though.

-Peff

[1] There are other more exotic revisions, too. The most arbitrary-sized
    that comes to mind is ":/some-string-to-match". I doubt anybody
    would be too mad if that were limited to 8k or even 4k, though.
Patrick Steinhardt Dec. 7, 2023, 6:52 a.m. UTC | #6
On Wed, Dec 06, 2023 at 02:40:35PM -0500, Jeff King wrote:
> On Fri, Nov 24, 2023 at 11:11:53AM +0100, Patrick Steinhardt wrote:
> 
> > > When we get "HEAD~~~~~~~~~^2~~~~~~" from the user, do we somehow try
> > > to create a file or a directory with that name and fail due to
> > > ENAMETOOLONG?
> > 
> > Sorry, this was a typo on my part. I didn't mean "revision", I meant
> > "reference" here. References are limited to at most 4kB on most
> > platforms due to filesystem limitations, whereas revisions currently
> > have no limits in place.
> 
> Even without filesystem limitations, references are effectively limited
> to 64kb due to the pkt-line format.
> 
> Revisions can be much longer than a reference, though. We accept
> "some_ref:some/path/in/tree", for instance[1].  I think you could argue
> that paths are likewise limited by the filesystem, though. Even on
> systems like Linux where paths can grow arbitrarily long (by descending
> and adding to the current directory), you're still limited in specifying
> a full pathname. And Git will always use the full path from the project
> root when creating worktree entries. Plus my recent tree-depth patches
> effectively limit us to 16MB in the default config.

I was only able to trigger these issues with _really_ long revisions,
like hundreds of megabytes. But that's on a glibc system, other systems
based on e.g. musl libc have a much smaller stack by default where these
limits would be hit sooner.

> So I think it might be reasonable to limit revision lengths just as a
> belt-and-suspenders against overflow attacks, etc. But I suspect that
> the limits we'd choose there might not match what we'd want for
> protection against stack exhaustion via recursion. E.g., I think 8k is
> probably the minimum I'd want for a revision ("my/4k/ref:my/4k/path").
> If one "~" character can create an expensive recursion, that might be
> too much.

Fair enough. I think combining the two approaches would be sensible as a
defense-in-depth approach.

Patrick

> So we probably need something like Taylor's patch anyway (or to switch
> to an iterative algorithm, though that might be tricky because of the
> way we parse). I agree it needs to handle "^", though.
> 
> -Peff
> 
> [1] There are other more exotic revisions, too. The most arbitrary-sized
>     that comes to mind is ":/some-string-to-match". I doubt anybody
>     would be too mad if that were limited to 8k or even 4k, though.
diff mbox series

Patch

diff --git a/object-name.c b/object-name.c
index 0bfa29dbbf..675e0a759e 100644
--- a/object-name.c
+++ b/object-name.c
@@ -1080,6 +1080,9 @@  static enum get_oid_result get_parent(struct repository *r,
 	return MISSING_OBJECT;
 }
 
+static int get_nth_ancestor_max_depth = 4096;
+static int get_nth_ancestor_curr_depth;
+
 static enum get_oid_result get_nth_ancestor(struct repository *r,
 					    const char *name, int len,
 					    struct object_id *result,
@@ -1089,20 +1092,31 @@  static enum get_oid_result get_nth_ancestor(struct repository *r,
 	struct commit *commit;
 	int ret;
 
+	if (++get_nth_ancestor_curr_depth > get_nth_ancestor_max_depth)
+		 return error(_("exceeded maximum ancestor depth"));
+
 	ret = get_oid_1(r, name, len, &oid, GET_OID_COMMITTISH);
 	if (ret)
-		return ret;
+		goto done;
 	commit = lookup_commit_reference(r, &oid);
-	if (!commit)
-		return MISSING_OBJECT;
+	if (!commit) {
+		ret = MISSING_OBJECT;
+		goto done;
+	}
 
 	while (generation--) {
-		if (repo_parse_commit(r, commit) || !commit->parents)
-			return MISSING_OBJECT;
+		if (repo_parse_commit(r, commit) || !commit->parents) {
+			ret = MISSING_OBJECT;
+			goto done;
+		}
 		commit = commit->parents->item;
 	}
 	oidcpy(result, &commit->object.oid);
-	return FOUND;
+
+	ret = FOUND;
+done:
+	get_nth_ancestor_curr_depth--;
+	return ret;
 }
 
 struct object *repo_peel_to_type(struct repository *r, const char *name, int namelen,
diff --git a/t/t1506-rev-parse-diagnosis.sh b/t/t1506-rev-parse-diagnosis.sh
index ef40511d89..b3b9f6c8c5 100755
--- a/t/t1506-rev-parse-diagnosis.sh
+++ b/t/t1506-rev-parse-diagnosis.sh
@@ -244,6 +244,11 @@  test_expect_success 'reject Nth ancestor if N is too high' '
 	test_must_fail git rev-parse HEAD~100000000000000000000000000000000
 '
 
+test_expect_success 'reject too-deep recursive ancestor queries' '
+	test_must_fail git rev-parse "HEAD$(perl -e "print \"~\" x 4097")" 2>err &&
+	grep "error: exceeded maximum ancestor depth" err
+'
+
 test_expect_success 'pathspecs with wildcards are not ambiguous' '
 	echo "*.c" >expect &&
 	git rev-parse "*.c" >actual &&