String truncation as a service

Recently I bought a new Lexmark printer. I’m not sure why I decided to subject myself to the torture of being a printer-owner. Maybe it was because the library is closed due to COVID-19 and I have nowhere to print anymore. Maybe it was because I wanted to make an irresponsible purchase. Maybe it was because I felt the world needed a little more suffering in it.

I decided that if I was getting a nice printer, I was going to get a good one. One with toner instead of ink. One that could make copies. One that had SNMP support. One that would hopefully last more than 100 pages. I’ve confirmed that at least all three of those are true. I also found that the Lexmark MC3224dwe (and likely many other Lexmark printers) come with a very fun, undocumented feature: a string truncation service.

One of the features of the MC3224dwe is the ability to scan to email. The manual for the printer has very helpful instructions for configuring the SMTP settings on the device:

From the E-mail Setup section, configure the settings.

When setting this up, I decided that since the printer is shared by myself and roommates, the best thing to do would probably be to not have the emails come from my personal Gmail and that I should instead set up SendGrid or SES to configure the emails. For some reason, the printer was completely unable to authenticate to either of these services, despite me confirming on other tools that the credentials were absolutely functional.

As it turns out, Lexmark truncates the password you enter into the SMTP settings to 31 characters. This isn’t documented anywhere that I’ve seen. And rather than boring you with the process of how I discovered this, we’ll go over how to use it to our advantage and you’ll pick up a bit of my troubleshooting path along the way.

This is shorter than any possible credential that could be used for AWS SES. The credentials that I’ve generated through their Console wizard have been 44 characters. The SendGrid credentials that I’ve generally gotten have always been 69 characters but I haven’t seen this documented.

To verify that this would be an issue, I fired up my Python interpreter and ran a few quick tests.

>>> 69 > 31
True
>>> 44 > 31
True

To follow along with building our new string truncation service, you’ll need

  • A Lexmark MC3224dwe (or any of their other broken models, which may be all of them)
  • Python 3.5+

To really turn this into a string truncation service (and get our truncated strings), we’ll need to spin up a super minimal SMTP server that absolutely does not come close to complying with any RFC.

We can use Python’s asyncio module to build our server. And we’ll use click to give it a friendly CLI.

Upon connecting, the Lexmark printer will send an EHLO and then it will always try to AUTH LOGIN (even if you tell it you only support PLAIN). At the end it will send a QUIT message. The functions to handle these functions will be registered using a simple decorator:

handlers = {}

def register_handler(name):
    def decorated(func):
        handlers[name] = func
        def wrapped(*args, **kwargs):
            return func(*args, **kwargs)
        return wrapped
    return decorated

All of our handlers will be decorated with this, and they’ll pass the SMTP command that they handle. Each will accept 3 arguments, the StreamReader and StreamWriter that we get from the asyncio server, and a variable args that contains the arguments passed to the SMTP command. They’ll return True if the connection should continue and False if it shouldn’t.

The easiest of the commands to implement is QUIT. It just returns False.

@register_handler('QUIT')
async def handle_quit(reader, writer, args):
    return False

Our EHLO handler is extremely simple. It basically just needs to tell the client that AUTH LOGIN PLAIN is a support extension. We will not implement support for HELO since that’d be useless (we wouldn’t get a string to truncate).

@register_handler('EHLO')
async def handle_ehlo(reader, writer, args):
    message = [
        "250-truncate\n",
        "250 AUTH LOGIN PLAIN\n",
    ]
    message = [m.encode() for m in message]
    writer.writelines(message)
    await writer.drain()
    return True

The AUTH method can be either PLAIN or LOGIN. If the Lexmark printer does a PLAIN auth, it will pass the credentials as an argument; however, support for prompting is fairly trivial. Adding support for LOGIN is as well (especially if we do it lazily), so we can throw that in. There are a lot of calls to bytes.decode to turn the bytes we get from the stream into strings that Python 3 will be a bit happier interacting with.

The implementation of AUTH is a little longer, but overall not awful. It handles the various authentication methods that are supported and once the password is found, it prints it out. It always pretends that the password invalid because we really don’t want the printer to try and send an email since that’s not supported (because again, we’re writing a really terrible SMTP server).

import base64

