Amateur cryptography: decrypting TVT CCTV camera config files

This is real case: I exported config data from some old TVT CCTV camera, and the file looked like this:

kjof{v^`yj'>+<+2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 27%7 pai p{y {c||u |c|}pL{h3wpc7%72 2 2 2 2
2 2 2 2 2 2 2 2 2 2 2 2 2^M
!0ebq/kjo|t`s2?>3??/xa~`yfsh -H[["%-"1
3~`sith=yx}nfra -,!--=wpcs| -u{i^?' 2xjx3fml3lrb2yx},??1
    !{d^?x|#
        !nqnobTaK`q{|hx1
            !jszp1S@! xahb#
            3xahb#A^32jszp1
        32nqnobTaK`q{|hx1
        3r|~@^?exli\inizn1
            !jszp1|m|ayzp32jszp1
            !jszp1rmw|i`qjs32jszp1
        32`nlRmwj~{N{|{h|#
    32{d^?x|#
    3njs|r}={d^?x2?ct|i-=lrzs{ -,-#
        !fijp1
            !fy/ivmj -hfs{.=?1,32fy1
            !|xan`oA|bx/ivmj -n{ofsh?/pneCxa -,>?1!.FLYNINFR@1! njs|r}Snpj#
            3nxt{~g={d^?x2?mr`qj|a?1{nq|x32|jfilu1
            !yrcinzj={d^?x2?nqnobTaK`q{|hx-#AR32yrcinzj#
            3|c|}pGrcy[tbx/ivmj -hfs{.=?1/?! |c|}pGrcy[tbx1
...

Clearly, this has some XML form. And I suspect the key has 2-byte length (see the first line).

Let's try all 2-byte XOR keys, and print only (decrypted) strings where all characters are printable:

#!/usr/bin/env python3

from __future__ import print_function
import sys, struct

KEYLEN=2

def xor_strings(s,t):
    # https://en.wikipedia.org/wiki/XOR_cipher#Example_implementation
    """xor two strings together"""
    return b"".join(bytes([a^b]) for a,b in zip(s,t))

def read_file(fname):
    file=open(fname, mode='rb')
    content=file.read()
    file.close()
    return content

def chunks(l, n):
    """divide input buffer by n-len chunks"""
    n = max(1, n)
    return [l[i:i + n] for i in range(0, len(l), n)]

#cipher_file=read_file(sys.argv[1])
cipher_file=b"!|xan`oA|bx/ivmj -n{ofsh?/pneCxa -,>?1!.FLYNINFR@1! njs|r}Snpj#"

def is_printable(t):
    for c in t:
        if c<0x20:
            return False
        if c>=0x80:
            return False
    return True

for i in range(256):
    for j in range(256):
        possible_key=struct.pack("BB", i, j)

        tmp=chunks(cipher_file, KEYLEN)
        plain=b"".join(map(lambda x: xor_strings(x, possible_key), tmp))
        if is_printable(plain):
            print (i,j,plain)

The resulting list has 1024 possible keys:

0 0 b'!|xan`oA|bx/ivmj -n{ofsh?/pneCxa -,>?1!.FLYNINFR@1! njs|r}Snpj#'
0 1 b'!}x`nao@|cx.iwmk ,nzogsi?.poeBx` ,,??0!/FMYOIOFS@0!!nks}r|Sopk#'
0 2 b'!~xcnboC|`x-itmh /nyodsj?-pleAxc /,<?3!,FNYLILFP@3!"nhs~r\x7fSlph#'
0 3 b'!\x7fxbncoB|ax,iumi .nxoesk?,pme@xb .,=?2!-FOYMIMFQ@2!#nis\x7fr~Smpi#'
0 4 b'!xxendoE|fx+irmn )n\x7fobsl?+pjeGxe ),:?5!*FHYJIJFV@5!$nnsxrySjpn#'
...
29 12 b'<pemslrMane#tzpf=!swrjnd"#mbxOem=!12"=<"[@DBTB[^]=<,sfnpoqNbmf>'
29 13 b'<qelsmrLaoe"t{pg= svrkne""mcxNel= 13"<<#[ADCTC[_]<<-sgnqopNcmg>'
29 14 b'<reosnrOale!txpd=#surhnf"!m`xMeo=#10"?< [BD@T@[\\]?<.sdnrosN`md>'
29 15 b'<sensorName type="string" maxLen="11"><![CDATA[]]></sensorName>'
...

It can be browsed manually and checked for meaningful data. Now we got decryption key: 29 15. But during further inspection I've found that some bytes are not encrypted: 0x09 (tab), 0x0A (newline) -- and this is why encrypted file preserves some visual structure.

Let's decrypt:

#!/usr/bin/env python3

from __future__ import print_function
import sys, struct

KEYLEN=2

def xor_strings(s,t):
    # https://en.wikipedia.org/wiki/XOR_cipher#Example_implementation
    """xor two strings together"""
    return b"".join(bytes([a^b]) for a,b in zip(s,t))

def read_file(fname):
    file=open(fname, mode='rb')
    content=file.read()
    file.close()
    return content

def chunks(l, n):
    """divide input buffer by n-len chunks"""
    n = max(1, n)
    return [l[i:i + n] for i in range(0, len(l), n)]

cipher_file=read_file(sys.argv[1])

k=29 # current state of k
for c in cipher_file:
    if c>=0x20:
        sys.stdout.write (chr(c^k))
    else:
        sys.stdout.write (chr(c)) # skip unencrypted parts
    if k==29:
        k=15
    elif k==15:
        k=29

The output:

verifyCode:1636===================================***/mnt/mtd/flash/alarmCfg.xml***===================================
<?xml version="1.0" encoding="UTF-8"?>
<config version="1.0" xmlns="http://www.ipc.com/ver10">
    <types>
        <alarmInVoltage>
            <enum>NO</enum>
            <enum>NC</enum>
        </alarmInVoltage>
        <oscObjectStatus>
            <enum>abandum</enum>
            <enum>objstolen</enum>
        </oscObjectStatus>
    </types>
    <sensor type="list" count="1">
        <item>
            <id type="uint32">1</id>
            <sensorName type="string" maxLen="11"><![CDATA[]]></sensorName>
            <switch type="boolean">false</switch>
            <voltage type="alarmInVoltage">NO</voltage>
            <alarmHoldTime type="uint32">20</alarmHoldTime>
            <triggerAlarmOut type="list" count="1">
                <itemType type="boolean"/>
                <item id="1">false</item>
...

All the files.

A practicing reverse engineering should solve such tasks without noticable effort.

(the post first published at 20260629.)


List of my other blog posts. Subscribe to my news feed,
If you noticed a typo/bug/error or have any suggestions, do not hesitate to drop me a note: my emails. Or use my zulip for feedback. Thanks in advance!
Also, among my services is writing examples-rich manuals, references and help files. If you like my work and want something similar for your (commercial) product: contact me.
If you enjoy my work, you can support it on patreon.
Some time ago (before 24-Mar-2025) there was Disqus JS script for comments. I dropped it --- it was so motley, distracting, animated, with too much ads. I never liked it. Also, comments din't appeared correctly (Disqus was buggy). Also, my blog is too chamberlike --- not many people write comments here. So I decided to switch to the model I once had at least in 2020 --- send me your comments by email (don't forget to include URL to this blog post) and I will copy&paste it here manually.
Let's party like it's ~1993-1996, in this ultimate, radical and uncompromisingly primitive pre-web1.0-style blog and website. This website is best viewed under lynx/links/elinks/w3m.