Skip to content

Harden LDAP control decoding against malformed BER input#6

Open
Fusion wants to merge 2 commits into
masterfrom
fix/cve2016
Open

Harden LDAP control decoding against malformed BER input#6
Fusion wants to merge 2 commits into
masterfrom
fix/cve2016

Conversation

@Fusion

@Fusion Fusion commented May 16, 2026

Copy link
Copy Markdown
Collaborator

Change DecodeControl to return (Control, error) and validate control structure and value types instead of relying on unchecked access.
Handle decode failures on both server and client paths, returning protocol errors for bad request controls and surfacing response decode errors.

Change DecodeControl to return (Control, error) and validate control structure and value types instead of relying on unchecked access.
Handle decode failures on both server and client paths, returning protocol errors for bad request controls and surfacing response decode errors.
@berkant-koc

Copy link
Copy Markdown

Thanks for the fast turnaround, Chris. I went through the diff against the original report. Summary: the seven unsafe sites in DecodeControl are all covered, and the defer recover() inside DecodeControl is a nice belt-and-braces. A few things I'd like to flag before merge:

  1. handleConnection still has no top-level recover. The original report called out that server.go:224 runs in a goroutine without a defer-recover at entry, and the existing recovers in server_bind.go/server_search.go only fire after the controls block. This PR fixes the immediate crash source, but any future panic on a pre-bind path (a new control decoder, a ber.ReadPacket bug, etc.) would still take the process down. A one-liner at the top of handleConnection would harden this structurally.

  2. No regression tests. There's no control_test.go in the repo, and the PR adds none. My original PoC is a 35-byte BER blob — happy to send a table-driven test covering control-type / criticality / paging-size / paging-children / control-value-string if it helps. Without coverage this fix is one careless refactor away from regressing.

  3. server.TLSConfig != nil guard was dropped at server.go:338. Looks unrelated to the control-decoding fix. With the guard gone, tls.Server(conn, nil) is reachable on StartTLS when the server has no TLS config — which then panics in the handshake. I think this should be reverted or replaced with an explicit error response.

  4. Sibling repos. nmcclain/ldap and cloudogu/go-ldap still carry the same five unguarded assertions in their DecodeControl. Not blocking this PR, but worth a heads-up since glauth is a fork of nmcclain.

The control-decoding hardening itself looks solid — I'd just like to see (1) and (3) addressed and ideally (2) before merge.

1 similar comment
@berkant-koc

Copy link
Copy Markdown

Thanks for the fast turnaround, Chris. I went through the diff against the original report. Summary: the seven unsafe sites in DecodeControl are all covered, and the defer recover() inside DecodeControl is a nice belt-and-braces. A few things I'd like to flag before merge:

  1. handleConnection still has no top-level recover. The original report called out that server.go:224 runs in a goroutine without a defer-recover at entry, and the existing recovers in server_bind.go/server_search.go only fire after the controls block. This PR fixes the immediate crash source, but any future panic on a pre-bind path (a new control decoder, a ber.ReadPacket bug, etc.) would still take the process down. A one-liner at the top of handleConnection would harden this structurally.

  2. No regression tests. There's no control_test.go in the repo, and the PR adds none. My original PoC is a 35-byte BER blob — happy to send a table-driven test covering control-type / criticality / paging-size / paging-children / control-value-string if it helps. Without coverage this fix is one careless refactor away from regressing.

  3. server.TLSConfig != nil guard was dropped at server.go:338. Looks unrelated to the control-decoding fix. With the guard gone, tls.Server(conn, nil) is reachable on StartTLS when the server has no TLS config — which then panics in the handshake. I think this should be reverted or replaced with an explicit error response.

  4. Sibling repos. nmcclain/ldap and cloudogu/go-ldap still carry the same five unguarded assertions in their DecodeControl. Not blocking this PR, but worth a heads-up since glauth is a fork of nmcclain.

The control-decoding hardening itself looks solid — I'd just like to see (1) and (3) addressed and ideally (2) before merge.

@Fusion

Fusion commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

@berkant-koc,

I hear you, these are valid points. Sorry for the delay addressing them.

Let's tackle them in order:

  1. Yes I kinda like things failing fast.
    This being said, I made a change that should keep both of us happy: I included the top-level recover(), but am also screaming in the logs :)

  2. I would greatly appreciate your help, yes. I'll admit to my motivation here being pure fatigue with dealing with BER (totally out of left field comment: who knew patching opensips' cluster protocol would drag me back in that world?)

  3. Well that's a nice merge oopsie. Fixed!

  4. I created this fork for GLAuth use. I was checking other forks of nmcclain/ldap, hoping to find an active one, and it's a depressing landscape. I'm afraid I don't really have any proper upstream to push these fixes to.

@berkant-koc

Copy link
Copy Markdown

Thanks for turning these around, Chris, and for the quick fixes.

Glad the top-level recover() made it in. The "screaming in the logs" part is exactly right, a silent recover would just bury the next bug. The TLSConfig guard restore on (3) looks good too.

On (2), here is the table-driven test I offered. It is control_test.go, package ldap, no new dependencies (only the go-asn1-ber you already pull in). Each control is round-tripped through Bytes() + ber.DecodePacket so it exercises the wire-decode path, which is where the original unchecked assertions actually panicked, not the in-memory packets.

Verified green against this branch (fix/cve2016): go test -run TestDecodeControl passes, 10 subtests.

Coverage:

  • valid round-trips: string control (type only, type+value, type+criticality+value) and paging (size + cookie)
  • the malformed inputs that previously reached the unchecked code: nil packet, empty control sequence, non-octet-string control type, non-boolean criticality, paging value sequence too short, and paging size greater than uint32 max
