Skip to content

Commit 5682a4c

Browse files
authored
Add support for forward dns compression pointers (#934)
- nslookup supports these and some implementations (likely avahi) will generate them - Careful attention was given to make sure we detect loops and do not create anti-patterns described in https://github.com/Forescout/namewreck/blob/main/rfc/draft-dashevskyi-dnsrr-antipatterns-00.txt Fixes home-assistant/core#53937 Fixes home-assistant/core#46985 Fixes home-assistant/core#53668 Fixes #308
1 parent 319992b commit 5682a4c

2 files changed

Lines changed: 346 additions & 82 deletions

File tree

tests/test_protocol.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,10 @@ def test_dns_compression_rollback_for_corruption():
632632
# ensure there is no corruption with the dns compression
633633
incoming = r.DNSIncoming(packet)
634634
assert incoming.valid is True
635+
assert (
636+
len(incoming.answers)
637+
== incoming.num_answers + incoming.num_authorities + incoming.num_additionals
638+
)
635639

636640

637641
def test_tc_bit_in_query_packet():
@@ -761,3 +765,225 @@ def test_records_same_packet_share_fate():
761765
first_time = dnsin.answers[0].created
762766
for answer in dnsin.answers:
763767
assert answer.created == first_time
768+
769+
770+
def test_dns_compression_invalid_skips_bad_name_compress_in_question():
771+
"""Test our wire parser can skip bad compression in questions."""
772+
packet = (
773+
b'\x00\x00\x00\x00\x00\x04\x00\x00\x00\x07\x00\x00\x11homeassistant1128\x05l'
774+
b'ocal\x00\x00\xff\x00\x014homeassistant1128 [534a4794e5ed41879ecf012252d3e02'
775+
b'a]\x0c_workstation\x04_tcp\xc0\x1e\x00\xff\x00\x014homeassistant1127 [534a47'
776+
b'94e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x014homeassistant1123 [534a479'
777+
b'4e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x014homeassistant1118 [534a4794'
778+
b'e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x01\xc0\x0c\x00\x01\x80'
779+
b'\x01\x00\x00\x00x\x00\x04\xc0\xa8<\xc3\xc0v\x00\x10\x80\x01\x00\x00\x00'
780+
b'x\x00\x01\x00\xc0v\x00!\x80\x01\x00\x00\x00x\x00\x1f\x00\x00\x00\x00'
781+
b'\x00\x00\x11homeassistant1127\x05local\x00\xc0\xb1\x00\x10\x80'
782+
b'\x01\x00\x00\x00x\x00\x01\x00\xc0\xb1\x00!\x80\x01\x00\x00\x00x\x00\x1f'
783+
b'\x00\x00\x00\x00\x00\x00\x11homeassistant1123\x05local\x00\xc0)\x00\x10\x80'
784+
b'\x01\x00\x00\x00x\x00\x01\x00\xc0)\x00!\x80\x01\x00\x00\x00x\x00\x1f'
785+
b'\x00\x00\x00\x00\x00\x00\x11homeassistant1128\x05local\x00'
786+
)
787+
parsed = r.DNSIncoming(packet)
788+
assert len(parsed.questions) == 4
789+
790+
791+
def test_dns_compression_all_invalid():
792+
"""Test our wire parser can skip all invalid data."""
793+
packet = (
794+
b'\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00!roborock-vacuum-s5e_miio416'
795+
b'112328\x00\x00/\x80\x01\x00\x00\x00x\x00\t\xc0P\x00\x05@\x00\x00\x00\x00'
796+
)
797+
parsed = r.DNSIncoming(packet)
798+
assert len(parsed.questions) == 0
799+
assert len(parsed.answers) == 0
800+
801+
802+
def test_invalid_next_name_ignored():
803+
"""Test our wire parser does not throw an an invalid next name.
804+
805+
The RFC states it should be ignored when used with mDNS.
806+
"""
807+
packet = (
808+
b'\x00\x00\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x07Android\x05local\x00\x00'
809+
b'\xff\x00\x01\xc0\x0c\x00/\x00\x01\x00\x00\x00x\x00\x08\xc02\x00\x04@'
810+
b'\x00\x00\x08\xc0\x0c\x00\x01\x00\x01\x00\x00\x00x\x00\x04\xc0\xa8X<'
811+
)
812+
parsed = r.DNSIncoming(packet)
813+
assert len(parsed.questions) == 1
814+
assert len(parsed.answers) == 2
815+
816+
817+
def test_dns_compression_invalid_skips_record():
818+
"""Test our wire parser can skip records we do not know how to parse."""
819+
packet = (
820+
b"\x00\x00\x84\x00\x00\x00\x00\x06\x00\x00\x00\x00\x04_hap\x04_tcp\x05local\x00\x00\x0c"
821+
b"\x00\x01\x00\x00\x11\x94\x00\x16\x13eufy HomeBase2-2464\xc0\x0c\x04Eufy\xc0\x16\x00/"
822+
b"\x80\x01\x00\x00\x00x\x00\x08\xc0\xa6\x00\x04@\x00\x00\x08\xc0'\x00/\x80\x01\x00\x00"
823+
b"\x11\x94\x00\t\xc0'\x00\x05\x00\x00\x80\x00@\xc0=\x00\x01\x80\x01\x00\x00\x00x\x00\x04"
824+
b"\xc0\xa8Dp\xc0'\x00!\x80\x01\x00\x00\x00x\x00\x08\x00\x00\x00\x00\xd1_\xc0=\xc0'\x00"
825+
b"\x10\x80\x01\x00\x00\x11\x94\x00K\x04c#=1\x04ff=2\x14id=38:71:4F:6B:76:00\x08md=T8010"
826+
b"\x06pv=1.1\x05s#=75\x04sf=1\x04ci=2\x0bsh=xaQk4g=="
827+
)
828+
parsed = r.DNSIncoming(packet)
829+
answer = r.DNSNsec(
830+
'eufy HomeBase2-2464._hap._tcp.local.',
831+
const._TYPE_NSEC,
832+
const._CLASS_IN | const._CLASS_UNIQUE,
833+
const._DNS_OTHER_TTL,
834+
'eufy HomeBase2-2464._hap._tcp.local.',
835+
[const._TYPE_TXT, const._TYPE_SRV],
836+
)
837+
assert answer in parsed.answers
838+
839+
840+
def test_dns_compression_points_forward():
841+
"""Test our wire parser can unpack nsec records with compression."""
842+
packet = (
843+
b"\x00\x00\x84\x00\x00\x00\x00\x07\x00\x00\x00\x00\x0eTV Beneden (2)"
844+
b"\x10_androidtvremote\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x11"
845+
b"\x94\x00\x15\x14bt=D8:13:99:AC:98:F1\xc0\x0c\x00/\x80\x01\x00\x00\x11"
846+
b"\x94\x00\t\xc0\x0c\x00\x05\x00\x00\x80\x00@\tAndroid-3\xc01\x00/\x80"
847+
b"\x01\x00\x00\x00x\x00\x08\xc0\x9c\x00\x04@\x00\x00\x08\xc0l\x00\x01\x80"
848+
b"\x01\x00\x00\x00x\x00\x04\xc0\xa8X\x0f\xc0\x0c\x00!\x80\x01\x00\x00\x00"
849+
b"x\x00\x08\x00\x00\x00\x00\x19B\xc0l\xc0\x1b\x00\x0c\x00\x01\x00\x00\x11"
850+
b"\x94\x00\x02\xc0\x0c\t_services\x07_dns-sd\x04_udp\xc01\x00\x0c\x00\x01"
851+
b"\x00\x00\x11\x94\x00\x02\xc0\x1b"
852+
)
853+
parsed = r.DNSIncoming(packet)
854+
answer = r.DNSNsec(
855+
'TV Beneden (2)._androidtvremote._tcp.local.',
856+
const._TYPE_NSEC,
857+
const._CLASS_IN | const._CLASS_UNIQUE,
858+
const._DNS_OTHER_TTL,
859+
'TV Beneden (2)._androidtvremote._tcp.local.',
860+
[const._TYPE_TXT, const._TYPE_SRV],
861+
)
862+
assert answer in parsed.answers
863+
864+
865+
def test_dns_compression_points_to_itself():
866+
"""Test our wire parser does not loop forever when a compression pointer points to itself."""
867+
packet = (
868+
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01"
869+
b"\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\xc0(\x00\x01\x80\x01\x00\x00\x00"
870+
b"\x01\x00\x04\xc0\xa8\xd0\x06"
871+
)
872+
parsed = r.DNSIncoming(packet)
873+
assert len(parsed.answers) == 1
874+
875+
876+
def test_dns_compression_points_beyond_packet():
877+
"""Test our wire parser does not fail when the compression pointer points beyond the packet."""
878+
packet = (
879+
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01'
880+
b'\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\xe7\x0f\x00\x01\x80\x01\x00\x00'
881+
b'\x00\x01\x00\x04\xc0\xa8\xd0\x06'
882+
)
883+
parsed = r.DNSIncoming(packet)
884+
assert len(parsed.answers) == 1
885+
886+
887+
def test_dns_compression_generic_failure():
888+
"""Test our wire parser does not loop forever when dns compression is corrupt."""
889+
packet = (
890+
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01'
891+
b'\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05-\x0c\x00\x01\x80\x01\x00\x00'
892+
b'\x00\x01\x00\x04\xc0\xa8\xd0\x06'
893+
)
894+
parsed = r.DNSIncoming(packet)
895+
assert len(parsed.answers) == 1
896+
897+
898+
def test_label_length_attack():
899+
"""Test our wire parser does not loop forever when the name exceeds 253 chars."""
900+
packet = (
901+
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x01d\x01d\x01d\x01d\x01d\x01d'
902+
b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d'
903+
b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d'
904+
b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d'
905+
b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d'
906+
b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d'
907+
b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d'
908+
b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d'
909+
b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x00\x00\x01\x80'
910+
b'\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\xc0\x0c\x00\x01\x80\x01\x00\x00\x00'
911+
b'\x01\x00\x04\xc0\xa8\xd0\x06'
912+
)
913+
parsed = r.DNSIncoming(packet)
914+
assert len(parsed.answers) == 0
915+
916+
917+
def test_label_compression_attack():
918+
"""Test our wire parser does not loop forever when exceeding the maximum number of labels."""
919+
packet = (
920+
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x03atk\x00\x00\x01\x80'
921+
b'\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\x03atk\x03atk\x03atk\x03atk\x03'
922+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
923+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
924+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
925+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
926+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
927+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
928+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
929+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
930+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
931+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
932+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
933+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
934+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
935+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
936+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
937+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
938+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
939+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03'
940+
b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\xc0'
941+
b'\x0c\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x06'
942+
)
943+
parsed = r.DNSIncoming(packet)
944+
assert len(parsed.answers) == 1
945+
946+
947+
def test_dns_compression_loop_attack():
948+
"""Test our wire parser does not loop forever when dns compression is in a loop."""
949+
packet = (
950+
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07\x03atk\x03dns\x05loc'
951+
b'al\xc0\x10\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\x04a'
952+
b'tk2\x04dns2\xc0\x14\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05'
953+
b'\x04atk3\xc0\x10\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0'
954+
b'\x05\x04atk4\x04dns5\xc0\x14\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0'
955+
b'\xa8\xd0\x05\x04atk5\x04dns2\xc0^\x00\x01\x80\x01\x00\x00\x00\x01\x00'
956+
b'\x04\xc0\xa8\xd0\x05\xc0s\x00\x01\x80\x01\x00\x00\x00\x01\x00'
957+
b'\x04\xc0\xa8\xd0\x05\xc0s\x00\x01\x80\x01\x00\x00\x00\x01\x00'
958+
b'\x04\xc0\xa8\xd0\x05'
959+
)
960+
parsed = r.DNSIncoming(packet)
961+
assert len(parsed.answers) == 0
962+
963+
964+
def test_txt_after_invalid_nsec_name_still_usable():
965+
"""Test that we can see the txt record after the invalid nsec record."""
966+
packet = (
967+
b'\x00\x00\x84\x00\x00\x00\x00\x06\x00\x00\x00\x00\x06_sonos\x04_tcp\x05loc'
968+
b'al\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x15\x12Sonos-542A1BC9220E'
969+
b'\xc0\x0c\x12Sonos-542A1BC9220E\xc0\x18\x00/\x80\x01\x00\x00\x00x\x00'
970+
b'\x08\xc1t\x00\x04@\x00\x00\x08\xc0)\x00/\x80\x01\x00\x00\x11\x94\x00'
971+
b'\t\xc0)\x00\x05\x00\x00\x80\x00@\xc0)\x00!\x80\x01\x00\x00\x00x'
972+
b'\x00\x08\x00\x00\x00\x00\x05\xa3\xc0>\xc0>\x00\x01\x80\x01\x00\x00\x00x'
973+
b'\x00\x04\xc0\xa8\x02:\xc0)\x00\x10\x80\x01\x00\x00\x11\x94\x01*2info=/api'
974+
b'/v1/players/RINCON_542A1BC9220E01400/info\x06vers=3\x10protovers=1.24.1\nbo'
975+
b'otseq=11%hhid=Sonos_rYn9K9DLXJe0f3LP9747lbvFvh;mhhid=Sonos_rYn9K9DLXJe0f3LP9'
976+
b'747lbvFvh.Q45RuMaeC07rfXh7OJGm<location=http://192.168.2.58:1400/xml/device_'
977+
b'description.xml\x0csslport=1443\x0ehhsslport=1843\tvariant=2\x0emdnssequen'
978+
b'ce=0'
979+
)
980+
parsed = r.DNSIncoming(packet)
981+
# The NSEC record with the invalid name compression should be skipped
982+
assert parsed.answers[4].text == (
983+
b'2info=/api/v1/players/RINCON_542A1BC9220E01400/info\x06vers=3\x10protovers'
984+
b'=1.24.1\nbootseq=11%hhid=Sonos_rYn9K9DLXJe0f3LP9747lbvFvh;mhhid=Sonos_rYn'
985+
b'9K9DLXJe0f3LP9747lbvFvh.Q45RuMaeC07rfXh7OJGm<location=http://192.168.2.58:14'
986+
b'00/xml/device_description.xml\x0csslport=1443\x0ehhsslport=1843\tvarian'
987+
b't=2\x0emdnssequence=0'
988+
)
989+
assert len(parsed.answers) == 5

0 commit comments

Comments
 (0)