@register_handler('AUTH')
async def handle_auth(reader, writer, args):
    if (not args) or (args[0] not in ('PLAIN', 'LOGIN')):
        writer.write("500 Only LOGIN/PLAIN are supported".encode())
        await writer.drain()
    
    method = args[0]

    if method == 'PLAIN':
        args.pop(0)
        if args:
            b64_creds = args[0]
        else:
            writer.write('334 '.encode())
            await writer.drain()
            b64_creds = (await reader.readline()).decode()

        creds = base64.b64decode(b64_creds).decode()
        _, _username, password, = creds.split('\0')
    elif method == 'LOGIN':
        # Prompt for the username
        # "Username:" -> VXNlcm5hbWU6
        writer.write('334 VXNlcm5hbWU6\n'.encode())
        await writer.drain()
        _username = (await reader.readline()).decode()
        # Prompt for the passworkd
        # "Password:" -> UGFzc3dvcmQ6
        writer.write('334 UGFzc3dvcmQ6\n'.encode())
        await writer.drain()
        b64_pass = (await reader.readline()).decode()
        password = base64.b64decode(b64_pass).decode()

    print(password)

    writer.write("535 Authentication credentials invalid\n".encode())
    await writer.drain()

    return True

Next, we’ll want a function to parse the commands the client sends and dispatch them to the handlers. It’ll accept the reader and writer that we got from the asyncio server and like the other handlers, it’ll return whether or not the connection should stay open.

async def handle_command(reader, writer):
    data = await reader.readline()
    message = data.decode()
    if not message:
        return False
    if message.endswith('\n'):
        message = message[:-1]
    command, *args = message.split()
    if command in handlers:
        return await handlers[command](reader, writer, args)
    else:
        writer.write(f'500 Error: command "{command}" not recognized\n'.encode())
        await writer.drain()
    return True

A function to open the connection and run our command handler in a loop will be useful. The function will also handle closing the connection once handle_command has returned False.

async def handle_connection(reader, writer):
    writer.write('220 String truncation service\n'.encode())
    await writer.drain()
    while await handle_command(reader, writer):
        pass
    
    writer.close()

Finally, we’ll want a function that will start our server as well as a main function with click decorators to support various CLI arguments.

import asyncio
import click

async def server_func(host, port):
    server = await asyncio.start_server(handle_connection, host, port)
    async with server:
        await server.serve_forever()
    

@click.command()
@click.option(
    '--host',
    help="The address to listen on",
    default="127.0.0.1",
)
@click.option(
    '--port',
    help="The port to listen on",
    type=click.INT,
    default=8025,
)
def main(host, port):
    asyncio.run(server_func(host, port))


if __name__ == '__main__':
    main()

So once you put it together you end up with a script you can execute by running

./server.py --host 0.0.0.0 --port 8025

which will listen on all interfaces on port 8025 for requests. Once it receives a login attempt, it will just print out the passwords.

With the server in place and running, the only thing left is to configure the Lexmark printer to send the truncated strings somewhere. This ends up proving to be fairly trivial. Just browse to the printer’s web interface and click “E-mail” in the navigation bar on the right (I learned this from that very helpful manual that was linked to earlier). The primary SMTP gateway will be the IP address of the machine that the server is listening on. Do not use SSL/TLS as our server does not support that. Only send non-sensitive strings to truncate.

The “Device Userid” needs to be specified, but it can be any value. Our server completely ignores it. The “Device Password’ is the field that gets truncated, so put you’re string there and hit “Save”.

Printer settings screenshot

On the printer (or using the remote operator panel), go to “E-mail”, tap the “To:” field and enter any email addres. Press “Add” and “Done”. Now you’re ready to have the printer send your truncated string to the server. Pressing start will have the printer initiate the SMTP connection and the truncated string should be in the standard ouput on the console.

For example, if you put “WHY THE HELL DOES LEXMARK TRUNCATE PASSWORDS” into the “Device Password” field, you’ll see

Standard out with the text "WHY THE HELL DOES LEXMARK TRUNC"

In the end, I am disappointed that my printer can’t really send emails using the services I’d like to use, but I do appreciate my ability to send truncated strings across my network to any device with just a bit of configuration.

The script probably could use a bit of work. It doesn’t set timeouts and implementing STARTTLS support really would be the icing on top of the cake for taking this just enough past too far. You’ll want to be careful doing this too much unless you turn off printing reports on the printer. This can be done in Settings > E-mail > E-mail Defaults > Admin Controls and flipping “Transmission Log” to “Do Not Print Log”.