I was able to return a string by creating it in memory and just passing an pointer and size of the string to python. I created a example project where I tested I few functions to return values before I implemented them: The example below is where I returned a string array, I made some changed to code below to fit the Q so I may have made a mistake somewhere.
C#:
    [DllExport]
    static void StringArrayExample(out IntPtr unmanagedArray, out int length)//for ref value you have to use out
    {
        var valueList = new[]
        {
            "aaa", "bbb", "ccc"
        };
        length = valueList.Length;//return the length of the array
        IntPtr[] ArrayPtr = new IntPtr[valueList.Length];
        for (int i = 0; i < valueList.Length; i++)//create list of string arrays and 
        {
            byte[] chars = System.Text.Encoding.ASCII.GetBytes(valueList[i] + '\0');//convert string to byte array
            ArrayPtr[i] = Marshal.AllocHGlobal(chars.Length * Marshal.SizeOf(typeof(char)));//allocate memory to char array, return ptr to location
            Marshal.Copy(chars, 0, ArrayPtr[i], chars.Length);//copy char array to memory location
        }
        //Create memory location to store pointers
        unmanagedArray = Marshal.AllocHGlobal(valueList.Length * Marshal.SizeOf(typeof(IntPtr)));
        //copy over all pointer to memory location
        Marshal.Copy(ArrayPtr, 0, unmanagedArray, ArrayPtr.Length);
    }
and the python function to call this function:
class Arrays is the data structure that I used for a string array.
Cdll = ctypes.WinDLL("CtypesExamples.dll")
class Arrays(ctypes.Structure):# 2 string arrays
   _fields_ = [ ('arr1', ctypes.POINTER(ctypes.c_char_p)),
                ('size1', ctypes.c_int),
                ('arr2', ctypes.POINTER(ctypes.c_char_p)),
                ('size2', ctypes.c_int)]
def StringArray(self, dllfunc, stringlist):
    unmanagedArray = ctypes.POINTER(ctypes.c_char_p)()#create ctypes pointer
    length = ctypes.POINTER(ctypes.c_int)()#create ctypes int
    #call function with the ctypes pointer and int as ref
    dllfunc(ctypes.byref(unmanagedArray), ctypes.byref(length))
    INTP = ctypes.POINTER(ctypes.c_int)#pointer to int
    addr = ctypes.addressof(length)#memory address of int
    arraylength = ctypes.cast(addr, INTP)[0]#cast the pointer to int to a python variable
    for i in range(0,arraylength):
        stringlist.append(ctypes.string_at(unmanagedArray[i]).decode(encoding='UTF-8'))#decode convert from bits to string
    return arraylength
#send list to c# dll
stringlist =list()
arraylength = self.StringArray(Cdll.StringArrayExample, stringlist)
print(str(arraylength)+" "+str(stringlist[0])+" "+str(stringlist[1])+" "+str(stringlist[2]))