Skip to content

Malformed NMEA input escapes the documented exception hierarchy and ends stream iteration (IndexError / UnicodeDecodeError / ValueError / TypeError) #227

Closed

Description

@rifatkinali

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 range

NMEASentenceFactory.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 >= 0

chk_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_messages

buffer[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 value

Both 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

  1. self-assigned this
    on Oct 11, 2026
  2. rifatkinali commented on Oct 11, 2026

    @rifatkinali
    ContributorAuthor

    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_dict on 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.

  3. M0r13n commented on Oct 11, 2026

    @M0r13n
    Owner

    @rifatkinali

    Thanks for the report and your feedback!

  4. M0r13n commented on Oct 11, 2026

    @M0r13n
    Owner

    Merged #231. Closing.

  5. M0r13n commented on Oct 11, 2026

    @M0r13n
    Owner

    Part of v3.3.1

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingenhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions