Repository navigation
Malformed NMEA input escapes the documented exception hierarchy and ends stream iteration (IndexError / UnicodeDecodeError / ValueError / TypeError) #227
Description
Hi, while fuzzing pyais 3.3.0 with Atheris (coverage-guided libFuzzer for Python) as part of
a maritime-security research project I found six input classes that raise plain Python
errors instead of InvalidNMEAMessageException. Five of them are reachable from the
socket readers and terminate the iteration, because _assemble_messages() only catches
InvalidNMEAMessageException and UnknownMessageException.
All of them reproduce on main (8d0d0ac) and on 3.3.0 from PyPI.
1. Whitespace-only line → IndexError
>>> pyais.decode(b" ")
IndexError: index out of range
>>> list(IterMessages([b"\n", b"!AIVDM,1,1,,A,13aEOK?P00PD2wVMdLDRhgvL289?,0*26"]))
IndexError: index out of rangeNMEASentenceFactory.produce() tests len(raw) == 0 before raw.strip(), then
indexes the stripped (now empty) bytes. TCPConnection/UDPReceiver are not affected
because Stream._iter_messages() drops short lines first; decode() and IterMessages
(callers that feed their own lines, e.g. from socket.makefile()) are.
2. Non-ASCII byte in the talker id → UnicodeDecodeError, ends UDPReceiver/TCPConnection
>>> pyais.decode(b"!\xffIVDM,1,1,,A,13aEOK?P00PD2wVMdLDRhgvL289?,0*26")
UnicodeDecodeError: 'ascii' codec can't decode byte 0xff in position 0_split_tag() calls .decode('ascii') on the first field without validation. A single
UDP datagram with that content ends a running UDPReceiver iteration (verified with a
loopback receiver). The same byte in the payload is accepted by decode() but makes
NMEAMessage(raw).asdict() and .decode_and_merge() raise.
3. Fill bits outside 0..5 → ValueError, reachable from the socket readers
>>> pyais.decode(b"!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,-1*5C")
ValueError: negative shift count
>>> pyais.decode(b"!AIVDM,1,1,,A,?5OP,152*5B")
ValueError: __len__() should return >= 0chk_to_int() accepts any integer for the fill-bits field; the bit vector then shifts by
a negative amount or ends up with a negative length.
4. Tag block with a malformed checksum field → ValueError through TagBlockQueue
>>> tbq = TagBlockQueue()
>>> list(IterMessages([b"\\g:1-2-1,s:FooBar*1C*00\\!AIVDM,1,1,,A,13nN34?000QFpgRWnQLLSPpF00SO,0*1C"], tbq=tbq))
ValueError: too many values to unpack (expected 2)TagBlock.init() does payload, check = self.raw.split(b'*'); two asterisks, none, or a
non-hex checksum raise. TagBlockQueue.put_sentence() calls init() inside the stream
loop, so one such tag block on the wire ends the stream.
5. Fragment number below -255 → IndexError in the stream loop
>>> list(ByteStream([b"!AIVDM,2,-300,4,A,55O0W7`00001L@gCWGA2uItLth@DqtL5@F22220j1h742t0Ht0000000,0*08\n"]))
IndexError: list assignment index out of range # stream.py, _assemble_messagesbuffer[slot][msg.frag_num - 1] = msg with a negative number indexes from the end; between
-1 and -255 the part is silently stored in the wrong slot, below that it raises.
6. Empty checksum input → TypeError from checksum() / compute_checksum()
>>> list(IterMessages([b"\\*1C\\!AIVDM,1,1,,A,13aEOK?P00PD2wVMdLDRhgvL289?,0*26"], tbq=TagBlockQueue()))
TypeError: reduce() of empty iterable with no initial value
>>> NMEAMessage(b"!*5M,1,1,,A,13aE,0*26").is_valid
TypeError: reduce() of empty iterable with no initial valueBoth helpers call reduce(xor, data) without an initial value.
Severity
Robustness, not memory safety: an unhandled exception in a long-running receiver. No CVE
intended. I have a PR ready that makes all six raise InvalidNMEAMessageException
(tag blocks: parsed with is_valid == False), with tests for decode(),
NMEAMessage(), IterMessages and TagBlockQueue; the full suite, flake8 and mypy pass.
Happy to split it per item if you prefer smaller PRs.
Activity
chore: borrow test cases from #227
fix: various low-severity bugs found by Claude Mythos 5.1 (#230)
rifatkinali commentedon Oct 11, 2026
Verified on main (fc08fcb) against our fuzzing corpus: 17 of the 18 minimal reproducers behind this report are now rejected with
InvalidNMEAMessageException, and all 158 real-sentence seeds still decode, so the stricter validation does not break live traffic.The one remaining reproducer is #228 (
encode_dicton a truncated type 6/8/24 message), which #230 does not touch; I will send a small, focused PR for that one.Thanks for the quick fix and for the credit in the changelog.
M0r13n commentedon Oct 11, 2026
@rifatkinali
Thanks for the report and your feedback!
M0r13n commentedon Oct 11, 2026
Merged #231. Closing.
M0r13n commentedon Oct 11, 2026
Part of v3.3.1