I believe I was confused because, in your third example, the pointers values are reversed, so I didn't realize they were supposed to be int32s counting from after the two pointers. With your given examples (ignoring the flipped pointers), this works:
In [27]: s = Struct(
...: 'e' / Int32ub,
...: 'c' / Int32ub,
...: 'exe' / Pointer(this.e + 8, CString(encoding='utf8')),
...: 'cwd' / Pointer(this.c + 8, CString(encoding='utf8')))
In [28]: c = s.parse(b'\x00\x00\x00\x06\x00\x00\x00\x10xxxxxxd/e/f\x00xxxxa/b/c\x00')
In [29]: c.exe
Out[29]: 'd/e/f'
In [30]: c.cwd
Out[30]: 'a/b/c'
In [31]: c = s.parse(b'\x00\x00\x00\x06\x00\x00\x00\x00d/e/f\x00a/b/c\x00')
In [32]: c.exe
Out[32]: 'a/b/c'
In [33]: c.cwd
Out[33]: 'd/e/f'
While it technically may be possible to create something in construct which would figure out the pointers, it's much more sane to simply make a helper function to do it (just watch out for off-by-1 errors):
In [38]: def data(exe, cwd):
...: return {'exe': exe, 'cwd': cwd, 'e': 0, 'c': len(exe)+1}
...:
In [39]: s.build(data('a/b/c', 'd/e/f'))
Out[39]: b'\x00\x00\x00\x00\x00\x00\x00\x05a/b/c\x00d/e/f\x00'
Or:
In [40]: def data(exe, cwd):
...: return {'exe': exe, 'cwd': cwd, 'e': len(cwd)+1, 'c': 0}
...:
In [41]: s.build(data('a/b/c', 'd/e/f'))
Out[41]: b'\x00\x00\x00\x05\x00\x00\x00\x00d/e/f\x00a/b/c\x00'
Interestingly, Construct will even fill extra space with null:
In [42]: def data(exe, cwd):
...: return {'exe': exe, 'cwd': cwd, 'e': 5+len(cwd)+1, 'c': 2}
...:
In [42]: s.build(data('a/b/c', 'd/e/f'))
Out[42]: b'\x00\x00\x00\x0b\x00\x00\x00\x02\x00\x00d/e/f\x00\x00\x00\x00a/b/c\x00'