package ldap

import (
	"bytes"
	"testing"

	ber "github.com/go-asn1-ber/asn1-ber"
)

// redecode serializes a packet and parses it again. DecodeControl runs on
// controls parsed from the wire, not on in-memory packets, and the original
// unchecked type assertions / child indexing only panic on wire-decoded
// input. Round-tripping through bytes reproduces that path faithfully.
func redecode(p *ber.Packet) *ber.Packet {
	if p == nil {
		return nil
	}
	return ber.DecodePacket(p.Bytes())
}

func controlSeq(children ...*ber.Packet) *ber.Packet {
	seq := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "Control")
	for _, c := range children {
		seq.AppendChild(c)
	}
	return seq
}

func octet(s string) *ber.Packet {
	return ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, s, "")
}

// pagingControl builds a paging control whose value wraps the supplied inner
// sequence, mirroring ControlPaging.Encode() so the byte layout matches the
// real wire format.
func pagingControl(inner *ber.Packet) *ber.Packet {
	ctrl := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "Control")
	ctrl.AppendChild(octet(ControlTypePaging))
	value := ber.Encode(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, nil, "Control Value (Paging)")
	value.AppendChild(inner)
	ctrl.AppendChild(value)
	return ctrl
}

func searchValueSeq(children ...*ber.Packet) *ber.Packet {
	seq := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "Search Control Value")
	for _, c := range children {
		seq.AppendChild(c)
	}
	return seq
}

func integer(v uint64) *ber.Packet {
	return ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, v, "")
}

func TestDecodeControl(t *testing.T) {
	tests := []struct {
		name    string
		packet  *ber.Packet
		wantErr bool
		check   func(t *testing.T, c Control)
	}{
		{
			name:   "string control, type only",
			packet: redecode((&ControlString{ControlType: "1.2.3.4"}).Encode()),
			check: func(t *testing.T, c Control) {
				cs, ok := c.(*ControlString)
				if !ok {
					t.Fatalf("got %T, want *ControlString", c)
				}
				if cs.ControlType != "1.2.3.4" {
					t.Errorf("ControlType = %q, want 1.2.3.4", cs.ControlType)
				}
				if cs.Criticality {
					t.Errorf("Criticality = true, want false")
				}
				if cs.ControlValue != "" {
					t.Errorf("ControlValue = %q, want empty", cs.ControlValue)
				}
			},
		},
		{
			name:   "string control, type and value",
			packet: redecode((&ControlString{ControlType: "1.2.3.4", ControlValue: "payload"}).Encode()),
			check: func(t *testing.T, c Control) {
				cs := c.(*ControlString)
				if cs.Criticality {
					t.Errorf("Criticality = true, want false")
				}
				if cs.ControlValue != "payload" {
					t.Errorf("ControlValue = %q, want payload", cs.ControlValue)
				}
			},
		},
		{
			name:   "string control, type criticality and value",
			packet: redecode((&ControlString{ControlType: "1.2.3.4", Criticality: true, ControlValue: "payload"}).Encode()),
			check: func(t *testing.T, c Control) {
				cs := c.(*ControlString)
				if !cs.Criticality {
					t.Errorf("Criticality = false, want true")
				}
				if cs.ControlValue != "payload" {
					t.Errorf("ControlValue = %q, want payload", cs.ControlValue)
				}
			},
		},
		{
			name:   "paging control round-trip",
			packet: redecode((&ControlPaging{PagingSize: 100, Cookie: []byte("cookie")}).Encode()),
			check: func(t *testing.T, c Control) {
				cp, ok := c.(*ControlPaging)
				if !ok {
					t.Fatalf("got %T, want *ControlPaging", c)
				}
				if cp.PagingSize != 100 {
					t.Errorf("PagingSize = %d, want 100", cp.PagingSize)
				}
				if !bytes.Equal(cp.Cookie, []byte("cookie")) {
					t.Errorf("Cookie = %q, want cookie", cp.Cookie)
				}
			},
		},
		{
			name:    "nil packet",
			packet:  nil,
			wantErr: true,
		},
		{
			name:    "empty control sequence",
			packet:  redecode(controlSeq()),
			wantErr: true,
		},
		{
			name:    "control type not an octet string",
			packet:  redecode(controlSeq(integer(5))),
			wantErr: true,
		},
		{
			name: "criticality not a boolean",
			packet: redecode(controlSeq(
				octet("1.2.3.4"),
				octet("not-a-bool"),
				octet("value"),
			)),
			wantErr: true,
		},
		{
			name:    "paging value sequence too short",
			packet:  redecode(pagingControl(searchValueSeq(integer(10)))),
			wantErr: true,
		},
		{
			name:    "paging size exceeds uint32",
			packet:  redecode(pagingControl(searchValueSeq(integer(uint64(1)<<32), octet("ck")))),
			wantErr: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c, err := DecodeControl(tt.packet)
			if tt.wantErr {
				if err == nil {
					t.Fatalf("DecodeControl() error = nil, want error")
				}
				if c != nil {
					t.Errorf("DecodeControl() control = %v, want nil on error", c)
				}
				return
			}
			if err != nil {
				t.Fatalf("DecodeControl() unexpected error: %v", err)
			}
			if c == nil {
				t.Fatalf("DecodeControl() control = nil, want non-nil")
			}
			if tt.check != nil {
				tt.check(t, c)
			}
		})
	}
}

Happy to open this as a PR against fix/cve2016 instead if that is easier to merge, just say the word. On (4): understood on the upstream situation, I will leave the sibling forks alone unless you want me to file heads-up issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants